init with kirby, vue and pagedjs interactive

This commit is contained in:
isUnknown 2025-11-24 14:01:48 +01:00
commit dc0ae26464
968 changed files with 211706 additions and 0 deletions

View file

@ -0,0 +1,197 @@
<?php
namespace Kirby\Content;
use Kirby\Cache\Cache;
use Kirby\Cms\App;
use Kirby\Cms\Files;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Pages;
use Kirby\Cms\Users;
use Kirby\Toolkit\A;
/**
* The Changes class tracks changed models
* in the Site's changes field.
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Changes
{
protected App $kirby;
public function __construct()
{
$this->kirby = App::instance();
}
/**
* Access helper for the cache, in which changes are stored
*/
public function cache(): Cache
{
return $this->kirby->cache('changes');
}
/**
* Returns whether the cache has been populated
*/
public function cacheExists(): bool
{
return $this->cache()->get('__updated__') !== null;
}
/**
* Returns the cache key for a given model
*/
public function cacheKey(ModelWithContent $model): string
{
return $model::CLASS_ALIAS . 's';
}
/**
* Verify that the tracked model still really has changes.
* If not, untrack and remove from collection.
*
* @template T of \Kirby\Cms\Files|\Kirby\Cms\Pages|\Kirby\Cms\Users
* @param T $tracked
* @return T
*/
public function ensure(Files|Pages|Users $tracked): Files|Pages|Users
{
foreach ($tracked as $model) {
if ($model->version('changes')->exists('*') === false) {
$this->untrack($model);
$tracked->remove($model);
}
}
return $tracked;
}
/**
* Return all files with unsaved changes
*/
public function files(): Files
{
$files = new Files([]);
foreach ($this->read('files') as $id) {
if ($file = $this->kirby->file($id)) {
$files->add($file);
}
}
return $this->ensure($files);
}
/**
* Rebuilds the cache by finding all models with changes version
*/
public function generateCache(): void
{
$models = [
'files' => [],
'pages' => [],
'users' => []
];
foreach ($this->kirby->models() as $model) {
if ($model->version('changes')->exists('*') === true) {
$models[$this->cacheKey($model)][] = (string)($model->uuid() ?? $model->id());
}
}
foreach ($models as $key => $changes) {
$this->update($key, $changes);
}
}
/**
* Return all pages with unsaved changes
*/
public function pages(): Pages
{
/**
* @var \Kirby\Cms\Pages $pages
*/
$pages = $this->kirby->site()->find(
false,
false,
...$this->read('pages')
);
return $this->ensure($pages);
}
/**
* Read the changes for a given model type
*/
public function read(string $key): array
{
return $this->cache()->get($key) ?? [];
}
/**
* Add a new model to the list of unsaved changes
*/
public function track(ModelWithContent $model): void
{
$key = $this->cacheKey($model);
$changes = $this->read($key);
$changes[] = (string)($model->uuid() ?? $model->id());
$this->update($key, $changes);
}
/**
* Remove a model from the list of unsaved changes
*/
public function untrack(ModelWithContent $model): void
{
// get the cache key for the model type
$key = $this->cacheKey($model);
// remove the model from the list of changes
$changes = A::filter(
$this->read($key),
fn ($id) => $id !== (string)($model->uuid() ?? $model->id())
);
$this->update($key, $changes);
}
/**
* Update the changes field
*/
public function update(string $key, array $changes): void
{
$changes = array_unique($changes);
$changes = array_values($changes);
$this->cache()->set($key, $changes);
$this->cache()->set('__updated__', time());
}
/**
* Return all users with unsaved changes
*/
public function users(): Users
{
/**
* @var \Kirby\Cms\Users $users
*/
$users = $this->kirby->users()->find(
false,
false,
...$this->read('users')
);
return $this->ensure($users);
}
}

View file

@ -0,0 +1,253 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Blueprint;
use Kirby\Cms\File;
use Kirby\Cms\ModelWithContent;
use Kirby\Form\Form;
/**
* The Content class handles all fields
* for content from pages, the site and users
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Content
{
/**
* The raw data array
*/
protected array $data = [];
/**
* Cached field objects
* Once a field is being fetched
* it is added to this array for
* later reuse
*/
protected array $fields = [];
/**
* A potential parent object.
* Not necessarily needed. Especially
* for testing, but field methods might
* need it.
*/
protected ModelWithContent|null $parent;
/**
* Magic getter for content fields
*/
public function __call(string $name, array $arguments = []): Field
{
return $this->get($name);
}
/**
* Creates a new Content object
*
* @param bool $normalize Set to `false` if the input field keys are already lowercase
*/
public function __construct(
array $data = [],
ModelWithContent|null $parent = null,
bool $normalize = true
) {
if ($normalize === true) {
$data = array_change_key_case($data, CASE_LOWER);
}
$this->data = $data;
$this->parent = $parent;
}
/**
* Same as `self::data()` to improve
* `var_dump` output
* @codeCoverageIgnore
*
* @see self::data()
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Converts the content to a new blueprint
*/
public function convertTo(string $to): array
{
// prepare data
$data = [];
$content = $this;
// blueprints
$old = $this->parent->blueprint();
$subfolder = dirname($old->name());
$new = Blueprint::factory(
$subfolder . '/' . $to,
$subfolder . '/default',
$this->parent
);
// forms
$oldForm = new Form(
fields: $old->fields(),
model: $this->parent
);
$newForm = new Form(
fields: $new->fields(),
model: $this->parent
);
// fields
$oldFields = $oldForm->fields();
$newFields = $newForm->fields();
// go through all fields of new template
foreach ($newFields as $newField) {
$name = $newField->name();
$oldField = $oldFields->get($name);
// field name and type matches with old template
if ($oldField?->type() === $newField->type()) {
$data[$name] = $content->get($name)->value();
} else {
$data[$name] = $newField->default();
}
}
// if the parent is a file, overwrite the template
// with the new template name
if ($this->parent instanceof File) {
$data['template'] = $to;
}
// preserve existing fields
return [...$this->data, ...$data];
}
/**
* Returns the raw data array
*/
public function data(): array
{
return $this->data;
}
/**
* Returns all registered field objects
*/
public function fields(): array
{
foreach ($this->data as $key => $value) {
$this->get($key);
}
return $this->fields;
}
/**
* Returns either a single field object
* or all registered fields
*/
public function get(string|null $key = null): Field|array
{
if ($key === null) {
return $this->fields();
}
$key = strtolower($key);
return $this->fields[$key] ??= new Field(
$this->parent,
$key,
$this->data()[$key] ?? null
);
}
/**
* Checks if a content field is set
*/
public function has(string $key): bool
{
return isset($this->data[strtolower($key)]) === true;
}
/**
* Returns all field keys
*/
public function keys(): array
{
return array_keys($this->data());
}
/**
* Returns a clone of the content object
* without the fields, specified by the
* passed key(s)
*/
public function not(string ...$keys): static
{
$copy = clone $this;
$copy->fields = [];
foreach ($keys as $key) {
unset($copy->data[strtolower($key)]);
}
return $copy;
}
/**
* Returns the parent
* Site, Page, File or User object
*/
public function parent(): ModelWithContent|null
{
return $this->parent;
}
/**
* Set the parent model
*
* @return $this
*/
public function setParent(ModelWithContent $parent): static
{
$this->parent = $parent;
return $this;
}
/**
* Returns the raw data array
*
* @see self::data()
*/
public function toArray(): array
{
return $this->data();
}
/**
* Updates the content in memory.
*/
public function update(
array|null $content = null,
bool $overwrite = false
): static {
$content = array_change_key_case((array)$content, CASE_LOWER);
$this->data = $overwrite === true ? $content : array_merge($this->data, $content);
// clear cache of Field objects
$this->fields = [];
return $this;
}
}

View file

@ -0,0 +1,212 @@
<?php
namespace Kirby\Content;
use Closure;
use Kirby\Cms\ModelWithContent;
use Stringable;
/**
* Every field in a Kirby content text file
* is being converted into such a Field object.
*
* Field methods can be registered for those Field
* objects, which can then be used to transform or
* convert the field value. This enables our
* daisy-chaining API for templates and other components
*
* ```php
* // Page field example with lowercase conversion
* $page->myField()->lower();
* ```
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Field implements Stringable
{
/**
* Field method aliases
*/
public static array $aliases = [];
/**
* Registered field methods
*/
public static array $methods = [];
/**
* Creates a new field object
*
* @param \Kirby\Cms\ModelWithContent|null $parent Parent object if available. This will be the page, site, user or file to which the content belongs
* @param string $key The field name
*/
public function __construct(
protected ModelWithContent|null $parent,
protected string $key,
public mixed $value
) {
}
/**
* Magic caller for field methods
*/
public function __call(string $method, array $arguments = []): mixed
{
$method = strtolower($method);
if (isset(static::$methods[$method]) === true) {
return (static::$methods[$method])(clone $this, ...$arguments);
}
if (isset(static::$aliases[$method]) === true) {
$method = strtolower(static::$aliases[$method]);
if (isset(static::$methods[$method]) === true) {
return (static::$methods[$method])(clone $this, ...$arguments);
}
}
return $this;
}
/**
* Simplifies the var_dump result
* @codeCoverageIgnore
*
* @see self::toArray()
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Makes it possible to simply echo
* or stringify the entire object
*
* @see self::toString()
*/
public function __toString(): string
{
return $this->toString();
}
/**
* Checks if the field exists in the content data array
*/
public function exists(): bool
{
return $this->parent->content()->has($this->key);
}
/**
* Checks if the field content is empty
*/
public function isEmpty(): bool
{
$value = $this->value;
if (is_string($value) === true) {
$value = trim($value);
}
return
$value === null ||
$value === '' ||
$value === [] ||
$value === '[]';
}
/**
* Checks if the field content is not empty
*/
public function isNotEmpty(): bool
{
return $this->isEmpty() === false;
}
/**
* Returns the name of the field
*/
public function key(): string
{
return $this->key;
}
/**
* @see self::parent()
*/
public function model(): ModelWithContent|null
{
return $this->parent;
}
/**
* Provides a fallback if the field value is empty
*
* @return $this|static
*/
public function or(mixed $fallback = null): static
{
if ($this->isNotEmpty() === true) {
return $this;
}
if ($fallback instanceof self) {
return $fallback;
}
$field = clone $this;
$field->value = $fallback;
return $field;
}
/**
* Returns the parent object of the field
*/
public function parent(): ModelWithContent|null
{
return $this->parent;
}
/**
* Converts the Field object to an array
*/
public function toArray(): array
{
return [$this->key => $this->value];
}
/**
* Returns the field value as string
*/
public function toString(): string
{
return (string)$this->value;
}
/**
* Returns the field content. If a new value is passed,
* the modified field will be returned. Otherwise it
* will return the field value.
*/
public function value(string|Closure|null $value = null): mixed
{
if ($value === null) {
return $this->value;
}
if ($value instanceof Closure) {
$value = $value->call($this, $this->value);
}
$clone = clone $this;
$clone->value = (string)$value;
return $clone;
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\LogicException;
/**
* @package Kirby Content
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ImmutableMemoryStorage extends MemoryStorage
{
public function __construct(
protected ModelWithContent $model,
protected ModelWithContent|null $nextModel = null
) {
parent::__construct($model);
}
/**
* Immutable storage entries cannot be deleted
*
* @throws \Kirby\Exception\LogicException
*/
public function delete(VersionId $versionId, Language $language): void
{
$this->preventMutation('deleted');
}
/**
* Immutable storage entries cannot be moved
*
* @throws \Kirby\Exception\LogicException
*/
public function move(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
): void {
$this->preventMutation('moved');
}
/**
* Returns the next state of the model if the
* reference is given
*/
public function nextModel(): ModelWithContent|null
{
return $this->nextModel;
}
/**
* Throws an exception to avoid the mutation of storage data
*
* @throws \Kirby\Exception\LogicException
*/
protected function preventMutation(string $mutation): void
{
throw new LogicException(
message: 'Storage for the ' . $this->model::CLASS_ALIAS . ' is immutable and cannot be ' . $mutation . '. Make sure to use the last alteration of the object.'
);
}
/**
* Immutable storage entries cannot be touched
*
* @throws \Kirby\Exception\LogicException
*/
public function touch(VersionId $versionId, Language $language): void
{
$this->preventMutation('touched');
}
/**
* Immutable storage entries cannot be updated
*
* @throws \Kirby\Exception\LogicException
*/
public function update(VersionId $versionId, Language $language, array $fields): void
{
$this->preventMutation('updated');
}
}

View file

