This commit is contained in:
commit
a3620a1f5f
1042 changed files with 226722 additions and 0 deletions
732
kirby/src/Cms/ModelWithContent.php
Normal file
732
kirby/src/Cms/ModelWithContent.php
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Cms;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Content\Content;
|
||||
use Kirby\Content\ImmutableMemoryStorage;
|
||||
use Kirby\Content\Lock;
|
||||
use Kirby\Content\MemoryStorage;
|
||||
use Kirby\Content\Storage;
|
||||
use Kirby\Content\Translation;
|
||||
use Kirby\Content\Translations;
|
||||
use Kirby\Content\Version;
|
||||
use Kirby\Content\VersionId;
|
||||
use Kirby\Content\Versions;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Panel\Model;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Uuid\Identifiable;
|
||||
use Kirby\Uuid\Uuid;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ModelWithContent
|
||||
*
|
||||
* @package Kirby Cms
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class ModelWithContent implements Identifiable, Stringable
|
||||
{
|
||||
/**
|
||||
* Each model must define a CLASS_ALIAS
|
||||
* which will be used in template queries.
|
||||
* The CLASS_ALIAS is a short human-readable
|
||||
* version of the class name, i.e. page.
|
||||
*/
|
||||
public const CLASS_ALIAS = null;
|
||||
|
||||
/**
|
||||
* Cached array of valid blueprints
|
||||
* that could be used for the model
|
||||
*/
|
||||
public array|null $blueprints = null;
|
||||
|
||||
public static App $kirby;
|
||||
protected Site|null $site;
|
||||
protected Storage $storage;
|
||||
|
||||
/**
|
||||
* Store values used to initilaize object
|
||||
*/
|
||||
protected array $propertyData = [];
|
||||
|
||||
public function __construct(array $props = [])
|
||||
{
|
||||
$this->site = $props['site'] ?? null;
|
||||
|
||||
$this->setContent($props['content'] ?? null);
|
||||
$this->setTranslations($props['translations'] ?? null);
|
||||
|
||||
$this->propertyData = $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the blueprint of the model
|
||||
*/
|
||||
abstract public function blueprint(): Blueprint;
|
||||
|
||||
/**
|
||||
* Returns an array with all blueprints that are available
|
||||
*/
|
||||
public function blueprints(string|null $inSection = null): array
|
||||
{
|
||||
// helper function
|
||||
$toBlueprints = static function (array $sections): array {
|
||||
$blueprints = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if ($section === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array)$section->blueprints() as $blueprint) {
|
||||
$blueprints[$blueprint['name']] = $blueprint;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($blueprints);
|
||||
};
|
||||
|
||||
$blueprint = $this->blueprint();
|
||||
|
||||
// no caching for when collecting for specific section
|
||||
if ($inSection !== null) {
|
||||
return $toBlueprints([$blueprint->section($inSection)]);
|
||||
}
|
||||
|
||||
return $this->blueprints ??= $toBlueprints($blueprint->sections());
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves or copies the model to a new storage instance/type
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
public function changeStorage(Storage|string $toStorage, bool $copy = false): static
|
||||
{
|
||||
if (is_string($toStorage) === true) {
|
||||
if (is_subclass_of($toStorage, Storage::class) === false) {
|
||||
throw new InvalidArgumentException('Invalid storage class');
|
||||
}
|
||||
|
||||
$toStorage = new $toStorage($this);
|
||||
}
|
||||
|
||||
$method = $copy ? 'copyAll' : 'moveAll';
|
||||
|
||||
$this->storage()->$method(to: $toStorage);
|
||||
$this->storage = $toStorage;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with the same
|
||||
* initial properties
|
||||
*
|
||||
* @todo eventually refactor without need of propertyData
|
||||
*/
|
||||
public function clone(array $props = []): static
|
||||
{
|
||||
$props = array_replace_recursive($this->propertyData, $props);
|
||||
$clone = new static($props);
|
||||
|
||||
// Move the clone to a new instance of the same storage class
|
||||
// The storage classes might need to rely on the model instance
|
||||
// and thus we need to make sure that the cloned object is
|
||||
// passed on to the new storage instance
|
||||
$storage = match (true) {
|
||||
isset($props['content']),
|
||||
isset($props['translations']) => $clone->storage()::class,
|
||||
default => $this->storage()::class
|
||||
};
|
||||
|
||||
$clone->changeStorage($storage);
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes any given model action
|
||||
*/
|
||||
abstract protected function commit(
|
||||
string $action,
|
||||
array $arguments,
|
||||
Closure $callback
|
||||
): mixed;
|
||||
|
||||
/**
|
||||
* Returns the content
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
|
||||
*/
|
||||
public function content(string|null $languageCode = null): Content
|
||||
{
|
||||
// get the targeted language
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
$versionId = VersionId::$render ?? 'latest';
|
||||
$version = $this->version($versionId);
|
||||
|
||||
if ($version->exists($language) === true) {
|
||||
return $version->content($language);
|
||||
}
|
||||
|
||||
return $this->version()->content($language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the content that should be written
|
||||
* to the text file
|
||||
* @unstable
|
||||
*/
|
||||
public function contentFileData(
|
||||
array $data,
|
||||
string|null $languageCode = null
|
||||
): array {
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts model to new blueprint
|
||||
* incl. its content for all translations
|
||||
*/
|
||||
protected function convertTo(string $blueprint): static
|
||||
{
|
||||
// Keep a copy of the old model with the original storage handler.
|
||||
// This will be used to delete the old versions.
|
||||
$old = $this->clone();
|
||||
|
||||
// Clone the object with the new blueprint as template
|
||||
$new = $this->clone(['template' => $blueprint]);
|
||||
|
||||
// Make sure to use the same storage class as the original model.
|
||||
// This is needed if the model has been constructed with `content` or
|
||||
// `translations` props. In this case, the storage would be set to
|
||||
// `MemoryStorage` in the clone method again, even if it might have been
|
||||
// changed before.
|
||||
$new->changeStorage($this->storage()::class);
|
||||
|
||||
// Copy this instance into immutable storage.
|
||||
// Moving the content would prematurely delete the old content storage entries.
|
||||
// But we need to keep them until the new content is written.
|
||||
$this->changeStorage(
|
||||
toStorage: new ImmutableMemoryStorage(
|
||||
model: $this,
|
||||
nextModel: $new
|
||||
),
|
||||
copy: true
|
||||
);
|
||||
|
||||
// Get all languages to loop through
|
||||
$languages = Languages::ensure();
|
||||
|
||||
// Loop through all versions
|
||||
foreach ($old->versions() as $oldVersion) {
|
||||
// Loop through all languages
|
||||
foreach ($languages as $language) {
|
||||
// Skip non-existing versions
|
||||
if ($oldVersion->exists($language) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert the content to the new blueprint
|
||||
$content = $oldVersion->content($language)->convertTo($blueprint);
|
||||
|
||||
// Delete the old versions. This will also remove the
|
||||
// content files from the storage if this is a plain text
|
||||
// storage instance.
|
||||
$oldVersion->delete($language);
|
||||
|
||||
// Save to re-create the new version
|
||||
// with the converted/updated content
|
||||
$new->version($oldVersion->id())->save($content, $language);
|
||||
}
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates default content for the model, by using our
|
||||
* Form class to generate the defaults, based on the
|
||||
* model's blueprint setup.
|
||||
*
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function createDefaultContent(): array
|
||||
{
|
||||
$fields = Fields::for($this, 'default');
|
||||
return $fields->fill($fields->defaults())->toStoredValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement a given field value
|
||||
*/
|
||||
public function decrement(
|
||||
string $field,
|
||||
int $by = 1,
|
||||
int $min = 0
|
||||
): static {
|
||||
$value = (int)$this->content()->get($field)->value() - $by;
|
||||
|
||||
if ($value < $min) {
|
||||
$value = $min;
|
||||
}
|
||||
|
||||
return $this->update([$field => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all content validation errors
|
||||
*/
|
||||
public function errors(): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($this->blueprint()->sections() as $section) {
|
||||
$errors = [...$errors, ...$section->errors()];
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a clone and fetches all
|
||||
* lazy-loaded getters to get a full copy
|
||||
*/
|
||||
public function hardcopy(): static
|
||||
{
|
||||
$clone = $this->clone();
|
||||
|
||||
foreach (get_object_vars($clone) as $name => $default) {
|
||||
if (method_exists($clone, $name) === true) {
|
||||
$clone->$name();
|
||||
}
|
||||
}
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each model must return a unique id
|
||||
*/
|
||||
public function id(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a given field value
|
||||
*/
|
||||
public function increment(
|
||||
string $field,
|
||||
int $by = 1,
|
||||
int|null $max = null
|
||||
): static {
|
||||
$value = (int)$this->content()->get($field)->value() + $by;
|
||||
|
||||
if ($max && $value > $max) {
|
||||
$value = $max;
|
||||
}
|
||||
|
||||
return $this->update([$field => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is locked for the current user
|
||||
* @deprecated 5.0.0 Use `->lock()->isLocked()` instead
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->lock()->isLocked() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the data has any errors
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->version('latest')->isValid('current');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Kirby instance
|
||||
*/
|
||||
public function kirby(): App
|
||||
{
|
||||
return static::$kirby ??= App::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lock for the model
|
||||
*/
|
||||
public function lock(): Lock
|
||||
{
|
||||
return $this->version('changes')->lock('*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the panel info of the model
|
||||
* @since 3.6.0
|
||||
*/
|
||||
abstract public function panel(): Model;
|
||||
|
||||
/**
|
||||
* Must return the permissions object for the model
|
||||
*/
|
||||
abstract public function permissions(): ModelPermissions;
|
||||
|
||||
/**
|
||||
* Clean internal caches
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function purge(): static
|
||||
{
|
||||
$this->blueprints = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a string query, starting from the model
|
||||
*/
|
||||
public function query(
|
||||
string|null $query = null,
|
||||
string|null $expect = null
|
||||
): mixed {
|
||||
if ($query === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = Str::query($query, [
|
||||
'kirby' => $this->kirby(),
|
||||
'site' => $this instanceof Site ? $this : $this->site(),
|
||||
'model' => $this,
|
||||
static::CLASS_ALIAS => $this
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($expect !== null && $result instanceof $expect === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content from the content file
|
||||
* @internal
|
||||
* @deprecated 5.0.0 Use `->version()->read()` instead
|
||||
*/
|
||||
public function readContent(string|null $languageCode = null): array
|
||||
{
|
||||
Helpers::deprecated('$model->readContent() is deprecated. Use $model->version()->read() instead.'); // @codeCoverageIgnore
|
||||
return $this->version()->read($languageCode ?? 'default') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the model
|
||||
*/
|
||||
abstract public function root(): string|null;
|
||||
|
||||
/**
|
||||
* Low-level method to save the model with the given data.
|
||||
* Consider using `::update()` instead.
|
||||
*/
|
||||
public function save(
|
||||
array|null $data = null,
|
||||
string|null $languageCode = null,
|
||||
bool $overwrite = false
|
||||
): static {
|
||||
// create a clone to avoid modifying the original
|
||||
$clone = $this->clone();
|
||||
|
||||
// move the old model into memory
|
||||
$this->changeStorage(
|
||||
toStorage: new ImmutableMemoryStorage(
|
||||
model: $this,
|
||||
nextModel: $clone
|
||||
),
|
||||
copy: true
|
||||
);
|
||||
|
||||
// update the clone
|
||||
$clone->version()->save(
|
||||
$data ?? [],
|
||||
$languageCode ?? 'current',
|
||||
$overwrite
|
||||
);
|
||||
|
||||
ModelState::update(
|
||||
method: 'set',
|
||||
current: $this,
|
||||
next: $clone
|
||||
);
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.0.0 Use $model->save() instead
|
||||
*/
|
||||
protected function saveContent(
|
||||
array|null $data = null,
|
||||
bool $overwrite = false
|
||||
): static {
|
||||
Helpers::deprecated('$model->saveContent() is deprecated. Use $model->save() instead.');
|
||||
return $this->save($data, 'default', $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.0.0 Use $model->save() instead
|
||||
*/
|
||||
protected function saveTranslation(
|
||||
array|null $data = null,
|
||||
string|null $languageCode = null,
|
||||
bool $overwrite = false
|
||||
): static {
|
||||
Helpers::deprecated('$model->saveTranslation() is deprecated. Use $model->save() instead.');
|
||||
return $this->save($data, $languageCode ?? 'default', $overwrite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Content object
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setContent(array|null $content = null): static
|
||||
{
|
||||
if ($content === null) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->changeStorage(MemoryStorage::class, copy: true);
|
||||
$this->version()->save($content, 'default');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the translations collection from an array
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setTranslations(array|null $translations = null): static
|
||||
{
|
||||
if ($translations === null) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->changeStorage(MemoryStorage::class, copy: true);
|
||||
|
||||
Translations::create(
|
||||
model: $this,
|
||||
version: $this->version(),
|
||||
translations: $translations
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent Site instance
|
||||
*/
|
||||
public function site(): Site
|
||||
{
|
||||
return $this->site ??= $this->kirby()->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content storage handler
|
||||
*/
|
||||
public function storage(): Storage
|
||||
{
|
||||
return $this->storage ??= $this->kirby()->storage($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the model to a simple array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'content' => $this->content()->toArray(),
|
||||
'translations' => $this->translations()->toArray()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* String template builder with automatic HTML escaping
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @param string|null $template Template string or `null` to use the model ID
|
||||
* @param string|null $fallback Fallback for tokens in the template that cannot be replaced
|
||||
* (`null` to keep the original token)
|
||||
*/
|
||||
public function toSafeString(
|
||||
string|null $template = null,
|
||||
array $data = [],
|
||||
string|null $fallback = ''
|
||||
): string {
|
||||
return $this->toString($template, $data, $fallback, 'safeTemplate');
|
||||
}
|
||||
|
||||
/**
|
||||
* String template builder
|
||||
*
|
||||
* @param string|null $template Template string or `null` to use the model ID
|
||||
* @param string|null $fallback Fallback for tokens in the template that cannot be replaced
|
||||
* (`null` to keep the original token)
|
||||
* @param string $handler For internal use
|
||||
*/
|
||||
public function toString(
|
||||
string|null $template = null,
|
||||
array $data = [],
|
||||
string|null $fallback = '',
|
||||
string $handler = 'template'
|
||||
): string {
|
||||
if ($template === null) {
|
||||
return $this->id() ?? '';
|
||||
}
|
||||
|
||||
if ($handler !== 'template' && $handler !== 'safeTemplate') {
|
||||
throw new InvalidArgumentException(message: 'Invalid toString handler'); // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
$result = Str::$handler($template, array_replace([
|
||||
'kirby' => $this->kirby(),
|
||||
'site' => $this instanceof Site ? $this : $this->site(),
|
||||
'model' => $this,
|
||||
static::CLASS_ALIAS => $this,
|
||||
], $data), ['fallback' => $fallback]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes it possible to convert the entire model
|
||||
* to a string. Mostly useful for debugging
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string)$this->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single translation by language code
|
||||
* If no code is specified the current translation is returned
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the language does not exist
|
||||
*/
|
||||
public function translation(
|
||||
string|null $languageCode = null
|
||||
): Translation {
|
||||
$language = Language::ensure($languageCode ?? 'current');
|
||||
|
||||
return new Translation(
|
||||
model: $this,
|
||||
version: $this->version(),
|
||||
language: $language
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translations collection
|
||||
*/
|
||||
public function translations(): Translations
|
||||
{
|
||||
return Translations::load(
|
||||
model: $this,
|
||||
version: $this->version()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the model data
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
|
||||
*/
|
||||
public function update(
|
||||
array|null $input = null,
|
||||
string|null $languageCode = null,
|
||||
bool $validate = false
|
||||
): static {
|
||||
$form = Form::for(
|
||||
model: $this,
|
||||
language: $languageCode,
|
||||
);
|
||||
|
||||
$form->submit(
|
||||
input: $input ?? [],
|
||||
force: $validate === false
|
||||
);
|
||||
|
||||
if ($validate === true) {
|
||||
$form->validate();
|
||||
}
|
||||
|
||||
return $this->commit(
|
||||
'update',
|
||||
[
|
||||
static::CLASS_ALIAS => $this,
|
||||
'values' => $form->toFormValues(),
|
||||
'strings' => $form->toStoredValues(),
|
||||
'languageCode' => $languageCode
|
||||
],
|
||||
fn ($model, $values, $strings, $languageCode) =>
|
||||
$model->save($strings, $languageCode, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model's UUID
|
||||
* @since 3.8.0
|
||||
*/
|
||||
public function uuid(): Uuid|null
|
||||
{
|
||||
return Uuid::for($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a content version instance
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function version(VersionId|string|null $versionId = null): Version
|
||||
{
|
||||
return new Version(
|
||||
model: $this,
|
||||
id: VersionId::from($versionId ?? 'latest')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a versions collection
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function versions(): Versions
|
||||
{
|
||||
return Versions::load($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low level data writer method
|
||||
* to store the given data on disk or anywhere else
|
||||
* @internal
|
||||
* @deprecated 5.0.0 Use `->version()->save()` instead
|
||||
*/
|
||||
public function writeContent(array $data, string|null $languageCode = null): bool
|
||||
{
|
||||
Helpers::deprecated('$model->writeContent() is deprecated. Use $model->version()->save() instead.'); // @codeCoverageIgnore
|
||||
$this->version()->save($data, $languageCode ?? 'default', true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue