Artifact a7ec198f099b252868b2d87cb2448538719d6f3e4cb9627ae194d0112d24aa04:


<?php

namespace Garradin\Entities\Web;

use Garradin\DB;
use Garradin\Entity;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Entities\Files\File;
use Garradin\Files\Files;
use Garradin\Web\Render\Render;

use KD2\DB\EntityManager as EM;

use const Garradin\WWW_URL;

class Page extends Entity
{
	const TABLE = 'web_pages';

	protected $id;
	protected $parent;
	protected $path;
	protected $uri;
	protected $_name = 'index.txt';
	protected $file_path;
	protected $title;
	protected $type;
	protected $status;
	protected $format;
	protected $published;
	protected $modified;
	protected $content;

	protected $_types = [
		'id'        => 'int',
		'parent'    => 'string',
		'path'      => 'string',
		'uri'       => 'string',
		'file_path' => 'string',
		'title'     => 'string',
		'type'      => 'int',
		'status'    => 'string',
		'format'    => 'string',
		'published' => 'DateTime',
		'modified'  => 'DateTime',
		'content'   => 'string',
	];

	const FORMAT_SKRIV = 'skriv';
	const FORMAT_ENCRYPTED = 'skriv/encrypted';
	const FORMAT_MARKDOWN = 'markdown';

	const FORMATS_LIST = [
		self::FORMAT_SKRIV => 'SkrivML',
		self::FORMAT_MARKDOWN => 'MarkDown',
		self::FORMAT_ENCRYPTED => 'Chiffré',
	];

	const STATUS_ONLINE = 'online';
	const STATUS_DRAFT = 'draft';

	const STATUS_LIST = [
		self::STATUS_ONLINE => 'En ligne',
		self::STATUS_DRAFT => 'Brouillon',
	];

	const TYPE_CATEGORY = 1;
	const TYPE_PAGE = 2;

	const TEMPLATES = [
		self::TYPE_PAGE => 'article.html',
		self::TYPE_CATEGORY => 'category.html',
	];

	protected $_file;
	protected $_attachments;

	static public function create(int $type, ?string $parent, string $title, string $status = self::STATUS_ONLINE): self
	{
		$page = new self;
		$data = compact('type', 'parent', 'title', 'status');
		$data['content'] = '';

		$page->importForm($data);
		$page->published = new \DateTime;
		$page->modified = new \DateTime;
		$page->type = $type;

		$db = DB::getInstance();
		if ($db->test(self::TABLE, 'uri = ?', $page->uri)) {
			$page->importForm(['uri' => $page->uri . date('-Y-m-d-His')]);
		}

		$page->file_path = $page->filepath(false);

		return $page;
	}

	public function file(bool $force_reload = false)
	{
		if (null === $this->_file || $force_reload) {
			$this->_file = Files::get($this->filepath());
		}

		return $this->_file;
	}

	public function load(array $data): void
	{
		parent::load($data);

		if ($this->file() && $this->file()->modified != $this->modified) {
			$this->loadFromFile($this->file());
			$this->save();
		}
	}

	public function url(): string
	{
		return WWW_URL . $this->uri;
	}

	public function template(): string
	{
		return self::TEMPLATES[$this->type];
	}

	public function asTemplateArray(): array
	{
		$out = $this->asArray();
		$out['url'] = $this->url();
		$out['html'] = $this->render();
		return $out;
	}

	public function render(array $options = []): string
	{
		if (!$this->file()) {
			throw new \LogicException('File does not exist: '  . $this->file_path);
		}

		return Render::render($this->format, $this->file(), $this->content, $options);
	}

	public function preview(string $content): string
	{
		return Render::render($this->format, $this->file(), $content, ['prefix' => '#']);
	}

	public function filepath(bool $stored = true): string
	{
		return $stored && isset($this->file_path) ? $this->file_path : File::CONTEXT_WEB . '/' . $this->path . '/' . $this->_name;
	}

	public function path(): string
	{
		return $this->path;
	}

