diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index 8e730d0f2..a3333fc60 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -10,14 +10,17 @@ use Formwork\Languages\Language; use Formwork\Languages\Languages; use Formwork\Metadata\MetadataCollection; +use Formwork\Model\Attributes\ReadonlyModelProperty; use Formwork\Model\Model; use Formwork\Pages\Traits\PageStatus; use Formwork\Pages\Traits\PageTraversal; use Formwork\Pages\Traits\PageUid; use Formwork\Pages\Traits\PageUri; +use Formwork\Parsers\Yaml; use Formwork\Site; use Formwork\Templates\Template; use Formwork\Utils\Arr; +use Formwork\Utils\Date; use Formwork\Utils\FileSystem; use Formwork\Utils\Path; use Formwork\Utils\Str; @@ -52,6 +55,26 @@ class Page extends Model implements Stringable protected const MODEL_IDENTIFIER = 'page'; + /** + * Ignored field names on frontmatter generation + */ + protected const IGNORED_FIELD_NAMES = ['content', 'template', 'parent']; + + /** + * Ignored field types on frontmatter generation + */ + protected const IGNORED_FIELD_TYPES = ['upload']; + + /** + * Slug regex + */ + protected const SLUG_REGEX = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/i'; + + /** + * Datetime format used for page numbering with `date` mode + */ + protected const DATE_NUM_FORMAT = 'Ymd'; + /** * Page path */ @@ -60,21 +83,25 @@ class Page extends Model implements Stringable /** * Page path relative to the content path */ + #[ReadonlyModelProperty] protected ?string $relativePath = null; /** * Page content file */ + #[ReadonlyModelProperty] protected ?ContentFile $contentFile = null; /** * Page last modified time - * */ + */ + #[ReadonlyModelProperty] protected int $lastModifiedTime; /** * Page route */ + #[ReadonlyModelProperty] protected ?string $route = null; /** @@ -95,6 +122,7 @@ class Page extends Model implements Stringable /** * Available page languages */ + #[ReadonlyModelProperty] protected Languages $languages; /** @@ -110,11 +138,13 @@ class Page extends Model implements Stringable /** * Page metadata */ + #[ReadonlyModelProperty] protected MetadataCollection $metadata; /** * Page files */ + #[ReadonlyModelProperty] protected FileCollection $files; /** @@ -125,10 +155,17 @@ class Page extends Model implements Stringable /** * Page loading state */ + #[ReadonlyModelProperty] protected bool $loaded = false; + /** + * Reference to the site + */ protected Site $site; + /** + * Page icon + */ protected string $icon; /** @@ -158,6 +195,9 @@ public function __toString(): string return (string) ($this->title() ?? $this->slug()); } + /** + * Return site + */ public function site(): Site { return $this->site; @@ -172,6 +212,8 @@ public function defaults(): array { $defaults = [ 'published' => true, + 'publishDate' => null, + 'unpublishDate' => null, 'routable' => true, 'listed' => true, 'searchable' => true, @@ -386,6 +428,82 @@ public function setLanguage(Language|string|null $language): void } } + /** + * Set page parent + */ + public function setParent(Page|Site|string $parent): void + { + if ($parent instanceof Page || $parent instanceof Site) { + $this->parent = $parent; + } else { + $this->parent = $this->resolveParent($parent); + } + } + + /** + * Set page template + */ + public function setTemplate(Template|string $template): void + { + if ($template instanceof Template) { + $this->template = $template; + } else { + $this->template = $this->site->templates()->get($template); + } + } + + /** + * Set page slug + */ + public function setSlug(string $slug): void + { + if (!$this->validateSlug($slug)) { + throw new InvalidArgumentException('Invalid page slug'); + } + if ($this->isIndexPage() || $this->isErrorPage()) { + throw new UnexpectedValueException('Cannot change slug of index or error pages'); + } + if ($this->site->findPage($this->parent()?->route() . $slug . '/') !== null) { + throw new UnexpectedValueException('A page with the same route already exists'); + } + $this->slug = $slug; + } + + /** + * Set page num + * + * If no arguments are passed, the num is set based on the current mode + */ + public function setNum(?int $num = null): void + { + if (func_num_args() === 0) { + $mode = $this->scheme()->options()->get('num'); + + $num = $this->num(); + + if ($mode === 'date' && $num !== null) { + $timestamp = isset($this->data['publishDate']) + ? Date::toTimestamp($this->data['publishDate']) + : $this->contentFile()?->lastModifiedTime(); + + if ($num === (int) date(self::DATE_NUM_FORMAT, $timestamp)) { + return; + } + } + + if (!$this->parent()) { + throw new UnexpectedValueException('Unexpected missing parent'); + } + + $num = match ($mode) { + 'date' => date(self::DATE_NUM_FORMAT), + default => 1 + max([0, ...$this->parent()->children()->everyItem()->num()->values()]) + }; + } + + $this->num = (int) $num; + } + /** * Return all page images */ @@ -513,21 +631,131 @@ public function reload(array $data = []): void $this->__construct($data); } + /** + * Return page content path + */ public function contentPath(): ?string { return $this->path; } + /** + * Return page content relative path + */ public function contentRelativePath(): ?string { return $this->relativePath; } + /** + * Return page icon + */ public function icon(): string { return $this->icon ??= $this->data['icon'] ?? $this->scheme()->options()->get('icon', 'page'); } + /** + * Save page contents and move files if needed + * + * @param string|null $language Language code to save the page in + */ + public function save(?string $language = null): void + { + if ($this->parent() === null) { + throw new UnexpectedValueException('Unexpected missing parent'); + } + + if ($this->parent()->contentPath() === null) { + throw new UnexpectedValueException('Unexpected missing parent content path'); + } + + $config = App::instance()->config(); + + $language ??= $this->language(); + + if ($language !== null && !in_array($language, $this->site->languages()->available()->keys(), true)) { + throw new InvalidArgumentException('Invalid page language'); + } + + $frontmatter = $this->contentFile()?->frontmatter() ?? []; + + $defaults = $this->defaults(); + + $fieldCollection = $this->fields + ->setValues([...$this->data, 'parent' => $this->parent()->route(), 'template' => $this->template]) + ->validate(); + + foreach ($fieldCollection as $field) { + if ( + $field->isEmpty() + || (Arr::has($defaults, $field->name()) && Arr::get($defaults, $field->name()) === $field->value()) + || in_array($field->name(), self::IGNORED_FIELD_NAMES, true) + || in_array($field->type(), self::IGNORED_FIELD_TYPES, true) + ) { + unset($frontmatter[$field->name()]); + continue; + } + + $frontmatter[$field->name()] = $field->value(); + } + + $content = str_replace("\r\n", "\n", $this->data['content']); + + $contentTemplate = $this->contentFile() !== null + ? Str::before(basename($this->contentFile()->path()), '.') + : $this->template()->name(); + + if (!$this->contentPath() && $this->num === null) { + $this->setNum(); + } + + $contentDir = $this->num() + ? $this->num() . '-' . $this->slug() + : $this->slug(); + + $contentPath = FileSystem::joinPaths( + (string) $this->parent()?->contentPath(), + $contentDir . '/' + ); + + $differ = $contentPath !== $this->contentPath() + || $contentTemplate !== $this->template->name() + || $frontmatter !== $this->contentFile()?->frontmatter() + || $content !== $this->contentFile()->content(); + + if ($differ) { + $filename = $this->template->name(); + + if ($language !== null) { + $filename .= '.' . $language; + } + + $filename .= $config->get('system.pages.content.extension'); + + $fileContent = Str::wrap(Yaml::encode($frontmatter), '---' . PHP_EOL) . $content; + + if ($contentPath !== $this->contentPath()) { + if (!FileSystem::isDirectory($contentPath, assertExists: false)) { + FileSystem::createDirectory($contentPath, recursive: true); + } + if ($this->contentPath() !== null) { + FileSystem::moveDirectory($this->contentPath(), $contentPath, overwrite: FileSystem::isEmptyDirectory($contentPath, assertExists: false)); + } + } elseif ($contentTemplate !== $this->template->name() && $this->contentFile() !== null) { + FileSystem::delete($this->contentFile()->path()); + } + + FileSystem::write($contentPath . $filename, $fileContent); + + $this->reload(['path' => $contentPath]); + + if ($this->site->contentPath() !== null) { + FileSystem::touch($this->site->contentPath()); + } + } + } + /** * Load files related to page */ @@ -671,4 +899,25 @@ protected function resetProperties(): void } } } + + /** + * Resolve parent page helper + * + * @param string $parent Page URI or '.' for site + */ + protected function resolveParent(string $parent): Page|Site + { + if ($parent === '.') { + return $this->site; + } + return $this->site->findPage($parent) ?? throw new RuntimeException('Invalid parent'); + } + + /** + * Validate page slug helper + */ + protected function validateSlug(string $slug): bool + { + return (bool) preg_match(self::SLUG_REGEX, $slug); + } } diff --git a/formwork/src/Panel/Controllers/PagesController.php b/formwork/src/Panel/Controllers/PagesController.php index 3d05c90dc..c05b0c31a 100644 --- a/formwork/src/Panel/Controllers/PagesController.php +++ b/formwork/src/Panel/Controllers/PagesController.php @@ -13,6 +13,7 @@ use Formwork\Http\RequestData; use Formwork\Http\RequestMethod; use Formwork\Http\Response; +use Formwork\Http\ResponseStatus; use Formwork\Pages\Page; use Formwork\Panel\ContentHistory\ContentHistory; use Formwork\Panel\ContentHistory\ContentHistoryEvent; @@ -21,27 +22,13 @@ use Formwork\Site; use Formwork\Utils\Arr; use Formwork\Utils\Constraint; -use Formwork\Utils\Date; use Formwork\Utils\FileSystem; use Formwork\Utils\Str; use Formwork\Utils\Uri; -use RuntimeException; use UnexpectedValueException; class PagesController extends AbstractController { - /** - * Valid page slug regex - */ - protected const SLUG_REGEX = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/i'; - - /** - * Page prefix date format - */ - protected const DATE_NUM_FORMAT = 'Ymd'; - - protected const IGNORED_FIELD_NAMES = ['content', 'template', 'parent']; - /** * Pages@index action */ @@ -232,7 +219,7 @@ public function edit(RouteParams $routeParams): Response : null; return new Response($this->view('pages.editor', [ - 'title' => $this->translate('panel.pages.editPage', $page->title()), + 'title' => $this->translate('panel.pages.editPage', (string) $page->title()), 'page' => $page, 'fields' => $page->fields(), 'currentLanguage' => $routeParams->get('language', $page->language()?->code()), @@ -268,7 +255,7 @@ public function preview(RouteParams $routeParams): Response $page->reload(['template' => $this->site->templates()->get($template)]); } - if ($page->parent() !== ($parent = $this->resolveParent($requestData->get('parent')))) { + if ($page->parent() !== ($this->resolveParent($requestData->get('parent')))) { $this->panel->notify($this->translate('panel.pages.page.cannotPreview.parentChanged'), 'error'); return $this->redirectToReferer( default: $this->generateRoute('panel.pages'), @@ -296,7 +283,7 @@ public function reorder(): JsonResponse|Response $parent = $this->resolveParent($requestData->get('parent')); if (!$parent->hasChildren()) { - return JsonResponse::error($this->translate('panel.pages.page.cannotMove')); + return JsonResponse::error($this->translate('panel.pages.page.cannotMove'), ResponseStatus::InternalServerError); } $pageCollection = $parent->children(); @@ -306,18 +293,16 @@ public function reorder(): JsonResponse|Response $to = Arr::indexOf($keys, $requestData->get('before')); if ($from === null || $to === null) { - return JsonResponse::error($this->translate('panel.pages.page.cannotMove')); + return JsonResponse::error($this->translate('panel.pages.page.cannotMove'), ResponseStatus::InternalServerError); } $pageCollection->moveItem($from, $to); - foreach ($pageCollection->values() as $i => $page) { - $name = basename((string) $page->relativePath()); - $newName = preg_replace(Page::NUM_REGEX, $i + 1 . '-', $name) - ?? throw new RuntimeException(sprintf('Replacement failed with error: %s', preg_last_error_msg())); - - if ($newName !== $name) { - $this->changePageName($page, $newName); + foreach ($pageCollection->filterBy('orderable')->values() as $i => $page) { + $num = $i + 1; + if ($num !== $page->num()) { + $page->set('num', $num); + $page->save(); } } @@ -618,66 +603,57 @@ public function file(RouteParams $routeParams): Response */ protected function createPage(FieldCollection $fieldCollection): Page { - try { - $parent = $this->resolveParent($fieldCollection->get('parent')->value()); - } catch (RuntimeException) { - throw new TranslatedException('Parent page not found', 'panel.pages.page.cannotCreate.invalidParent'); - } + $page = new Page(['site' => $this->site, 'published' => false]); - if ($parent instanceof Page && !$parent->allowChildren()) { - throw new TranslatedException('Parent page does not allow children', 'panel.pages.page.cannotCreate.invalidParent'); - } + $data = $fieldCollection->everyItem()->value()->toArray(); - // Validate page slug - if (!$this->validateSlug($fieldCollection->get('slug')->value())) { - throw new TranslatedException('Invalid page slug', 'panel.pages.page.cannotCreate.invalidSlug'); - } + $page->setMultiple($data); - $route = $parent->route() . $fieldCollection->get('slug')->value() . '/'; - - // Ensure there isn't a page with the same route - if ($this->site->findPage($route) !== null) { - throw new TranslatedException('A page with the same route already exists', 'panel.pages.page.cannotCreate.alreadyExists'); - } + $page->save($this->site->languages()->default()); - // Validate page template - if (!$this->site->templates()->has($fieldCollection->get('template'))) { - throw new TranslatedException('Invalid page template', 'panel.pages.page.cannotCreate.invalidTemplate'); + if ($page->contentPath()) { + $contentHistory = new ContentHistory($page->contentPath()); + $contentHistory->update(ContentHistoryEvent::Created, $this->panel->user()->username(), time()); + $contentHistory->save(); } - $scheme = $this->app->schemes()->get('pages.' . $fieldCollection->get('template')->value()); - - $path = FileSystem::joinPaths( - (string) $parent->contentPath(), - $this->makePageNum($parent, $scheme->options()->get('num')) . '-' . $fieldCollection->get('slug')->value(), - '/' - ); - - FileSystem::createDirectory($path, recursive: true); - - $language = $this->site->languages()->default(); - - $filename = $fieldCollection->get('template')->value(); - $filename .= $language !== null ? '.' . $language : ''; - $filename .= $this->config->get('system.pages.content.extension'); + return $page; + } - FileSystem::createFile($path . $filename); + /** + * Update a page + */ + protected function updatePage(Page $page, RequestData $requestData, FieldCollection $fieldCollection, bool $force = false): Page + { + foreach ($fieldCollection as $field) { + if ($field->type() === 'upload') { + if (!$field->isEmpty()) { + $uploadedFiles = $field->is('multiple') ? $field->value() : [$field->value()]; + $this->processPageUploads($uploadedFiles, $page, $field->acceptMimeTypes()); + } + $fieldCollection->remove($field->name()); + } + } - $contentData = [ - 'title' => $fieldCollection->get('title')->value(), - 'published' => false, - ]; + $previousData = $page->data(); - $fileContent = Str::wrap(Yaml::encode($contentData), '---' . PHP_EOL); + /** @var array */ + $data = [...$fieldCollection->everyItem()->value()->toArray(), 'slug' => $requestData->get('slug')]; - FileSystem::write($path . $filename, $fileContent); + $page->setMultiple($data); + $page->save($requestData->get('language')); - $contentHistory = new ContentHistory($path); + if ($page->contentPath() === null) { + throw new UnexpectedValueException('Unexpected missing content file'); + } - $contentHistory->update(ContentHistoryEvent::Created, $this->panel->user()->username(), time()); - $contentHistory->save(); + if ($previousData !== $page->data() || $force) { + $contentHistory = new ContentHistory($page->contentPath()); + $contentHistory->update(ContentHistoryEvent::Edited, $this->panel->user()->username(), time()); + $contentHistory->save(); + } - return $this->site->retrievePage($path); + return $page; } protected function updateFileMetadata(File $file, FieldCollection $fieldCollection): void @@ -707,155 +683,6 @@ protected function updateFileMetadata(File $file, FieldCollection $fieldCollecti FileSystem::write($metaFile, Yaml::encode($data)); } - /** - * Update a page - */ - protected function updatePage(Page $page, RequestData $requestData, FieldCollection $fieldCollection, bool $force = false): Page - { - if ($page->contentFile() === null) { - throw new RuntimeException('Unexpected missing content file'); - } - - // Load current page frontmatter - $frontmatter = $page->contentFile()->frontmatter(); - - // Preserve the title if not given - if (!empty($requestData->get('title'))) { - $frontmatter['title'] = $requestData->get('title'); - } - - // Get page defaults - $defaults = $page->defaults(); - - // Handle data from fields - foreach ($fieldCollection as $field) { - // Remove empty and default values - if ( - $field->isEmpty() - || (Arr::has($defaults, $field->name()) && Arr::get($defaults, $field->name()) === $field->value()) - || in_array($field->name(), self::IGNORED_FIELD_NAMES, true) - ) { - unset($frontmatter[$field->name()]); - continue; - } - - if ($field->type() === 'upload') { - $uploadedFiles = $field->is('multiple') ? $field->value() : [$field->value()]; - $this->processPageUploads($uploadedFiles, $page, $field->acceptMimeTypes()); - continue; - } - - // Set frontmatter value - $frontmatter[$field->name()] = $field->value(); - } - - $content = $requestData->has('content') ? str_replace("\r\n", "\n", $requestData->get('content')) : $page->data()['content']; - - $language = $requestData->get('language'); - - // Validate language - if (!empty($language) && !in_array($language, $this->config->get('system.languages.available'), true)) { - throw new TranslatedException('Invalid page language', 'panel.pages.page.cannotEdit.invalidLanguage'); - } - - if ($page->contentFile() === null) { - throw new RuntimeException('Unexpected missing content file'); - } - - $differ = $frontmatter !== $page->contentFile()->frontmatter() || $content !== $page->data()['content'] || $language !== $page->language(); - - if ($force || $differ) { - $filename = $requestData->get('template'); - $filename .= empty($language) ? '' : '.' . $language; - $filename .= $this->config->get('system.pages.content.extension'); - - $fileContent = Str::wrap(Yaml::encode($frontmatter), '---' . PHP_EOL) . $content; - - if ($page->contentPath() === null) { - throw new UnexpectedValueException('Unexpected missing page path'); - } - - if ($this->site->contentPath() === null) { - throw new UnexpectedValueException('Unexpected missing site path'); - } - - FileSystem::write($page->contentPath() . $filename, $fileContent); - FileSystem::touch($this->site->contentPath()); - - $contentHistory = new ContentHistory($page->contentPath()); - - $contentHistory->update(ContentHistoryEvent::Edited, $this->panel->user()->username(), time()); - $contentHistory->save(); - - // Update page with the new data - $page->reload(); - - // Set correct page language if it has changed - if (!empty($language) && $language !== $page->language()?->code()) { - $page->setLanguage($language); - } - - if ($page->contentFile() === null) { - throw new RuntimeException('Unexpected missing content file'); - } - - // Check if page number has to change - - $timestamp = isset($page->data()['publishDate']) - ? Date::toTimestamp($page->data()['publishDate']) - : $page->contentFile()->lastModifiedTime(); - - if ($page->scheme()->options()->get('num') === 'date' && $page->num() !== ($num = (int) date(self::DATE_NUM_FORMAT, $timestamp))) { - if ($page->relativePath() === null) { - throw new UnexpectedValueException('Unexpected missing page relative path'); - } - - $name = preg_replace(Page::NUM_REGEX, $num . '-', basename($page->relativePath())) - ?? throw new RuntimeException(sprintf('Replacement failed with error: %s', preg_last_error_msg())); - - try { - $page = $this->changePageName($page, $name); - } catch (RuntimeException) { - throw new TranslatedException('Cannot change page num', 'panel.pages.page.cannotChangeNum'); - } - } - } - - // Check if parent page has to change - try { - if ($page->parent() !== ($parent = $this->resolveParent($requestData->get('parent')))) { - $page = $this->changePageParent($page, $parent); - } - } catch (RuntimeException) { - throw new TranslatedException('Invalid parent page', 'panel.pages.page.cannotEdit.invalidParent'); - } - - // Check if page template has to change - if ($page->template()->name() !== ($template = $requestData->get('template'))) { - if (!$this->site->templates()->has($template)) { - throw new TranslatedException('Invalid page template', 'panel.pages.page.cannotEdit.invalidTemplate'); - } - $page = $this->changePageTemplate($page, $template); - } - - // Check if page slug has to change - if ($page->slug() !== ($slug = $requestData->get('slug'))) { - if (!$this->validateSlug($slug)) { - throw new TranslatedException('Invalid page slug', 'panel.pages.page.cannotEdit.invalidSlug'); - } - // Don't change index and error pages slug - if ($page->isIndexPage() || $page->isErrorPage()) { - throw new TranslatedException('Cannot change slug of index or error pages', 'panel.pages.page.cannotEdit.indexOrErrorPageSlug'); - } - if ($this->site->findPage($page->parent()?->route() . $slug . '/') !== null) { - throw new TranslatedException('A page with the same route already exists', 'panel.pages.page.cannotEdit.alreadyExists'); - } - $page = $this->changePageName($page, ltrim($page->num() . '-', '-') . $slug); - } - - return $page; - } - /** * Process page uploads * @@ -877,79 +704,6 @@ protected function processPageUploads(array $files, Page $page, ?array $mimeType $page->reload(); } - /** - * Make a page num according to 'date' or default mode - * - * @param string $mode 'date' for pages with a publish date - */ - protected function makePageNum(Page|Site $parent, ?string $mode): string - { - return (string) match ($mode) { - 'date' => date(self::DATE_NUM_FORMAT), - default => 1 + max([0, ...$parent->children()->everyItem()->num()->values()]) - }; - } - - /** - * Change the name of a page - */ - protected function changePageName(Page $page, string $name): Page - { - if ($page->contentPath() === null) { - throw new UnexpectedValueException('Unexpected missing page path'); - } - $directory = dirname($page->contentPath()); - $destination = FileSystem::joinPaths($directory, $name, DS); - FileSystem::moveDirectory($page->contentPath(), $destination); - return $this->site->retrievePage($destination); - } - - /** - * Change the parent of a page - */ - protected function changePageParent(Page $page, Page|Site $parent): Page - { - if ($parent instanceof Page && !$parent->allowChildren()) { - throw new UnexpectedValueException('Parent page does not allow children'); - } - - if ($parent->contentPath() === null) { - throw new UnexpectedValueException('Unexpected missing parent page path'); - } - - if ($page->contentPath() === null) { - throw new UnexpectedValueException('Unexpected missing page path'); - } - - if ($page->contentRelativePath() === null) { - throw new UnexpectedValueException('Unexpected missing page relative path'); - } - - $destination = FileSystem::joinPaths($parent->contentPath(), basename($page->contentRelativePath()), DS); - - FileSystem::moveDirectory($page->contentPath(), $destination); - return $this->site->retrievePage($destination); - } - - /** - * Change page template - */ - protected function changePageTemplate(Page $page, string $template): Page - { - if ($page->contentPath() === null) { - throw new UnexpectedValueException('Unexpected missing page path'); - } - - if ($page->contentFile() === null) { - throw new UnexpectedValueException('Unexpected missing content file'); - } - - $destination = $page->contentPath() . $template . $this->config->get('system.pages.content.extension'); - FileSystem::move($page->contentFile()->path(), $destination); - $page->reload(); - return $page; - } - /** * Resolve parent page helper * @@ -960,15 +714,7 @@ protected function resolveParent(string $parent): Page|Site if ($parent === '.') { return $this->site; } - return $this->site->findPage($parent) ?? throw new RuntimeException('Invalid parent'); - } - - /** - * Validate page slug helper - */ - protected function validateSlug(string $slug): bool - { - return (bool) preg_match(self::SLUG_REGEX, $slug); + return $this->site->findPage($parent) ?? throw new UnexpectedValueException('Invalid parent'); } /**