first version
This commit is contained in:
730
kirby/src/Cms/PageActions.php
Executable file
730
kirby/src/Cms/PageActions.php
Executable file
@@ -0,0 +1,730 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\F;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
trait PageActions
|
||||
{
|
||||
|
||||
/**
|
||||
* Changes the sorting number
|
||||
* The sorting number must already be correct
|
||||
* when the method is called
|
||||
*
|
||||
* @param int $num
|
||||
* @return self
|
||||
*/
|
||||
public function changeNum(int $num = null): self
|
||||
{
|
||||
if ($this->isDraft() === true) {
|
||||
throw new LogicException('Drafts cannot change their sorting number');
|
||||
}
|
||||
|
||||
return $this->commit('changeNum', [$this, $num], function ($oldPage, $num) {
|
||||
$newPage = $oldPage->clone([
|
||||
'num' => $num,
|
||||
'dirname' => null,
|
||||
'root' => null
|
||||
]);
|
||||
|
||||
// actually move the page on disk
|
||||
if ($oldPage->exists() === true) {
|
||||
Dir::move($oldPage->root(), $newPage->root());
|
||||
}
|
||||
|
||||
// overwrite the child in the parent page
|
||||
$newPage
|
||||
->parentModel()
|
||||
->children()
|
||||
->set($newPage->id(), $newPage);
|
||||
|
||||
return $newPage;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the slug/uid of the page
|
||||
*
|
||||
* @param string $slug
|
||||
* @param string $language
|
||||
* @return self
|
||||
*/
|
||||
public function changeSlug(string $slug, string $languageCode = null): self
|
||||
{
|
||||
// always sanitize the slug
|
||||
$slug = Str::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 ($language = $this->kirby()->language($languageCode)) {
|
||||
if ($language->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;
|
||||
}
|
||||
|
||||
return $this->commit('changeSlug', [$this, $slug, $languageCode = null], function ($oldPage, $slug) {
|
||||
$newPage = $oldPage->clone([
|
||||
'slug' => $slug,
|
||||
'dirname' => null,
|
||||
'root' => null
|
||||
]);
|
||||
|
||||
// actually move stuff on disk
|
||||
if ($oldPage->exists() === true) {
|
||||
if (Dir::move($oldPage->root(), $newPage->root()) !== true) {
|
||||
throw new LogicException('The page directory cannot be moved');
|
||||
}
|
||||
|
||||
Dir::remove($oldPage->mediaRoot());
|
||||
}
|
||||
|
||||
// overwrite the new page in the parent collection
|
||||
if ($newPage->isDraft() === true) {
|
||||
$newPage->parentModel()->drafts()->set($newPage->id(), $newPage);
|
||||
} else {
|
||||
$newPage->parentModel()->children()->set($newPage->id(), $newPage);
|
||||
}
|
||||
|
||||
return $newPage;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the slug for a specific language
|
||||
*
|
||||
* @param string $slug
|
||||
* @param string $language
|
||||
* @return self
|
||||
*/
|
||||
protected function changeSlugForLanguage(string $slug, string $languageCode = null): self
|
||||
{
|
||||
$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');
|
||||
}
|
||||
|
||||
return $this->commit('changeSlug', [$this, $slug, $languageCode], function ($page, $slug, $languageCode) {
|
||||
// remove the slug if it's the same as the folder name
|
||||
if ($slug === $page->uid()) {
|
||||
$slug = null;
|
||||
}
|
||||
|
||||
return $page->save(['slug' => $slug], $languageCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the status of the current page
|
||||
* to either draft, listed or unlisted
|
||||
*
|
||||
* @param string $status "draft", "listed" or "unlisted"
|
||||
* @param integer $position Optional sorting number
|
||||
* @return Page
|
||||
*/
|
||||
public function changeStatus(string $status, int $position = null): self
|
||||
{
|
||||
switch ($status) {
|
||||
case 'draft':
|
||||
return $this->changeStatusToDraft();
|
||||
case 'listed':
|
||||
return $this->changeStatusToListed($position);
|
||||
case 'unlisted':
|
||||
return $this->changeStatusToUnlisted();
|
||||
default:
|
||||
throw new Exception('Invalid status: ' . $status);
|
||||
}
|
||||
}
|
||||
|
||||
protected function changeStatusToDraft(): self
|
||||
{
|
||||
$page = $this->commit('changeStatus', [$this, 'draft'], function ($page) {
|
||||
return $page->unpublish();
|
||||
});
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function changeStatusToListed(int $position = null): self
|
||||
{
|
||||
// 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', [$this, 'listed', $num], function ($page, $status, $position) {
|
||||
return $page->publish()->changeNum($position);
|
||||
});
|
||||
|
||||
if ($this->blueprint()->num() === 'default') {
|
||||
$page->resortSiblingsAfterListing($num);
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function changeStatusToUnlisted(): self
|
||||
{
|
||||
if ($this->status() === 'unlisted') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$page = $this->commit('changeStatus', [$this, 'unlisted'], function ($page) {
|
||||
return $page->publish()->changeNum(null);
|
||||
});
|
||||
|
||||
$this->resortSiblingsAfterUnlisting();
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the page template
|
||||
*
|
||||
* @param string $template
|
||||
* @return self
|
||||
*/
|
||||
public function changeTemplate(string $template): self
|
||||
{
|
||||
if ($template === $this->template()->name()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
// prepare data to transfer between blueprints
|
||||
$oldBlueprint = 'pages/' . $this->template();
|
||||
$newBlueprint = 'pages/' . $template;
|
||||
|
||||
return $this->commit('changeTemplate', [$this, $template], function ($oldPage, $template) use ($oldBlueprint, $newBlueprint) {
|
||||
if ($this->kirby()->multilang() === true) {
|
||||
$newPage = $this->clone([
|
||||
'template' => $template
|
||||
]);
|
||||
|
||||
foreach ($this->kirby()->languages()->codes() as $code) {
|
||||
if (F::remove($oldPage->contentFile($code)) !== true) {
|
||||
throw new LogicException('The old text file could not be removed');
|
||||
}
|
||||
|
||||
// convert the content to the new blueprint
|
||||
$content = $oldPage->transferData($oldPage->content($code), $oldBlueprint, $newBlueprint)['data'];
|
||||
|
||||
// save the language file
|
||||
$newPage->save($content, $code);
|
||||
}
|
||||
|
||||
// return a fresh copy of the object
|
||||
return $newPage->clone();
|
||||
} else {
|
||||
$newPage = $this->clone([
|
||||
'content' => $this->transferData($this->content(), $oldBlueprint, $newBlueprint)['data'],
|
||||
'template' => $template
|
||||
]);
|
||||
|
||||
if (F::remove($oldPage->contentFile()) !== true) {
|
||||
throw new LogicException('The old text file could not be removed');
|
||||
}
|
||||
|
||||
return $newPage->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the page title
|
||||
*
|
||||
* @param string $title
|
||||
* @param string|null $languageCode
|
||||
* @return self
|
||||
*/
|
||||
public function changeTitle(string $title, string $languageCode = null): self
|
||||
{
|
||||
return $this->commit('changeTitle', [$this, $title, $languageCode], function ($page, $title, $languageCode) {
|
||||
return $page->save(['title' => $title], $languageCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @param string $action
|
||||
* @param mixed ...$arguments
|
||||
* @return mixed
|
||||
*/
|
||||
protected function commit(string $action, array $arguments, Closure $callback)
|
||||
{
|
||||
$old = $this->hardcopy();
|
||||
|
||||
$this->rules()->$action(...$arguments);
|
||||
$this->kirby()->trigger('page.' . $action . ':before', ...$arguments);
|
||||
$result = $callback(...$arguments);
|
||||
$this->kirby()->trigger('page.' . $action . ':after', $result, $old);
|
||||
$this->kirby()->cache('pages')->flush();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and stores a new page
|
||||
*
|
||||
* @param array $props
|
||||
* @return self
|
||||
*/
|
||||
public static function create(array $props): self
|
||||
{
|
||||
// clean up the slug
|
||||
$props['slug'] = Str::slug($props['slug'] ?? $props['content']['title'] ?? null);
|
||||
$props['template'] = $props['model'] = strtolower($props['template'] ?? 'default');
|
||||
$props['isDraft'] = ($props['draft'] ?? true);
|
||||
|
||||
// create a temporary page object
|
||||
$page = Page::factory($props);
|
||||
|
||||
// create a form for the page
|
||||
$form = Form::for($page, [
|
||||
'values' => $props['content'] ?? []
|
||||
]);
|
||||
|
||||
// inject the content
|
||||
$page = $page->clone(['content' => $form->strings(true)]);
|
||||
|
||||
// run the hooks and creation action
|
||||
$page = $page->commit('create', [$page, $props], function ($page, $props) {
|
||||
|
||||
// always create pages in the default language
|
||||
if ($page->kirby()->multilang() === true) {
|
||||
$languageCode = $page->kirby()->defaultLanguage()->code();
|
||||
} else {
|
||||
$languageCode = null;
|
||||
}
|
||||
|
||||
// write the content file
|
||||
$page = $page->save($page->content()->toArray(), $languageCode);
|
||||
|
||||
// flush the parent cache to get children and drafts right
|
||||
if ($page->isDraft() === true) {
|
||||
$page->parentModel()->drafts()->append($page->id(), $page);
|
||||
} else {
|
||||
$page->parentModel()->children()->append($page->id(), $page);
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* @param array $props
|
||||
* @return self
|
||||
*/
|
||||
public function createChild(array $props): self
|
||||
{
|
||||
$props = array_merge($props, [
|
||||
'url' => null,
|
||||
'num' => null,
|
||||
'parent' => $this,
|
||||
'site' => $this->site(),
|
||||
]);
|
||||
|
||||
return static::create($props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the sorting number for the page
|
||||
* depending on the blueprint settings
|
||||
*
|
||||
* @param integer $num
|
||||
* @return integer
|
||||
*/
|
||||
public function createNum(int $num = null): int
|
||||
{
|
||||
$mode = $this->blueprint()->num();
|
||||
|
||||
switch ($mode) {
|
||||
case 'zero':
|
||||
return 0;
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
$format = 'date' ? 'Ymd' : 'YmdHi';
|
||||
$date = $this->content()->get('date')->value();
|
||||
$time = empty($date) === true ? time() : strtotime($date);
|
||||
|
||||
return date($format, $time);
|
||||
break;
|
||||
case 'default':
|
||||
|
||||
$max = $this
|
||||
->parentModel()
|
||||
->children()
|
||||
->listed()
|
||||
->merge($this)
|
||||
->count();
|
||||
|
||||
// default positioning at the end
|
||||
if ($num === null) {
|
||||
$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:
|
||||
$template = Str::template($mode, [
|
||||
'kirby' => $this->kirby(),
|
||||
'page' => $this,
|
||||
'site' => $this->site(),
|
||||
]);
|
||||
|
||||
return intval($template);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the page
|
||||
*
|
||||
* @param bool $force
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(bool $force = false): bool
|
||||
{
|
||||
return $this->commit('delete', [$this, $force], function ($page, $force) {
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($page->isDraft() === true) {
|
||||
$page->parentModel()->drafts()->remove($page);
|
||||
} else {
|
||||
$page->parentModel()->children()->remove($page);
|
||||
$page->resortSiblingsAfterUnlisting();
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function publish()
|
||||
{
|
||||
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
|
||||
$page->parentModel()->drafts()->remove($page);
|
||||
$page->parentModel()->children()->append($page->id(), $page);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean internal caches
|
||||
*/
|
||||
public function purge(): self
|
||||
{
|
||||
$this->children = null;
|
||||
$this->blueprint = null;
|
||||
$this->drafts = null;
|
||||
$this->files = null;
|
||||
$this->content = null;
|
||||
$this->inventory = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function resortSiblingsAfterListing(int $position = null): bool
|
||||
{
|
||||
// get all siblings including the current page
|
||||
$siblings = $this
|
||||
->parentModel()
|
||||
->children()
|
||||
->listed()
|
||||
->append($this)
|
||||
->filter(function ($page) {
|
||||
return $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;
|
||||
} else {
|
||||
if ($sibling = $siblings->get($id)) {
|
||||
$sibling->changeNum($key + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$parent = $this->parentModel();
|
||||
$parent->children = $parent->children()->sortBy('num', 'asc');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resortSiblingsAfterUnlisting(): bool
|
||||
{
|
||||
$index = 0;
|
||||
$parent = $this->parentModel();
|
||||
$siblings = $parent
|
||||
->children()
|
||||
->listed()
|
||||
->not($this)
|
||||
->filter(function ($page) {
|
||||
return $page->blueprint()->num() === 'default';
|
||||
});
|
||||
|
||||
if ($siblings->count() > 0) {
|
||||
foreach ($siblings as $sibling) {
|
||||
$index++;
|
||||
$sibling->changeNum($index);
|
||||
}
|
||||
|
||||
$parent->children = $siblings->sortBy('num', 'desc');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function sort($position = null)
|
||||
{
|
||||
return $this->changeStatus('listed', $position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers data from old to new blueprint and tracks changes
|
||||
*
|
||||
* @param Content $content
|
||||
* @param string $old Old blueprint
|
||||
* @param string $new New blueprint
|
||||
* @return array
|
||||
*/
|
||||
protected function transferData(Content $content, string $old, string $new): array
|
||||
{
|
||||
// Prepare data
|
||||
$data = [];
|
||||
$old = Blueprint::factory($old, 'pages/default', $this);
|
||||
$new = Blueprint::factory($new, 'pages/default', $this);
|
||||
$oldForm = new Form(['fields' => $old->fields(), 'model' => $this]);
|
||||
$newForm = new Form(['fields' => $new->fields(), 'model' => $this]);
|
||||
$oldFields = $oldForm->fields();
|
||||
$newFields = $newForm->fields();
|
||||
|
||||
// Tracking changes
|
||||
$added = [];
|
||||
$replaced = [];
|
||||
$removed = [];
|
||||
|
||||
// Ensure to keep title
|
||||
$data['title'] = $content->get('title')->value();
|
||||
|
||||
// Go through all fields of new template
|
||||
foreach ($newFields as $newField) {
|
||||
$name = $newField->name();
|
||||
$oldField = $oldFields->get($name);
|
||||
|
||||
// Field name matches with old template
|
||||
if ($oldField !== null) {
|
||||
|
||||
// Same field type, add and keep value
|
||||
if ($oldField->type() === $newField->type()) {
|
||||
$data[$name] = $content->get($name)->value();
|
||||
|
||||
// Different field type, add with empty value
|
||||
} else {
|
||||
$data[$name] = null;
|
||||
$replaced[$name] = $oldFields->get($name)->label() ?? $name;
|
||||
}
|
||||
|
||||
// Field does not exist in old template,
|
||||
// add with empty or preserved value
|
||||
} else {
|
||||
$preserved = $content->get($name);
|
||||
$data[$name] = $preserved ? $preserved->value(): null;
|
||||
$added[$name] = $newField->label() ?? $name;
|
||||
}
|
||||
}
|
||||
|
||||
// Go through all values to preserve them
|
||||
foreach ($content->fields() as $field) {
|
||||
$name = $field->key();
|
||||
$newField = $newFields->get($name);
|
||||
|
||||
if ($newField === null) {
|
||||
$data[$name] = $field->value();
|
||||
$removed[$name] = $field->name();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'added' => $added,
|
||||
'replaced' => $replaced,
|
||||
'removed' => $removed
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a page from listed or
|
||||
* unlisted to draft.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function unpublish()
|
||||
{
|
||||
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
|
||||
$page->parentModel()->children()->remove($page);
|
||||
$page->parentModel()->drafts()->append($page->id(), $page);
|
||||
|
||||
$page->resortSiblingsAfterUnlisting();
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the page data
|
||||
*
|
||||
* @param array $input
|
||||
* @param string $language
|
||||
* @param boolean $validate
|
||||
* @return self
|
||||
*/
|
||||
public function update(array $input = null, string $language = null, bool $validate = false)
|
||||
{
|
||||
if ($this->isDraft() === true) {
|
||||
$validate = false;
|
||||
}
|
||||
|
||||
$page = parent::update($input, $language, $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());
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user