	public function syncFile(string $path): void
	{
		$export = $this->export();

		$exists = Files::callStorage('exists', $path);

		// Create file if required
		if (!$exists) {
			$file = $this->_file = File::createAndStore(Utils::dirname($path), Utils::basename($path), null, $export);
		}
		else {
			$target = $this->filepath(false);

			// Move parent directory if needed
			if ($path !== $target) {
				$dir = Files::get(Utils::dirname($path));
				$dir->rename(Utils::dirname($target));
				$this->_file = null;
			}

			$file = $this->file();

			// Or update file
			if ($file->fetch() !== $export) {
				$file->store(null, $export);
			}
		}

		$this->syncSearch();
	}

	public function syncSearch(): void
	{
		$content = $this->format == self::FORMAT_ENCRYPTED ? null : strip_tags($this->render());
		$this->file()->indexForSearch(null, $content, $this->title);
	}

	public function save(): bool
	{
		if (isset($this->_modified['uri']) || isset($this->_modified['path'])) {
			$this->set('file_path', $this->filepath(false));
		}

		$current_path = $this->_modified['file_path'] ?? $this->file_path;
		parent::save();
		$this->syncFile($current_path);

		return true;
	}

	public function delete(): bool
	{
		Files::get(Utils::dirname($this->file_path))->delete();
		return parent::delete();
	}

