init with kirby, vue and pagedjs interactive
This commit is contained in:
commit
dc0ae26464
968 changed files with 211706 additions and 0 deletions
197
public/kirby/src/Content/Changes.php
Normal file
197
public/kirby/src/Content/Changes.php
Normal 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);
|
||||
}
|
||||
}
|
||||
253
public/kirby/src/Content/Content.php
Normal file
253
public/kirby/src/Content/Content.php
Normal 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;
|
||||
}
|
||||
}
|
||||
212
public/kirby/src/Content/Field.php
Normal file
212
public/kirby/src/Content/Field.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
public/kirby/src/Content/ImmutableMemoryStorage.php
Normal file
90
public/kirby/src/Content/ImmutableMemoryStorage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
229
public/kirby/src/Content/Lock.php
Normal file
229
public/kirby/src/Content/Lock.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
public/kirby/src/Content/LockedContentException.php
Normal file
31
public/kirby/src/Content/LockedContentException.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
99
public/kirby/src/Content/MemoryStorage.php
Normal file
99
public/kirby/src/Content/MemoryStorage.php
Normal 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);
|
||||
}
|
||||
}
|
||||
331
public/kirby/src/Content/PlainTextStorage.php
Normal file
331
public/kirby/src/Content/PlainTextStorage.php
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
325
public/kirby/src/Content/Storage.php
Normal file
325
public/kirby/src/Content/Storage.php
Normal 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;
|
||||
}
|
||||
191
public/kirby/src/Content/Translation.php
Normal file
191
public/kirby/src/Content/Translation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
79
public/kirby/src/Content/Translations.php
Normal file
79
public/kirby/src/Content/Translations.php
Normal 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);
|
||||
}
|
||||
}
|
||||
687
public/kirby/src/Content/Version.php
Normal file
687
public/kirby/src/Content/Version.php
Normal 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();
|
||||
}
|
||||
}
|
||||
81
public/kirby/src/Content/VersionCache.php
Normal file
81
public/kirby/src/Content/VersionCache.php
Normal 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;
|
||||
}
|
||||
}
|
||||
121
public/kirby/src/Content/VersionId.php
Normal file
121
public/kirby/src/Content/VersionId.php
Normal 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;
|
||||
}
|
||||
}
|
||||
161
public/kirby/src/Content/VersionRules.php
Normal file
161
public/kirby/src/Content/VersionRules.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
public/kirby/src/Content/Versions.php
Normal file
49
public/kirby/src/Content/Versions.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue