Files
lichterei-web/kirby/src/Cms/PageActions.php
Bastian Allgeier 63ddf40692 Upgrade to 4.5.0
2024-11-28 11:24:28 +01:00

1017 lines
26 KiB
PHP

<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* PageActions
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
trait PageActions
{
/**
* Adapts necessary modifications which page uuid, page slug and files uuid
* of copy objects for single or multilang environments
* @internal
*/
protected function adaptCopy(Page $copy, bool $files = false, bool $children = false): Page
{
if ($this->kirby()->multilang() === true) {
foreach ($this->kirby()->languages() as $language) {
// overwrite with new UUID for the page and files
// for default language (remove old, add new)
if (
Uuids::enabled() === true &&
$language->isDefault() === true
) {
$copy = $copy->save(['uuid' => Uuid::generate()], $language->code());
// regenerate UUIDs of page files
if ($files !== false) {
foreach ($copy->files() as $file) {
$file->save(['uuid' => Uuid::generate()], $language->code());
}
}
// regenerate UUIDs of all page children
if ($children !== false) {
foreach ($copy->index(true) as $child) {
// always adapt files of subpages as they are currently always copied;
// but don't adapt children because we already operate on the index
$this->adaptCopy($child, true);
}
}
}
// remove all translated slugs
if (
$language->isDefault() === false &&
$copy->translation($language)->exists() === true
) {
$copy = $copy->save(['slug' => null], $language->code());
}
}
return $copy;
}
// overwrite with new UUID for the page and files (remove old, add new)
if (Uuids::enabled() === true) {
$copy = $copy->save(['uuid' => Uuid::generate()]);
// regenerate UUIDs of page files
if ($files !== false) {
foreach ($copy->files() as $file) {
$file->save(['uuid' => Uuid::generate()]);
}
}
// regenerate UUIDs of all page children
if ($children !== false) {
foreach ($copy->index(true) as $child) {
// always adapt files of subpages as they are currently always copied;
// but don't adapt children because we already operate on the index
$this->adaptCopy($child, true);
}
}
}
return $copy;
}
/**
* Changes the sorting number.
* The sorting number must already be correct
* when the method is called.
* This only affects this page,
* siblings will not be resorted.
*
* @return $this|static
* @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved
*/
public function changeNum(int|null $num = null): static
{
if ($this->isDraft() === true) {
throw new LogicException('Drafts cannot change their sorting number');
}
// don't run the action if everything stayed the same
if ($this->num() === $num) {
return $this;
}
return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) {
$newPage = $oldPage->clone([
'num' => $num,
'dirname' => null,
'root' => null
]);
// actually move the page on disk
if ($oldPage->exists() === true) {
if (Dir::move($oldPage->root(), $newPage->root()) === true) {
// Updates the root path of the old page with the root path
// of the moved new page to use fly actions on old page in loop
$oldPage->root = $newPage->root();
} else {
throw new LogicException('The page directory cannot be moved');
}
}
// overwrite the child in the parent page
static::updateParentCollections($newPage, 'set');
return $newPage;
});
}
/**
* Changes the slug/uid of the page
*
* @return $this|static
* @throws \Kirby\Exception\LogicException If the directory cannot be moved
*/
public function changeSlug(
string $slug,
string|null $languageCode = null
): static {
// always sanitize the slug
$slug = Url::slug($slug);
// in multi-language installations the slug for the non-default
// languages is stored in the text file. The changeSlugForLanguage
// method takes care of that.
if ($this->kirby()->language($languageCode)?->isDefault() === false) {
return $this->changeSlugForLanguage($slug, $languageCode);
}
// if the slug stays exactly the same,
// nothing needs to be done.
if ($slug === $this->slug()) {
return $this;
}
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null];
return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) {
$newPage = $oldPage->clone([
'slug' => $slug,
'dirname' => null,
'root' => null
]);
// clear UUID cache recursively (for children and files as well)
$oldPage->uuid()?->clear(true);
if ($oldPage->exists() === true) {
// remove the lock of the old page
$oldPage->lock()?->remove();
// actually move stuff on disk
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
throw new LogicException('The page directory cannot be moved');
}
// remove from the siblings
static::updateParentCollections($oldPage, 'remove');
Dir::remove($oldPage->mediaRoot());
}
// overwrite the new page in the parent collection
static::updateParentCollections($newPage, 'set');
return $newPage;
});
}
/**
* Change the slug for a specific language
*
* @throws \Kirby\Exception\NotFoundException If the language for the given language code cannot be found
* @throws \Kirby\Exception\InvalidArgumentException If the slug for the default language is being changed
*/
protected function changeSlugForLanguage(
string $slug,
string|null $languageCode = null
): static {
$language = $this->kirby()->language($languageCode);
if (!$language) {
throw new NotFoundException('The language: "' . $languageCode . '" does not exist');
}
if ($language->isDefault() === true) {
throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language');
}
$arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $language->code()];
return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) {
// remove the slug if it's the same as the folder name
if ($slug === $page->uid()) {
$slug = null;
}
$newPage = $page->save(['slug' => $slug], $languageCode);
// overwrite the updated page in the parent collection
static::updateParentCollections($newPage, 'set');
return $newPage;
});
}
/**
* Change the status of the current page
* to either draft, listed or unlisted.
* If changing to `listed`, you can pass a position for the
* page in the siblings collection. Siblings will be resorted.
*
* @param string $status "draft", "listed" or "unlisted"
* @param int|null $position Optional sorting number
* @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed
*/
public function changeStatus(string $status, int|null $position = null): static
{
return match ($status) {
'draft' => $this->changeStatusToDraft(),
'listed' => $this->changeStatusToListed($position),
'unlisted' => $this->changeStatusToUnlisted(),
default => throw new InvalidArgumentException('Invalid status: ' . $status)
};
}
protected function changeStatusToDraft(): static
{
$arguments = ['page' => $this, 'status' => 'draft', 'position' => null];
$page = $this->commit(
'changeStatus',
$arguments,
fn ($page) => $page->unpublish()
);
return $page;
}
/**
* @return $this|static
*/
protected function changeStatusToListed(int|null $position = null): static
{
// create a sorting number for the page
$num = $this->createNum($position);
// don't sort if not necessary
if ($this->status() === 'listed' && $num === $this->num()) {
return $this;
}
$page = $this->commit(
'changeStatus',
[
'page' => $this,
'status' => 'listed',
'position' => $num
],
fn ($page, $status, $position) =>
$page->publish()->changeNum($position)
);
if ($this->blueprint()->num() === 'default') {
$page->resortSiblingsAfterListing($num);
}
return $page;
}
/**
* @return $this|static
*/
protected function changeStatusToUnlisted(): static
{
if ($this->status() === 'unlisted') {
return $this;
}
$page = $this->commit(
'changeStatus',
[
'page' => $this,
'status' => 'unlisted',
'position' => null
],
fn ($page) => $page->publish()->changeNum(null)
);
$this->resortSiblingsAfterUnlisting();
return $page;
}
/**
* Change the position of the page in its siblings
* collection. Siblings will be resorted. If the page
* status isn't yet `listed`, it will be changed to it.
*
* @return $this|static
*/
public function changeSort(int|null $position = null): static
{
return $this->changeStatus('listed', $position);
}
/**
* Changes the page template
*
* @return $this|static
* @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved
*/
public function changeTemplate(string $template): static
{
if ($template === $this->intendedTemplate()->name()) {
return $this;
}
return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) {
// convert for new template/blueprint
$page = $oldPage->convertTo($template);
// update the parent collection
static::updateParentCollections($page, 'set');
return $page;
});
}
/**
* Change the page title
*/
public function changeTitle(
string $title,
string|null $languageCode = null
): static {
// if the `$languageCode` argument is not set and is not the default language
// the `$languageCode` argument is sent as the current language
if (
$languageCode === null &&
$language = $this->kirby()->language()
) {
if ($language->isDefault() === false) {
$languageCode = $language->code();
}
}
$arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode];
return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) {
$page = $page->save(['title' => $title], $languageCode);
// flush the parent cache to get children and drafts right
static::updateParentCollections($page, 'set');
return $page;
});
}
/**
* Commits a page action, by following these steps
*
* 1. checks the action rules
* 2. sends the before hook
* 3. commits the store action
* 4. sends the after hook
* 5. returns the result
*/
protected function commit(
string $action,
array $arguments,
Closure $callback
): mixed {
$old = $this->hardcopy();
$kirby = $this->kirby();
$argumentValues = array_values($arguments);
$this->rules()->$action(...$argumentValues);
$kirby->trigger('page.' . $action . ':before', $arguments);
$result = $callback(...$argumentValues);
if ($action === 'create') {
$argumentsAfter = ['page' => $result];
} elseif ($action === 'duplicate') {
$argumentsAfter = ['duplicatePage' => $result, 'originalPage' => $old];
} elseif ($action === 'delete') {
$argumentsAfter = ['status' => $result, 'page' => $old];
} else {
$argumentsAfter = ['newPage' => $result, 'oldPage' => $old];
}
$kirby->trigger('page.' . $action . ':after', $argumentsAfter);
$kirby->cache('pages')->flush();
return $result;
}
/**
* Copies the page to a new parent
*
* @throws \Kirby\Exception\DuplicateException If the page already exists
*
* @internal
*/
public function copy(array $options = []): static
{
$slug = $options['slug'] ?? $this->slug();
$isDraft = $options['isDraft'] ?? $this->isDraft();
$parent = $options['parent'] ?? null;
$parentModel = $options['parent'] ?? $this->site();
$num = $options['num'] ?? null;
$children = $options['children'] ?? false;
$files = $options['files'] ?? false;
// clean up the slug
$slug = Url::slug($slug);
if ($parentModel->findPageOrDraft($slug)) {
throw new DuplicateException([
'key' => 'page.duplicate',
'data' => [
'slug' => $slug
]
]);
}
$tmp = new static([
'isDraft' => $isDraft,
'num' => $num,
'parent' => $parent,
'slug' => $slug,
]);
$ignore = [
$this->kirby()->locks()->file($this)
];
// don't copy files
if ($files === false) {
foreach ($this->files() as $file) {
$ignore[] = $file->root();
// append all content files
array_push($ignore, ...$file->storage()->contentFiles('published'));
array_push($ignore, ...$file->storage()->contentFiles('changes'));
}
}
Dir::copy($this->root(), $tmp->root(), $children, $ignore);
$copy = $parentModel->clone()->findPageOrDraft($slug);
// normalize copy object
$copy = $this->adaptCopy($copy, $files, $children);
// add copy to siblings
static::updateParentCollections($copy, 'append', $parentModel);
return $copy;
}
/**
* Creates and stores a new page
*/
public static function create(array $props): Page
{
// clean up the slug
$props['slug'] = Url::slug($props['slug'] ?? $props['content']['title'] ?? null);
$props['template'] = $props['model'] = strtolower($props['template'] ?? 'default');
$props['isDraft'] ??= $props['draft'] ?? true;
// make sure that a UUID gets generated and
// added to content right away
$props['content'] ??= [];
if (Uuids::enabled() === true) {
$props['content']['uuid'] ??= Uuid::generate();
}
// create a temporary page object
$page = Page::factory($props);
// always create pages in the default language
if ($page->kirby()->multilang() === true) {
$languageCode = $page->kirby()->defaultLanguage()->code();
} else {
$languageCode = null;
}
// create a form for the page
// use always default language to fill form with default values
$form = Form::for(
$page,
[
'language' => $languageCode,
'values' => $props['content']
]
);
// inject the content
$page = $page->clone(['content' => $form->strings(true)]);
// run the hooks and creation action
$page = $page->commit(
'create',
[
'page' => $page,
'input' => $props
],
function ($page, $props) use ($languageCode) {
// write the content file
$page = $page->save($page->content()->toArray(), $languageCode);
// flush the parent cache to get children and drafts right
static::updateParentCollections($page, 'append');
return $page;
}
);
// publish the new page if a number is given
if (isset($props['num']) === true) {
$page = $page->changeStatus('listed', $props['num']);
}
return $page;
}
/**
* Creates a child of the current page
*/
public function createChild(array $props): Page
{
$props = array_merge($props, [
'url' => null,
'num' => null,
'parent' => $this,
'site' => $this->site(),
]);
$modelClass = Page::$models[$props['template'] ?? null] ?? Page::class;
return $modelClass::create($props);
}
/**
* Create the sorting number for the page
* depending on the blueprint settings
*/
public function createNum(int $num = null): int
{
$mode = $this->blueprint()->num();
switch ($mode) {
case 'zero':
return 0;
case 'date':
case 'datetime':
// the $format needs to produce only digits,
// so it can be converted to integer below
$format = $mode === 'date' ? 'Ymd' : 'YmdHi';
$lang = $this->kirby()->defaultLanguage() ?? null;
$field = $this->content($lang)->get('date');
$date = $field->isEmpty() ? 'now' : $field;
return (int)date($format, strtotime($date));
case 'default':
$max = $this
->parentModel()
->children()
->listed()
->merge($this)
->count();
// default positioning at the end
$num ??= $max;
// avoid zeros or negative numbers
if ($num < 1) {
return 1;
}
// avoid higher numbers than possible
if ($num > $max) {
return $max;
}
return $num;
default:
// get instance with default language
$app = $this->kirby()->clone([], false);
$app->setCurrentLanguage();
$template = Str::template($mode, [
'kirby' => $app,
'page' => $app->page($this->id()),
'site' => $app->site(),
], ['fallback' => '']);
return (int)$template;
}
}
/**
* Deletes the page
*/
public function delete(bool $force = false): bool
{
return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) {
// clear UUID cache
$page->uuid()?->clear();
// delete all files individually
foreach ($page->files() as $file) {
$file->delete();
}
// delete all children individually
foreach ($page->children() as $child) {
$child->delete(true);
}
// actually remove the page from disc
if ($page->exists() === true) {
// delete all public media files
Dir::remove($page->mediaRoot());
// delete the content folder for this page
Dir::remove($page->root());
// if the page is a draft and the _drafts folder
// is now empty. clean it up.
if ($page->isDraft() === true) {
$draftsDir = dirname($page->root());
if (Dir::isEmpty($draftsDir) === true) {
Dir::remove($draftsDir);
}
}
}
static::updateParentCollections($page, 'remove');
if ($page->isDraft() === false) {
$page->resortSiblingsAfterUnlisting();
}
return true;
});
}
/**
* Duplicates the page with the given
* slug and optionally copies all files
*/
public function duplicate(string|null $slug = null, array $options = []): static
{
// create the slug for the duplicate
$slug = Url::slug($slug ?? $this->slug() . '-' . Url::slug(I18n::translate('page.duplicate.appendix')));
$arguments = [
'originalPage' => $this,
'input' => $slug,
'options' => $options
];
return $this->commit('duplicate', $arguments, function ($page, $slug, $options) {
$page = $this->copy([
'parent' => $this->parent(),
'slug' => $slug,
'isDraft' => true,
'files' => $options['files'] ?? false,
'children' => $options['children'] ?? false,
]);
if (isset($options['title']) === true) {
$page = $page->changeTitle($options['title']);
}
return $page;
});
}
/**
* Moves the page to a new parent if the
* new parent accepts the page type
*/
public function move(Site|Page $parent): Page
{
// nothing to move
if ($this->parentModel()->is($parent) === true) {
return $this;
}
$arguments = [
'page' => $this,
'parent' => $parent
];
return $this->commit('move', $arguments, function ($page, $parent) {
// remove the uuid cache for this page
$page->uuid()?->clear(true);
// move drafts into the drafts folder of the parent
if ($page->isDraft() === true) {
$newRoot = $parent->root() . '/_drafts/' . $page->dirname();
} else {
$newRoot = $parent->root() . '/' . $page->dirname();
}
// try to move the page directory on disk
if (Dir::move($page->root(), $newRoot) !== true) {
throw new LogicException([
'key' => 'page.move.directory'
]);
}
// flush all collection caches to be sure that
// the new child is included afterwards
$parent->purge();
// double-check if the new child can actually be found
if (!$newPage = $parent->childrenAndDrafts()->find($page->slug())) {
throw new LogicException([
'key' => 'page.move.notFound'
]);
}
return $newPage;
});
}
/**
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
* @internal
*/
public function publish(): static
{
if ($this->isDraft() === false) {
return $this;
}
$page = $this->clone([
'isDraft' => false,
'root' => null
]);
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {
throw new LogicException('The draft folder cannot be moved');
}
// Get the draft folder and check if there are any other drafts
// left. Otherwise delete it.
$draftDir = dirname($this->root());
if (Dir::isEmpty($draftDir) === true) {
Dir::remove($draftDir);
}
}
// remove the page from the parent drafts and add it to children
$parentModel = $page->parentModel();
$parentModel->drafts()->remove($page);
$parentModel->children()->append($page->id(), $page);
// update the childrenAndDrafts() cache if it is initialized
if ($parentModel->childrenAndDrafts !== null) {
$parentModel->childrenAndDrafts()->set($page->id(), $page);
}
return $page;
}
/**
* Clean internal caches
*
* @return $this
*/
public function purge(): static
{
parent::purge();
$this->blueprint = null;
$this->children = null;
$this->childrenAndDrafts = null;
$this->drafts = null;
$this->files = null;
$this->inventory = null;
return $this;
}
/**
* @throws \Kirby\Exception\LogicException If the page is not included in the siblings collection
*/
protected function resortSiblingsAfterListing(int $position = null): bool
{
// get all siblings including the current page
$siblings = $this
->parentModel()
->children()
->listed()
->append($this)
->filter(fn ($page) => $page->blueprint()->num() === 'default');
// get a non-associative array of ids
$keys = $siblings->keys();
$index = array_search($this->id(), $keys);
// if the page is not included in the siblings something went wrong
if ($index === false) {
throw new LogicException('The page is not included in the sorting index');
}
if ($position > count($keys)) {
$position = count($keys);
}
// move the current page number in the array of keys
// subtract 1 from the num and the position, because of the
// zero-based array keys
$sorted = A::move($keys, $index, $position - 1);
foreach ($sorted as $key => $id) {
if ($id === $this->id()) {
continue;
}
$siblings->get($id)?->changeNum($key + 1);
}
$parent = $this->parentModel();
$parent->children = $parent->children()->sort('num', 'asc');
$parent->childrenAndDrafts = null;
return true;
}
/**
* @internal
*/
public function resortSiblingsAfterUnlisting(): bool
{
$index = 0;
$parent = $this->parentModel();
$siblings = $parent
->children()
->listed()
->not($this)
->filter(fn ($page) => $page->blueprint()->num() === 'default');
if ($siblings->count() > 0) {
foreach ($siblings as $sibling) {
$index++;
$sibling->changeNum($index);
}
$parent->children = $siblings->sort('num', 'asc');
$parent->childrenAndDrafts = null;
}
return true;
}
/**
* Stores the content on disk
* @internal
*/
public function save(
array|null $data = null,
string|null $languageCode = null,
bool $overwrite = false
): static {
$page = parent::save($data, $languageCode, $overwrite);
// overwrite the updated page in the parent collection
static::updateParentCollections($page, 'set');
return $page;
}
/**
* Convert a page from listed or
* unlisted to draft.
* @internal
*
* @return $this|static
* @throws \Kirby\Exception\LogicException If the folder cannot be moved
*/
public function unpublish(): static
{
if ($this->isDraft() === true) {
return $this;
}
$page = $this->clone([
'isDraft' => true,
'num' => null,
'dirname' => null,
'root' => null
]);
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {
throw new LogicException('The page folder cannot be moved to drafts');
}
}
// remove the page from the parent children and add it to drafts
$parentModel = $page->parentModel();
$parentModel->children()->remove($page);
$parentModel->drafts()->append($page->id(), $page);
// update the childrenAndDrafts() cache if it is initialized
if ($parentModel->childrenAndDrafts !== null) {
$parentModel->childrenAndDrafts()->set($page->id(), $page);
}
$page->resortSiblingsAfterUnlisting();
return $page;
}
/**
* Updates the page data
*/
public function update(
array|null $input = null,
string|null $languageCode = null,
bool $validate = false
): static {
if ($this->isDraft() === true) {
$validate = false;
}
$page = parent::update($input, $languageCode, $validate);
// if num is created from page content, update num on content update
if (
$page->isListed() === true &&
in_array($page->blueprint()->num(), ['zero', 'default']) === false
) {
$page = $page->changeNum($page->createNum());
}
// overwrite the updated page in the parent collection
static::updateParentCollections($page, 'set');
return $page;
}
/**
* Updates parent collections with the new page object
* after a page action
*
* @param \Kirby\Cms\Page $page
* @param string $method Method to call on the parent collections
* @param \Kirby\Cms\Page|null $parentMdel
*/
protected static function updateParentCollections(
$page,
string $method,
$parentModel = null
): void {
$parentModel ??= $page->parentModel();
// method arguments depending on the called method
$args = $method === 'remove' ? [$page] : [$page->id(), $page];
if ($page->isDraft() === true) {
$parentModel->drafts()->$method(...$args);
} else {
$parentModel->children()->$method(...$args);
}
// update the childrenAndDrafts() cache if it is initialized
if ($parentModel->childrenAndDrafts !== null) {
$parentModel->childrenAndDrafts()->$method(...$args);
}
}
}