@ -0,0 +1,229 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\User;
use Kirby\Data\Data;
use Kirby\Toolkit\Str;
/**
* The Lock class provides information about the
* locking state of a content version, depending
* on the timestamp and locked user id
*
* @since 5.0.0
* @unstable
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Lock
{
public function __construct(
protected User|null $user = null,
protected int|null $modified = null,
protected bool $legacy = false
) {
}
/**
* Creates a lock for the given version by
* reading the modification timestamp and
* lock user id from the version.
*/
public static function for(
Version $version,
Language|string $language = 'default'
): static {
if ($legacy = static::legacy($version->model())) {
return $legacy;
}
// wildcard to search for a lock in any language
// the first locked one will be preferred
if ($language === '*') {
foreach (Languages::ensure() as $language) {
$lock = static::for($version, $language);
// return the first locked lock if any exists
if ($lock->isLocked() === true) {
return $lock;
}
}
// return the last lock if no lock was found
return $lock;
}
$language = Language::ensure($language);
// if the version does not exist, it cannot be locked
if ($version->exists($language) === false) {
// create an open lock for the current user
return new static(
user: App::instance()->user(),
);
}
// Read the locked user id from the version
if ($userId = ($version->read($language)['lock'] ?? null)) {
$user = App::instance()->user($userId);
}
return new static(
user: $user ?? null,
modified: $version->modified($language)
);
}
/**
* Checks if the lock is still active because
* recent changes have been made to the content
*/
public function isActive(): bool
{
$minutes = 10;
return $this->modified > time() - (60 * $minutes);
}
/**
* Checks if content locking is enabled at all
*/
public static function isEnabled(): bool
{
return App::instance()->option('content.locking', true) !== false;
}
/**
* Checks if the lock is coming from an old .lock file
*/
public function isLegacy(): bool
{
return $this->legacy;
}
/**
* Checks if the lock is actually locked
*/
public function isLocked(): bool
{
// if locking is disabled globally,
// the lock is always open
if (static::isEnabled() === false) {
return false;
}
if ($this->user === null) {
return false;
}
// the version is not locked if the editing user
// is the currently logged in user
if ($this->user === App::instance()->user()) {
return false;
}
// check if the lock is still active due to the
// content currently being edited.
if ($this->isActive() === false) {
return false;
}
return true;
}
/**
* Looks for old .lock files and tries to create a
* usable lock instance from them
*/
public static function legacy(ModelWithContent $model): static|null
{
$kirby = $model->kirby();
$file = static::legacyFile($model);
$id = '/' . $model->id();
// no legacy lock file? no lock.
if (file_exists($file) === false) {
return null;
}
$data = Data::read($file, 'yml', fail: false)[$id] ?? [];
// no valid lock entry? no lock.
if (isset($data['lock']) === false) {
return null;
}
// has the lock been unlocked? no lock.
if (isset($data['unlock']) === true) {
return null;
}
return new static(
user: $kirby->user($data['lock']['user']),
modified: $data['lock']['time'],
legacy: true
);
}
/**
* Returns the absolute path to a legacy lock file
*/
public static function legacyFile(ModelWithContent $model): string
{
$root = match ($model::CLASS_ALIAS) {
'file' => dirname($model->root()),
default => $model->root()
};
return $root . '/.lock';
}
/**
* Returns the timestamp when the locked content has
* been updated. You can pass a format to get a useful,
* formatted date back.
*/
public function modified(
string|null $format = null,
string|null $handler = null
): int|string|false|null {
if ($this->modified === null) {
return null;
}
return Str::date($this->modified, $format, $handler);
}
/**
* Converts the lock info to an array. This is directly
* usable for Panel view props.
*/
public function toArray(): array
{
return [
'isLegacy' => $this->isLegacy(),
'isLocked' => $this->isLocked(),
'modified' => $this->modified('c', 'date'),
'user' => [
'id' => $this->user?->id(),
'email' => $this->user?->email()
]
];
}
/**
* Returns the user to whom this lock belongs
*/
public function user(): User|null
{
return $this->user;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Kirby\Content;
use Kirby\Exception\LogicException;
/**
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LockedContentException extends LogicException
{
protected static string $defaultKey = 'content.lock';
protected static string $defaultFallback = 'The version is locked';
protected static int $defaultHttpCode = 423;
public function __construct(
Lock $lock,
string|null $key = null,
string|null $message = null,
) {
parent::__construct(
message: $message,
key: $key,
details: $lock->toArray()
);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Kirby\Content;
use Kirby\Cache\MemoryCache;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
/**
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class MemoryStorage extends Storage
{
/**
* Cache instance, used to store content in memory
*/
protected MemoryCache $cache;
/**
* Sets up the cache instance
*/
public function __construct(protected ModelWithContent $model)
{
parent::__construct($model);
$this->cache = new MemoryCache();
}
/**
* Returns a unique id for a combination
* of the version id, the language code and the model id
*/
protected function cacheId(VersionId $versionId, Language $language): string
{
return $versionId->value() . '/' . $language->code() . '/' . $this->model->id() . '/' . spl_object_hash($this->model);
}
/**
* Deletes an existing version in an idempotent way if it was already deleted
*/
public function delete(VersionId $versionId, Language $language): void
{
$this->cache->remove($this->cacheId($versionId, $language));
}
/**
* Checks if a version exists
*/
public function exists(VersionId $versionId, Language $language): bool
{
return $this->cache->exists($this->cacheId($versionId, $language));
}
/**
* Returns the modification timestamp of a version if it exists
*/
public function modified(VersionId $versionId, Language $language): int|null
{
if ($this->exists($versionId, $language) === false) {
return null;
}
return $this->cache->modified($this->cacheId($versionId, $language));
}
/**
* Returns the stored content fields
*
* @return array<string, string>
*/
public function read(VersionId $versionId, Language $language): array
{
return $this->cache->get($this->cacheId($versionId, $language)) ?? [];
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function touch(VersionId $versionId, Language $language): void
{
$fields = $this->read($versionId, $language);
$this->write($versionId, $language, $fields);
}
/**
* Writes the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*/
protected function write(VersionId $versionId, Language $language, array $fields): void
{
$this->cache->set($this->cacheId($versionId, $language), $fields);
}
}

View file

@ -0,0 +1,331 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\File;
use Kirby\Cms\Language;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
/**
* Content storage handler using plain text files
* stored in the content folder
*
* @package Kirby Content
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 4.0.0
* @unstable
*/
class PlainTextStorage extends Storage
{
/**
* Creates the absolute directory path for the model
*/
protected function contentDirectory(VersionId $versionId): string
{
$directory = match (true) {
$this->model instanceof File
=> dirname($this->model->root()),
default
=> $this->model->root()
};
if ($versionId->is('changes')) {
$directory .= '/_changes';
}
return $directory;
}
/**
* Returns the absolute path to the content file
* @internal To be made `protected` when the CMS core no longer relies on it
*/
public function contentFile(VersionId $versionId, Language $language): string
{
// get the filename without extension and language code
return match (true) {
$this->model instanceof File => $this->contentFileForFile($this->model, $versionId, $language),
$this->model instanceof Page => $this->contentFileForPage($this->model, $versionId, $language),
$this->model instanceof Site => $this->contentFileForSite($this->model, $versionId, $language),
$this->model instanceof User => $this->contentFileForUser($this->model, $versionId, $language),
// @codeCoverageIgnoreStart
default => throw new LogicException(
message: 'Cannot determine content file for model type "' . $this->model::CLASS_ALIAS . '"'
)
// @codeCoverageIgnoreEnd
};
}
/**
* Returns the absolute path to the content file of a file model
*/
protected function contentFileForFile(File $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->filename(), $language);
}
/**
* Returns the absolute path to the content file of a page model
*/
protected function contentFileForPage(Page $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->intendedTemplate()->name(), $language);
}
/**
* Returns the absolute path to the content file of a site model
*/
protected function contentFileForSite(Site $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename('site', $language);
}
/**
* Returns the absolute path to the content file of a user model
*/
protected function contentFileForUser(User $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename('user', $language);
}
/**
* Creates a filename with extension and optional language code
* in a multi-language installation
*/
protected function contentFilename(string $name, Language $language): string
{
$kirby = $this->model->kirby();
$extension = $kirby->contentExtension();
if ($language->isSingle() === false) {
return $name . '.' . $language->code() . '.' . $extension;
}
return $name . '.' . $extension;
}
/**
* Returns an array with content files of all languages
* @internal To be made `protected` when the CMS core no longer relies on it
*/
public function contentFiles(VersionId $versionId): array
{
if ($this->model->kirby()->multilang() === true) {
return $this->model->kirby()->languages()->values(
fn ($language) => $this->contentFile($versionId, $language)
);
}
return [
$this->contentFile($versionId, Language::single())
];
}
/**
* Deletes an existing version in an idempotent way if it was already deleted
*/
public function delete(VersionId $versionId, Language $language): void
{
$contentFile = $this->contentFile($versionId, $language);
// @codeCoverageIgnoreStart
if (F::unlink($contentFile) !== true) {
throw new Exception(message: 'Could not delete content file');
}
// @codeCoverageIgnoreEnd
$contentDirectory = $this->contentDirectory($versionId);
// clean up empty content directories (_changes or the page/user directory)
$this->deleteEmptyDirectory($contentDirectory);
// delete empty _drafts directories for pages
if (
$versionId->is('latest') === true &&
$this->model instanceof Page &&
$this->model->isDraft() === true
) {
$this->deleteEmptyDirectory(dirname($contentDirectory));
}
}
/**
* Helper to delete empty _changes directories
*
* @throws \Kirby\Exception\Exception if the directory cannot be deleted
*/
protected function deleteEmptyDirectory(string $directory): void
{
if (
Dir::exists($directory) === true &&
Dir::isEmpty($directory) === true
) {
// @codeCoverageIgnoreStart
if (Dir::remove($directory) !== true) {
throw new Exception(
message: 'Could not delete empty content directory'
);
}
// @codeCoverageIgnoreEnd
}
}
/**
* Checks if a version exists
*/
public function exists(VersionId $versionId, Language $language): bool
{
$contentFile = $this->contentFile($versionId, $language);
// The version definitely exists, if there's a
// matching content file
if (file_exists($contentFile) === true) {
return true;
}
// A changed version or non-default language version does not exist
// if the content file was not found
if (
$versionId->is('latest') === false ||
$language->isDefault() === false
) {
return false;
}
// Whether the default version exists,
// depends on different cases for each model.
// Page, Site and User exist as soon as the folder is there.
// A File exists as soon as the file is there.
return match (true) {
$this->model instanceof File => is_file($this->model->root()) === true,
$this->model instanceof Page,
$this->model instanceof Site,
$this->model instanceof User => is_dir($this->model->root()) === true,
// @codeCoverageIgnoreStart
default => throw new LogicException(
message: 'Cannot determine existence for model type "' . $this->model::CLASS_ALIAS . '"'
)
// @codeCoverageIgnoreEnd
};
}
/**
* Compare two version-language-storage combinations
*/
public function isSameStorageLocation(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
) {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
// no need to compare content files if the new
// storage type is different
if ($toStorage instanceof self === false) {
return false;
}
$contentFileA = $this->contentFile($fromVersionId, $fromLanguage);
$contentFileB = $toStorage->contentFile($toVersionId, $toLanguage);
return $contentFileA === $contentFileB;
}
/**
* Returns the modification timestamp of a version
* if it exists
*/
public function modified(VersionId $versionId, Language $language): int|null
{
$modified = F::modified($this->contentFile($versionId, $language));
if (is_int($modified) === true) {
return $modified;
}
return null;
}
/**
* Returns the stored content fields
*
* @return array<string, string>
*/
public function read(VersionId $versionId, Language $language): array
{
$contentFile = $this->contentFile($versionId, $language);
if (file_exists($contentFile) === true) {
return Data::read($contentFile);
}
// For existing versions that don't have a content file yet,
// we can safely return an empty array that can be filled later.
// This might be the case for pages that only have a directory
// so far, or for files that don't have any metadata yet.
return [];
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\Exception If the file cannot be touched
*/
public function touch(VersionId $versionId, Language $language): void
{
$success = touch($this->contentFile($versionId, $language));
// @codeCoverageIgnoreStart
if ($success !== true) {
throw new Exception(
message: 'Could not touch existing content file'
);
}
// @codeCoverageIgnoreEnd
}
/**
* Writes the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\Exception If the content cannot be written
*/
protected function write(VersionId $versionId, Language $language, array $fields): void
{
// only store non-null value fields
$fields = array_filter($fields, fn ($field) => $field !== null);
// Content for files is only stored when there are any fields.
// Otherwise, the storage handler will take care here of cleaning up
// unnecessary content files.
if ($this->model instanceof File && $fields === []) {
$this->delete($versionId, $language);
return;
}
$success = Data::write($this->contentFile($versionId, $language), $fields);
// @codeCoverageIgnoreStart
if ($success !== true) {
throw new Exception(message: 'Could not write the content file');
}
// @codeCoverageIgnoreEnd
}
}