	public function selfCheck(): void
	{
		$db = DB::getInstance();
		$this->assert($this->type === self::TYPE_CATEGORY || $this->type === self::TYPE_PAGE, 'Unknown page type');
		$this->assert(array_key_exists($this->status, self::STATUS_LIST), 'Unknown page status');
		$this->assert(array_key_exists($this->format, self::FORMATS_LIST), 'Unknown page format');
		$this->assert(trim($this->title) !== '', 'Le titre ne peut rester vide');
		$this->assert(trim($this->file_path) !== '', 'Le chemin de fichier ne peut rester vide');
		$this->assert(trim($this->path) !== '', 'Le chemin ne peut rester vide');
		$this->assert(trim($this->uri) !== '', 'L\'URI ne peut rester vide');
		$this->assert($this->path !== $this->parent, 'Invalid parent page');
		$this->assert($this->parent === '' || $db->test(self::TABLE, 'path = ?', $this->parent), 'Page parent inexistante');

		$this->assert(!$this->exists() || !$db->test(self::TABLE, 'path = ? AND id != ?', $this->path, $this->id()), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->uri);
		$this->assert($this->exists() || !$db->test(self::TABLE, 'path = ?', $this->path), 'Cette adresse URI est déjà utilisée par une autre page, merci d\'en choisir une autre : ' . $this->path);
	}

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['date']) && isset($source['date_time'])) {
			$source['published'] = $source['date'] . ' ' . $source['date_time'];
		}

		$parent = $this->parent;

		if (isset($source['title']) && is_null($this->path)) {
			$source['uri'] = $source['title'];
		}

		if (isset($source['uri'])) {
			$source['uri'] = Utils::transformTitleToURI($source['uri']);
			$source['path'] = trim($parent . '/' . $source['uri'], '/');
		}

		$uri = $source['uri'] ?? $this->uri;

		if (array_key_exists('parent', $source)) {
			if (is_array($source['parent'])) {
				$source['parent'] = key($source['parent']);
			}

			if (empty($source['parent'])) {
				$source['parent'] = '';
			}

			$parent = $source['parent'];
			$source['path'] = trim($parent . '/' . $uri, '/');
		}

		if (!empty($source['encryption']) ) {
			$this->set('format', self::FORMAT_ENCRYPTED);
		}
		else {
			$this->set('format', self::FORMAT_SKRIV);
		}

		return parent::importForm($source);
	}

	public function getBreadcrumbs(): array
	{
		$sql = '
			WITH RECURSIVE parents(title, parent, path, level) AS (
				SELECT title, parent, path, 1 FROM web_pages WHERE id = ?
				UNION ALL
				SELECT p.title, p.parent, p.path, level + 1
				FROM web_pages p
					JOIN parents ON parents.parent = p.path
			)
			SELECT path, title FROM parents ORDER BY level DESC;';
		return DB::getInstance()->getAssoc($sql, $this->id());
	}

	public function listAttachments(): array
	{
		if (null === $this->_attachments) {
			$list = Files::list(Utils::dirname($this->filepath()));

			// Remove the page itself
			$list = array_filter($list, function ($a) {
				return $a->name != $this->_name && $a->type != $a::TYPE_DIRECTORY;
			});

			$this->_attachments = $list;
		}

		return $this->_attachments;
	}

	static public function findTaggedAttachments(string $text): array
	{
		preg_match_all('/<<?(?:file|image)\s*(?:\|\s*)?([\w\d_.-]+)/ui', $text, $match, PREG_PATTERN_ORDER);
		preg_match_all('/#(?:file|image):\[([\w\d_.-]+)\]/ui', $text, $match2, PREG_PATTERN_ORDER);

		return array_merge($match[1], $match2[1]);
	}

	/**
	 * Return list of images
	 * If $all is FALSE then this will only return images that are not present in the content
	 */
	public function getImageGallery(bool $all = true): array
	{
		return $this->getAttachmentsGallery($all, true);
	}

	/**
	 * Return list of files
	 * If $all is FALSE then this will only return files that are not present in the content
	 */
	public function getAttachmentsGallery(bool $all = true, bool $images = false): array
	{
		$out = [];
		$tagged = [];

		if (!$all) {
			$tagged = $this->findTaggedAttachments($this->content);
		}

		foreach ($this->listAttachments() as $a) {
			if ($images && !$a->image) {
				continue;
			}
			elseif (!$images && $a->image) {
				continue;
			}

			// Skip
			if (!$all && in_array($a->name, $tagged)) {
				continue;
			}

			$out[] = $a;
		}

		return $out;
	}

	public function export(): string
	{
		$meta = [
			'Title' => str_replace("\n", '', trim($this->title)),
			'Status' => $this->status,
			'Published' => $this->published->format('Y-m-d H:i:s'),
			'Format' => $this->format,
		];

		$out = '';

		foreach ($meta as $key => $value) {
			$out .= sprintf("%s: %s\n", $key, $value);
		}

		$out .= "\n----\n\n" . $this->content;

		return $out;
	}

	public function importFromRaw(string $str): bool
	{
		$str = preg_replace("/\r\n|\r|\n/", "\n", $str);
		$str = explode("\n\n----\n\n", $str, 2);

		if (count($str) !== 2) {
			return false;
		}

		list($meta, $content) = $str;

		$meta = explode("\n", trim($meta));

		foreach ($meta as $line) {
			$key = strtolower(trim(strtok($line, ':')));
			$value = trim(strtok(''));

			if ($key == 'title') {
				$this->set('title', $value);
			}
			elseif ($key == 'published') {
				$this->set('published', new \DateTime($value));
			}
			elseif ($key == 'format') {
				$value = strtolower($value);

				if (!array_key_exists($value, self::FORMATS_LIST)) {
					throw new \LogicException('Unknown format: ' . $value);
				}

				$this->set('format', $value);
			}
			elseif ($key == 'status') {
				$value = strtolower($value);

				if (!array_key_exists($value, self::STATUS_LIST)) {
					throw new \LogicException('Unknown status: ' . $value);
				}

				$this->set('status', $value);
			}
			else {
				// Ignore other metadata
			}
		}

		$this->set('content', trim($content, "\n\r"));

		return true;
	}

	public function loadFromFile(File $file): void
	{
		if (!$this->importFromRaw($file->fetch())) {
			throw new \LogicException('Invalid page content: ' . $file->parent);
		}

		$this->set('modified', $file->modified);

		foreach (Files::list($file->parent) as $subfile) {
			if ($subfile->type == File::TYPE_DIRECTORY) {
				$this->set('type', self::TYPE_CATEGORY);
				return;
			}
		}

		$this->set('type', self::TYPE_PAGE); // Default
	}

	static public function fromFile(File $file): self
	{
		$page = new self;

		// Path is relative to web root
		$page->set('file_path', $file->path);
		$page->set('path', substr(Utils::dirname($file->path), strlen(File::CONTEXT_WEB . '/')));
		$page->set('uri', Utils::basename($page->path));
		$page->set('parent', Utils::dirname($page->path) == '.' ? '' : Utils::dirname($page->path));

		$page->loadFromFile($file);
		return $page;
	}
}