View file

@ -0,0 +1,325 @@
<?php
namespace Kirby\Content;
use Generator;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\A;
/**
* Abstract for content storage handlers;
* note that it is so far not viable to build custom
* handlers because the CMS core relies on the filesystem
* and cannot fully benefit from this abstraction yet
*
* @package Kirby Content
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 4.0.0
* @unstable
*/
abstract class Storage
{
public function __construct(protected ModelWithContent $model)
{
}
/**
* Returns generator for all existing version-language combinations
*
* @return Generator<\Kirby\Content\VersionId, \Kirby\Cms\Language>
*/
public function all(): Generator
{
foreach (Languages::ensure() as $language) {
foreach ($this->model->versions() as $version) {
if ($this->exists($version->id(), $language) === true) {
yield $version->id() => $language;
}
}
}
}
/**
* Copies content from one version-language combination to another
*/
public function copy(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
): void {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
// don't copy content to the same version-language-storage combination
if ($this->isSameStorageLocation(
fromVersionId: $fromVersionId,
fromLanguage: $fromLanguage,
toVersionId: $toVersionId,
toLanguage: $toLanguage,
toStorage: $toStorage
)) {
return;
}
// read the existing fields
$content = $this->read($fromVersionId, $fromLanguage);
// create the new version
$toStorage->create($toVersionId, $toLanguage, $content);
}
/**
* Copies all content to another storage
*/
public function copyAll(Storage $to): void
{
foreach ($this->all() as $versionId => $language) {
$this->copy($versionId, $language, toStorage: $to);
}
}
/**
* Creates a new version
*
* @param array<string, string> $fields Content fields
*/
public function create(VersionId $versionId, Language $language, array $fields): void
{
$this->write($versionId, $language, $fields);
}
/**
* Deletes an existing version in an idempotent way if it was already deleted
*/
abstract public function delete(VersionId $versionId, Language $language): void;
/**
* Deletes all versions when deleting a language
* @unstable
* @todo Move to `Language` class
*/
public function deleteLanguage(Language $language): void
{
foreach ($this->model->versions() as $version) {
$this->delete($version->id(), $language);
}
}
/**
* Checks if a version exists
*/
abstract public function exists(VersionId $versionId, Language $language): bool;
/**
* Creates a new storage instance with all the versions
* from the given storage instance.
*/
public static function from(self $fromStorage): static
{
$toStorage = new static(
model: $fromStorage->model()
);
// copy all versions from the given storage instance
// and add them to the new storage instance.
$fromStorage->copyAll($toStorage);
return $toStorage;
}
/**
* Compare two version-language-storage combinations
*/
public function isSameStorageLocation(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
) {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
if (
$fromVersionId->is($toVersionId) &&
$fromLanguage->is($toLanguage) &&
$this === $toStorage
) {
return true;
}
return false;
}
/**
* Returns the related model
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* Returns the modification timestamp of a version if it exists
*/
abstract public function modified(VersionId $versionId, Language $language): int|null;
/**
* Moves content from one version-language combination to another
*/
public function move(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
): void {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
// don't move content to the same version-language-storage combination
if ($this->isSameStorageLocation(
fromVersionId: $fromVersionId,
fromLanguage: $fromLanguage,
toVersionId: $toVersionId,
toLanguage: $toLanguage,
toStorage: $toStorage
)) {
return;
}
// copy content to new version
$this->copy(
$fromVersionId,
$fromLanguage,
$toVersionId,
$toLanguage,
$toStorage
);
// clean up the old version
$this->delete($fromVersionId, $fromLanguage);
}
/**
* Moves all content to another storage
*/
public function moveAll(Storage $to): void
{
foreach ($this->all() as $versionId => $language) {
$this->move($versionId, $language, toStorage: $to);
}
}
/**
* Adapts all versions when converting languages
* @unstable
* @todo Move to `Language` class
*/
public function moveLanguage(
Language $fromLanguage,
Language $toLanguage
): void {
foreach ($this->model->versions() as $version) {
if ($this->exists($version->id(), $fromLanguage) === true) {
$this->move(
$version->id(),
$fromLanguage,
toLanguage: $toLanguage
);
}
}
}
/**
* Returns the stored content fields
*
* @return array<string, string>
*/
abstract public function read(VersionId $versionId, Language $language): array;
/**
* Searches and replaces one or multiple strings
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function replaceStrings(
VersionId $versionId,
Language $language,
array $map
): void {
$fields = $this->read($versionId, $language);
$fields = A::map(
$fields,
function ($value) use ($map) {
// skip fields with null values
if ($value === null) {
return null;
}
return str_replace(
array_keys($map),
array_values($map),
$value
);
}
);
$this->update($versionId, $language, $fields);
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
abstract public function touch(VersionId $versionId, Language $language): void;
/**
* Touches all versions of a language
* @unstable
* @todo Move to `Language` class
*/
public function touchLanguage(Language $language): void
{
foreach ($this->model->versions() as $version) {
if ($this->exists($version->id(), $language) === true) {
$this->touch($version->id(), $language);
}
}
}
/**
* Updates the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\Exception If the file cannot be written
*/
public function update(VersionId $versionId, Language $language, array $fields): void
{
$this->write($versionId, $language, $fields);
}
/**
* Writes the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\Exception If the content cannot be written
*/
abstract protected function write(VersionId $versionId, Language $language, array $fields): void;
}

View file

@ -0,0 +1,191 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Helpers;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\Exception;
/**
* Each page, file or site can have multiple
* translated versions of their content,
* represented by this class
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Translation
{
/**
* Creates a new translation object
*/
public function __construct(
protected ModelWithContent $model,
protected Version $version,
protected Language $language
) {
}
/**
* Improve `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Returns the language code of the
* translation
*/
public function code(): string
{
return $this->language->code();
}
/**
* Returns the translation content
* as plain array
*/
public function content(): array
{
return $this->version->content($this->language)->toArray();
}
/**
* Absolute path to the translation content file
*
* @deprecated 5.0.0
*/
public function contentFile(): string
{
Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods');
return $this->version->contentFile($this->language);
}
/**
* Creates a new Translation for the given model
*
* @todo Needs to be refactored as soon as Version::create becomes static
* (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
*/
public static function create(
ModelWithContent $model,
Version $version,
Language $language,
array $fields,
string|null $slug = null
): static {
// add the custom slug to the fields array
if ($slug !== null) {
$fields['slug'] = $slug;
}
$version->save($fields, $language);
return new static(
model: $model,
version: $version,
language: $language,
);
}
/**
* Checks if the translation file exists
*/
public function exists(): bool
{
return $this->version->exists($this->language);
}
/**
* Returns the translation code as id
*/
public function id(): string
{
return $this->language->code();
}
/**
* Checks if the this is the default translation
* of the model
*
* @deprecated 5.0.0 Use `::language()->isDefault()` instead
*/
public function isDefault(): bool
{
Helpers::deprecated('`$translation->isDefault()` has been deprecated. Use `$translation->language()->isDefault()` instead.', 'translation-methods');
return $this->language->isDefault();
}
/**
* Returns the language
*/
public function language(): Language
{
return $this->language;
}
/**
* Returns the parent page, file or site object
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* @deprecated 5.0.0 Use `$translation->model()` instead
*/
public function parent(): ModelWithContent
{
throw new Exception(
message: '`$translation->parent()` has been deprecated. Please use `$translation->model()` instead'
);
}
/**
* Returns the custom translation slug
*/
public function slug(): string|null
{
return $this->version->read($this->language)['slug'] ?? null;
}
/**
* Converts the most important translation
* props to an array
*/
public function toArray(): array
{
return [
'code' => $this->language->code(),
'content' => $this->content(),
'exists' => $this->exists(),
'slug' => $this->slug(),
];
}
/**
* @deprecated 5.0.0 Use `$model->version()->update()` instead
*/
public function update(array|null $data = null, bool $overwrite = false): static
{
throw new Exception(
message: '`$translation->update()` has been deprecated. Please use `$model->version()->update()` instead'
);
}
/**
* Returns the version
*/
public function version(): Version
{
return $this->version;
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Collection;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
/**
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Content\Translation>
*/
class Translations extends Collection
{
/**
* Creates a new Translations collection from
* an array of translations properties. This is
* used in ModelWithContent::setTranslations to properly
* normalize an array definition.
*
* @todo Needs to be refactored as soon as Version::create becomes static
* (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
*/
public static function create(
ModelWithContent $model,
Version $version,
array $translations
): static {
foreach ($translations as $translation) {
Translation::create(
model: $model,
version: $version,
language: Language::ensure($translation['code'] ?? 'default'),
fields: $translation['content'] ?? [],
slug: $translation['slug'] ?? null
);
}
return static::load(
model: $model,
version: $version
);
}
/**
* Simplifies `Translations::find` by allowing to pass
* Language codes that will be properly validated here.
*/
public function findByKey(string $key): Translation|null
{
return parent::get(Language::ensure($key)->code());
}
/**
* Loads all available translations for a given model
*/
public static function load(
ModelWithContent $model,
Version $version
): static {
$translations = [];
foreach (Languages::ensure() as $language) {
$translations[] = new Translation(
model: $model,
version: $version,
language: $language
);
}
return new static($translations);
}
}

View file

@ -0,0 +1,687 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Fields;
use Kirby\Http\Uri;
/**
* The Version class handles all actions for a single
* version and is identified by a VersionId instance
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class Version
{
public function __construct(
protected ModelWithContent $model,
protected VersionId $id
) {
}
/**
* Returns a Content object for the given language
*/
public function content(Language|string $language = 'default'): Content
{
$language = Language::ensure($language);
$fields = $this->read($language) ?? [];
// This is where we merge content from the default language
// to provide a fallback for missing/untranslated fields.
//
// @todo This is the critical point that needs to be removed/refactored
// in the future, to provide multi-language support with truly
// individual versions of pages and no longer enforce the fallback.
if ($language->isDefault() === false) {
// merge the fields with the default language
$fields = [
...$this->read('default') ?? [],
...$fields
];
}
// remove fields that should not be used for the Content object
unset($fields['lock']);
return new Content(
parent: $this->model,
data: $fields,
normalize: false
);
}
/**
* Provides simplified access to the absolute content file path.
* This should stay an internal method and be removed as soon as
* the dependency on file storage methods is resolved more clearly.
*
* @internal
*/
public function contentFile(Language|string $language = 'default'): string
{
return $this->model->storage()->contentFile(
$this->id,
Language::ensure($language)
);
}
/**
* Make sure that all field names are converted to lower
* case to be able to merge and filter them properly
*/
protected function convertFieldNamesToLowerCase(array $fields): array
{
return array_change_key_case($fields, CASE_LOWER);
}
/**
* Creates a new version for the given language
* @todo Convert to a static method that creates the version initially with all relevant languages
*
* @param array<string, string> $fields Content fields
*/
public function create(
array $fields,
Language|string $language = 'default'
): void {
$language = Language::ensure($language);
// check if creating is allowed
VersionRules::create($this, $fields, $language);
// track the changes
if ($this->id->is('changes') === true) {
(new Changes())->track($this->model);
}
$this->model->storage()->create(
versionId: $this->id,
language: $language,
fields: $this->prepareFieldsBeforeWrite($fields, $language)
);
// make sure that an older version does not exist in the cache
VersionCache::remove($this, $language);
}
/**
* Deletes a version for a specific language
*/
public function delete(Language|string $language = 'default'): void
{
if ($language === '*') {
foreach (Languages::ensure() as $language) {
$this->delete($language);
}
return;
}
$language = Language::ensure($language);
// check if deleting is allowed
VersionRules::delete($this, $language);
$this->model->storage()->delete($this->id, $language);
// untrack the changes if the version does no longer exist
// in any of the available languages
if (
$this->id->is('changes') === true &&
$this->exists('*') === false
) {
(new Changes())->untrack($this->model);
}
// Remove the version from the cache
VersionCache::remove($this, $language);
}
/**
* Returns all validation errors for the given language
*/
public function errors(Language|string $language = 'default'): array
{
$fields = Fields::for($this->model, $language);
$fields->fill(
input: $this->content($language)->toArray()
);
return $fields->errors();
}
/**
* Checks if a version exists for the given language
*/
public function exists(Language|string $language = 'default'): bool
{
// go through all possible languages to check if this
// version exists in any language
if ($language === '*') {
foreach (Languages::ensure() as $language) {
if ($this->exists($language) === true) {
return true;
}
}
return false;
}
return $this->model->storage()->exists(
$this->id,
Language::ensure($language)
);
}
/**
* Returns the VersionId instance for this version
*/
public function id(): VersionId
{
return $this->id;
}
/**
* Returns whether the content of both versions
* is identical
*/
public function isIdentical(
Version|VersionId|string $version,
Language|string $language = 'default'
): bool {
if (is_string($version) === true) {
$version = VersionId::from($version);
}
if ($version instanceof VersionId) {
$version = $this->sibling($version);
}
if ($version->id()->is($this->id) === true) {
return true;
}
$language = Language::ensure($language);
$fields = Fields::for($this->model, $language);
// read fields low-level from storage
$a = $this->read($language) ?? [];
$b = $version->read($language) ?? [];
// remove fields that should not be
// considered in the comparison
unset(
$a['lock'],
$b['lock'],
$a['uuid'],
$b['uuid']
);
$a = $fields->reset()->fill(input: $a)->toFormValues();
$b = $fields->reset()->fill(input: $b)->toFormValues();
ksort($a);
ksort($b);
return $a === $b;
}
/**
* Checks if the version is the latest version
*/
public function isLatest(): bool
{
return $this->id->is('latest');
}
/**
* Checks if the version is locked for the current user
*/
public function isLocked(Language|string $language = 'default'): bool
{
return $this->lock($language)->isLocked();
}
/**
* Checks if there are any validation errors for the given language
*/
public function isValid(Language|string $language = 'default'): bool
{
return $this->errors($language) === [];
}
/**
* Returns the lock object for the version
*/
public function lock(Language|string $language = 'default'): Lock
{
return Lock::for($this, $language);
}
/**
* Returns the parent model
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* Returns the modification timestamp of a version
* if it exists
*/
public function modified(
Language|string $language = 'default'
): int|null {
if ($this->exists($language) === true) {
return $this->model->storage()->modified(
$this->id,
Language::ensure($language)
);
}
return null;
}
/**
* Moves the version to a new language and/or version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function move(
Language|string $fromLanguage,
VersionId|null $toVersionId = null,
Language|string|null $toLanguage = null,
Storage|null $toStorage = null
): void {
$fromVersion = $this;
$fromLanguage = Language::ensure($fromLanguage);
$toLanguage = Language::ensure($toLanguage ?? $fromLanguage);
$toVersion = $this->sibling($toVersionId ?? $this->id);
// check if moving is allowed
VersionRules::move(
fromVersion: $fromVersion,
fromLanguage: $fromLanguage,
toVersion: $toVersion,
toLanguage: $toLanguage
);
$this->model->storage()->move(
fromVersionId: $fromVersion->id(),
fromLanguage: $fromLanguage,
toVersionId: $toVersion->id(),
toLanguage: $toLanguage,
toStorage: $toStorage
);
// remove both versions from the cache
VersionCache::remove($fromVersion, $fromLanguage);
VersionCache::remove($toVersion, $toLanguage);
}
/**
* Prepare fields to be written by removing unwanted fields
* depending on the language or model and by cleaning the field names
*/
protected function prepareFieldsBeforeWrite(
array $fields,
Language $language
): array {
// convert all field names to lower case
$fields = $this->convertFieldNamesToLowerCase($fields);
// make sure to store the right fields for the model
$fields = $this->model->contentFileData($fields, $language);
// add the editing user
if (
Lock::isEnabled() === true &&
$this->id->is('changes') === true
) {
$fields['lock'] = $this->model->kirby()->user()?->id();
// remove the lock field for any other version or
// if locking is disabled
} else {
unset($fields['lock']);
}
// the default language stores all fields
if ($language->isDefault() === true) {
return $fields;
}
// remove all untranslatable fields
foreach ($this->model->blueprint()->fields() as $field) {
if (($field['translate'] ?? true) === false) {
unset($fields[strtolower($field['name'])]);
}
}
// remove UUID for non-default languages
unset($fields['uuid']);
return $fields;
}
/**
* Make sure that reading from storage will always
* return a usable set of fields with clean field names
*/
protected function prepareFieldsAfterRead(array $fields, Language $language): array
{
$fields = $this->convertFieldNamesToLowerCase($fields);
// ignore all fields with null values
return array_filter($fields, fn ($field) => $field !== null);
}
/**
* Returns a verification token for the authentication
* of draft and version previews
* @unstable
*/
public function previewToken(): string
{
if ($this->model instanceof Site) {
// the site itself does not render; its preview is the home page
$homePage = $this->model->homePage();
if ($homePage === null) {
throw new NotFoundException('The home page does not exist');
}
return $homePage->version($this->id)->previewToken();
}
if (($this->model instanceof Page) === false) {
throw new LogicException('Invalid model type');
}
return $this->previewTokenFromUrl($this->model->url());
}
/**
* Returns a verification token for the authentication
* of draft and version previews from a raw URL
*/
protected function previewTokenFromUrl(string $url): string
{
// get rid of all modifiers after the path
$uri = new Uri($url);
$uri->fragment = null;
$uri->params = null;
$uri->query = null;
$data = [
'url' => $uri->toString(),
'versionId' => $this->id->value()
];
$token = $this->model->kirby()->contentToken(
null,
json_encode($data, JSON_UNESCAPED_SLASHES)
);
return substr($token, 0, 10);
}
/**
* This method can only be applied to the "changes" version.
* It will copy all fields over to the "latest" version and delete
* this version afterwards.
*/
public function publish(Language|string $language = 'default'): void
{
$language = Language::ensure($language);
// check if publishing is allowed
VersionRules::publish($this, $language);
$latest = $this->sibling('latest')->read($language) ?? [];
$changes = $this->read($language) ?? [];
// overwrite all fields that are not in the `changes` version
// with a null value. The ModelWithContent::update method will merge
// the input with the existing content fields and setting null values
// for removed fields will take care of not inheriting old values.
foreach ($latest as $key => $value) {
if (isset($changes[$key]) === false) {
$changes[$key] = null;
}
}
// update the latest version
$this->model = $this->model->update(
input: $changes,
languageCode: $language->code(),
validate: true
);
// delete the changes
$this->delete($language);
}
/**
* Returns the stored content fields
*
* @return array<string, string>|null
*/
public function read(Language|string $language = 'default'): array|null
{
$language = Language::ensure($language);
try {
// make sure that the version exists
VersionRules::read($this, $language);
$fields = VersionCache::get($this, $language);
if ($fields === null) {
$fields = $this->model->storage()->read($this->id, $language);
$fields = $this->prepareFieldsAfterRead($fields, $language);
if ($fields !== null) {
VersionCache::set($this, $language, $fields);
}
}
return $fields;
} catch (NotFoundException) {
return null;
}
}
/**
* Replaces the content of the current version with the given fields
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function replace(
array $fields,
Language|string $language = 'default'
): void {
$language = Language::ensure($language);
// check if replacing is allowed
VersionRules::replace($this, $fields, $language);
$this->model->storage()->update(
versionId: $this->id,
language: $language,
fields: $this->prepareFieldsBeforeWrite($fields, $language)
);
// remove the version from the cache to read
// a fresh version next time
VersionCache::remove($this, $language);
}
/**
* Convenience wrapper around ::create, ::replace and ::update.
*/
public function save(
array $fields,
Language|string $language = 'default',
bool $overwrite = false
): void {
if ($this->exists($language) === false) {
$this->create($fields, $language);
return;
}
if ($overwrite === true) {
$this->replace($fields, $language);
return;
}
$this->update($fields, $language);
}
/**
* Returns a sibling version for the same model
*/
public function sibling(VersionId|string $id): Version
{
return new Version(
model: $this->model,
id: VersionId::from($id)
);
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function touch(Language|string $language = 'default'): void
{
$language = Language::ensure($language);
VersionRules::touch($this, $language);
$this->model->storage()->touch($this->id, $language);
}
/**
* Updates the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function update(
array $fields,
Language|string $language = 'default'
): void {
$language = Language::ensure($language);
// check if updating is allowed
VersionRules::update($this, $fields, $language);
// merge the previous state with the new state to always
// update to a complete version
$fields = [
...$this->read($language),
...$fields
];
$this->model->storage()->update(
versionId: $this->id,
language: $language,
fields: $this->prepareFieldsBeforeWrite($fields, $language)
);
// remove the version from the cache to read
// a fresh version next time
VersionCache::remove($this, $language);
}
/**
* Returns the preview URL with authentication for drafts and versions
* @unstable
*/
public function url(): string|null
{
if (
($this->model instanceof Page || $this->model instanceof Site) === false
) {
throw new LogicException('Only pages and the site have a content preview URL');
}
$url = $this->model->blueprint()->preview();
// preview was disabled
if ($url === false) {
return null;
}
// we only need to add a token for draft and changes previews
if (
($this->model instanceof Site || $this->model->isDraft() === false) &&
$this->id->is('changes') === false
) {
return match (true) {
is_string($url) => $url,
default => $this->model->url()
};
}
// check if the URL was customized
if (is_string($url) === true) {
return $this->urlFromOption($url);
}
// it wasn't, use the safer/more reliable model-based preview token
return $this->urlWithQueryParams($this->model->url(), $this->previewToken());
}
/**
* Returns the preview URL based on an arbitrary URL from
* the blueprint option
*/
protected function urlFromOption(string $url): string
{
// try to determine a token for a local preview
// (we cannot determine the token for external previews)
if ($token = $this->previewTokenFromUrl($url)) {
return $this->urlWithQueryParams($url, $token);
}
// fall back to the URL as defined in the blueprint
return $url;
}
/**
* Assembles the preview URL with the added `_token` and `_version`
* query params, no matter if the base URL already contains query params
*/
protected function urlWithQueryParams(string $baseUrl, string $token): string
{
$uri = new Uri($baseUrl);
$uri->query->_token = $token;
if ($this->id->is('changes') === true) {
$uri->query->_version = 'changes';
}
return $uri->toString();
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use WeakMap;
/**
* The Version cache class keeps content fields
* to avoid multiple storage reads for the same
* content.
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionCache
{
/**
* All cache values for all versions
* and language combinations
*/
protected static WeakMap $cache;
/**
* Tries to receive a fields for a version/language combination
*/
public static function get(Version $version, Language $language): array|null
{
$model = $version->model();
$key = $version->id() . ':' . $language->code();
return static::$cache[$model][$key] ?? null;
}
/**
* Removes fields for a version/language combination
*/
public static function remove(Version $version, Language $language): void
{
$model = $version->model();
if (isset(static::$cache[$model]) === false) {
return;
}
// Avoid indirect manipulation of WeakMap
$key = $version->id() . ':' . $language->code();
$map = static::$cache[$model];
unset($map[$key]);
static::$cache[$model] = $map;
}
/**
* Resets the cache
*/
public static function reset(): void
{
static::$cache = new WeakMap();
}
/**
* Keeps fields for a version/language combination
*/
public static function set(
Version $version,
Language $language,
array $fields = []
): void {
$model = $version->model();
$key = $version->id() . ':' . $language->code();
static::$cache ??= new WeakMap();
static::$cache[$model] ??= [];
static::$cache[$model][$key] = $fields;
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Kirby\Content;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Stringable;
/**
* The Version ID identifies a version of content.
* This can be the currently latest version or changes
* to the content. In the future, we also plan to use this
* for older revisions of the content.
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionId implements Stringable
{
/**
* Latest stable version of the content
*/
public const LATEST = 'latest';
/**
* Latest changes to the content (optional)
*/
public const CHANGES = 'changes';
/**
* A global store for a version id that should be
* rendered for each model in a live preview scenario.
*/
public static self|null $render = null;
/**
* @throws \Kirby\Exception\InvalidArgumentException If the version ID is not valid
*/
public function __construct(
public string $value
) {
if (in_array($value, [static::CHANGES, static::LATEST], true) === false) {
throw new InvalidArgumentException(message: 'Invalid Version ID');
}
}
/**
* Converts the VersionId instance to a simple string value
*/
public function __toString(): string
{
return $this->value;
}
/**
* Creates a VersionId instance for the latest content changes
*/
public static function changes(): static
{
return new static(static::CHANGES);
}
/**
* Creates a VersionId instance from a simple string value
*/
public static function from(VersionId|string $value): static
{
if ($value instanceof VersionId) {
return $value;
}
return new static($value);
}
/**
* Compares a VersionId object or string value with this id
*/
public function is(VersionId|string $id): bool
{
return static::from($id)->value === $this->value;
}
/**
* Creates a VersionId instance for the latest stable version of the content
*/
public static function latest(): static
{
return new static(static::LATEST);
}
/**
* Temporarily sets the version ID for preview rendering
* only for the logic in the callback
*/
public static function render(VersionId|string $versionId, Closure $callback): mixed
{
$original = static::$render;
static::$render = static::from($versionId);
try {
return $callback();
} finally {
// ensure that the render version ID is *always* reset
// to the original value, even if an error occurred
static::$render = $original;
}
}
/**
* Returns the ID value
*/
public function value(): string
{
return $this->value;
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
/**
* The VersionRules class handles the validation for all
* modification actions on a single version
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionRules
{
public static function create(
Version $version,
array $fields,
Language $language
): void {
if ($version->exists($language) === true) {
throw new LogicException(
message: 'The version already exists'
);
}
}
/**
* Checks if a version/language combination exists and otherwise
* will throw a `NotFoundException`
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public static function ensure(Version $version, Language $language): void
{
if ($version->exists($language) === true) {
return;
}
$message = match($version->model()->kirby()->multilang()) {
true => 'Version "' . $version->id() . ' (' . $language->code() . ')" does not already exist',
false => 'Version "' . $version->id() . '" does not already exist',
};
throw new NotFoundException($message);
}
public static function delete(
Version $version,
Language $language
): void {
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.delete'
);
}
}
public static function move(
Version $fromVersion,
Language $fromLanguage,
Version $toVersion,
Language $toLanguage
): void {
// make sure that the source version exists
static::ensure($fromVersion, $fromLanguage);
// check if the source version is locked in any language
if ($fromVersion->isLocked('*') === true) {
throw new LockedContentException(
lock: $fromVersion->lock('*'),
key: 'content.lock.move'
);
}
// check if the target version is locked in any language
if ($toVersion->isLocked('*') === true) {
throw new LockedContentException(
lock: $toVersion->lock('*'),
key: 'content.lock.update'
);
}
}
public static function publish(
Version $version,
Language $language
): void {
// the latest version is already published
if ($version->isLatest() === true) {
throw new LogicException(
message: 'This version is already published'
);
}
// make sure that the version exists
static::ensure($version, $language);
// check if the version is locked in any language
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.publish'
);
}
}
public static function read(
Version $version,
Language $language
): void {
static::ensure($version, $language);
}
public static function replace(
Version $version,
array $fields,
Language $language
): void {
// make sure that the version exists
static::ensure($version, $language);
// check if the version is locked in any language
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.replace'
);
}
}
public static function touch(
Version $version,
Language $language
): void {
static::ensure($version, $language);
}
public static function update(
Version $version,
array $fields,
Language $language
): void {
static::ensure($version, $language);
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.update'
);
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Collection;
use Kirby\Cms\ModelWithContent;
/**
* @package Kirby Content
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Content\Version>
*/
class Versions extends Collection
{
/**
* Deletes all versions in the collection
*/
public function delete(): void
{
foreach ($this->data as $version) {
$version->delete('*');
}
}
/**
* Loads all available versions for a given model
*
* Versions need to be loaded in the order `changes`, `latest`
* to ensure that models are deleted correctly. The `latest`
* version always needs to be deleted last, otherwise the
* PlainTextStorage handler will not be able to clean up
* content directories.
*/
public static function load(
ModelWithContent $model
): static {
return new static(
objects: [
$model->version('changes'),
$model->version('latest'),
],
parent: $model
);
}
}