ajout d'un toggle pour désactiver le bandeau du haut de la page

This commit is contained in:
antonin gallon 2026-01-27 16:11:17 +01:00
parent c5ae72945d
commit f771bb3f24
95 changed files with 22574 additions and 1 deletions

View file

@ -0,0 +1,137 @@
<?php
namespace Kirby\Api\Controller;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Content\Lock;
use Kirby\Filesystem\F;
use Kirby\Form\Fields;
use Kirby\Form\Form;
/**
* The Changes controller takes care of the request logic
* to save, discard and publish changes.
*
* @package Kirby Api
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Changes
{
/**
* Cleans up legacy lock files. The `discard`, `publish` and `save` actions
* are perfect for this cleanup job. They will be stopped early if
* the lock is still active and otherwise, we can use them to clean
* up outdated .lock files to keep the content folders clean. This
* can be removed as soon as old .lock files should no longer be around.
*
* @todo Remove in 6.0.0
*/
protected static function cleanup(ModelWithContent $model): void
{
F::remove(Lock::legacyFile($model));
}
/**
* Discards unsaved changes by deleting the changes version
*/
public static function discard(ModelWithContent $model): array
{
$model->version('changes')->delete('current');
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
return [
'status' => 'ok'
];
}
/**
* Saves the lastest state of changes first and then publishes them
*/
public static function publish(ModelWithContent $model, array $input): array
{
// save the given changes first
static::save(
model: $model,
input: $input
);
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
// get the changes version
$changes = $model->version('changes');
// if the changes version does not exist, we need to return early
if ($changes->exists('current') === false) {
return [
'status' => 'ok',
];
}
// publish the changes
$changes->publish(
language: 'current'
);
return [
'status' => 'ok'
];
}
/**
* Saves form input in a new or existing `changes` version
*/
public static function save(ModelWithContent $model, array $input): array
{
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
// get the current language
$language = Language::ensure('current');
// create the fields instance for the model
$fields = Fields::for($model, $language);
// get the changes and latest version for the model
$changes = $model->version('changes');
$latest = $model->version('latest');
// get the source version for the existing content
$source = $changes->exists($language) === true ? $changes : $latest;
$content = $source->content($language)->toArray();
// fill in the form values and pass through any values that are not
// defined as fields, such as uuid, title or similar.
$fields->fill(input: $content);
// submit the new values from the request input
$fields->submit(input: $input);
// save the changes
$changes->save(
fields: $fields->toStoredValues(),
language: $language
);
// if the changes are identical to the latest version,
// we can delete the changes version already at this point
if ($changes->isIdentical(version: $latest, language: $language)) {
$changes->delete(
language: $language
);
}
return [
'status' => 'ok'
];
}
}

436
kirby/src/Api/Upload.php Normal file
View file

@ -0,0 +1,436 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Cms\App;
use Kirby\Cms\File;
use Kirby\Cms\FileRules;
use Kirby\Cms\Page;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* The Upload class handles file uploads in the
* context of the API. It adds support for chunked
* uploads.
*
* @package Kirby Api
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
readonly class Upload
{
public function __construct(
protected Api $api,
protected bool $single = true,
protected bool $debug = false
) {
}
/**
* Ensures a clean chunk ID by stripping forbidden characters
*
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
*/
public static function chunkId(string $id): string
{
$id = Str::slug($id, '', 'a-z0-9');
if (strlen($id) < 3) {
throw new InvalidArgumentException(
message: 'Chunk ID must at least be 3 characters long'
);
}
return $id;
}
/**
* Returns the ideal size for a file chunk
*/
public static function chunkSize(): int
{
$max = [
Str::toBytes(ini_get('upload_max_filesize')),
Str::toBytes(ini_get('post_max_size'))
];
// consider cloudflare proxy limit, if detected
if (isset($_SERVER['HTTP_CF_CONNECTING_IP']) === true) {
$max[] = Str::toBytes('100M');
}
// to be sure, only use 95% of the max possible upload size
return (int)floor(min($max) * 0.95);
}
/**
* Clean up tmp directory of stale files
*/
public static function cleanTmpDir(): void
{
foreach (Dir::files($dir = static::tmpDir(), [], true) as $file) {
// remove any file that hasn't been altered
// in the last 24 hours
if (F::modified($file) < time() - 86400) {
F::remove($file);
}
}
// remove tmp directory if completely empty
if (Dir::isEmpty($dir) === true) {
Dir::remove($dir);
}
}
/**
* Throws an exception with the appropriate translated error message
*
* @throws \Exception Any upload error
*/
public static function error(int $error): void
{
// get error messages from translation
$message = [
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
];
throw new Exception(
message: $message[$error] ?? I18n::translate('upload.error.default', 'The file could not be uploaded')
);
}
/**
* Sanitize the filename and extension
* based on the detected mime type
*/
public static function filename(array $upload): string
{
// get the extension of the uploaded file
$extension = F::extension($upload['name']);
// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (
empty($extension) === true ||
in_array($extension, ['tmp', 'temp'], true) === true
) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
return $filename;
}
return basename($upload['name']);
}
/**
* Upload the files and call closure for each file
*
* @throws \Exception Any upload error
*/
public function process(Closure $callback): array
{
$files = $this->api->requestFiles();
$uploads = [];
$errors = [];
static::validateFiles($files);
foreach ($files as $upload) {
if (
isset($upload['tmp_name']) === false &&
is_array($upload) === true
) {
continue;
}
try {
if ($upload['error'] !== 0) {
static::error($upload['error']);
}
$filename = static::filename($upload);
$source = $this->source($upload['tmp_name'], $filename);
// if the file is uploaded in chunks…
if ($this->api->requestHeaders('Upload-Length')) {
$source = $this->processChunk($source, $filename);
}
// apply callback only to complete uploads
// (incomplete chunk request will return empty $source)
$data = match ($source) {
null => null,
default => $callback($source, $filename)
};
$uploads[$upload['name']] = match (true) {
is_object($data) => $this->api->resolve($data)->toArray(),
default => $data
};
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
// clean up file from system tmp directory
F::unlink($upload['tmp_name']);
}
if ($this->single === true) {
break;
}
}
return static::response($uploads, $errors);
}
/**
* Handle chunked uploads by merging all chunks
* in the tmp directory and only returning the new
* $source path to the tmp file once complete
*
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
* @throws \Kirby\Exception\Exception Chunk offset does not match existing tmp file
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
*/
public function processChunk(
string $source,
string $filename
): string|null {
// ensure the tmp upload directory exists
Dir::make($dir = static::tmpDir());
// create path for file in tmp upload directory;
// prefix with id while file isn't completely uploaded yet
$id = $this->api->requestHeaders('Upload-Id', '');
$id = static::chunkId($id);
$total = (int)$this->api->requestHeaders('Upload-Length');
$filename = basename($filename);
$tmpRoot = $dir . '/' . $id . '-' . $filename;
// validate various aspects of the request
// to ensure the chunk isn't trying to do malicious actions
static::validateChunk(
source: $source,
tmp: $tmpRoot,
total: $total,
offset: $this->api->requestHeaders('Upload-Offset'),
template: $this->api->requestBody('template'),
);
// stream chunk content and append it to partial file
stream_copy_to_stream(
fopen($source, 'r'),
fopen($tmpRoot, 'a')
);
// clear file stat cache so the following call to `F::size`
// really returns the updated file size
clearstatcache();
// if file isn't complete yet, return early
if (F::size($tmpRoot) < $total) {
return null;
}
// remove id from partial filename now the file is complete,
// so we can pass the path from the tmp upload directory
// as new source path for the file back to the API upload method
rename(
$tmpRoot,
$source = $dir . '/' . $filename
);
return $source;
}
/**
* Convert uploads and errors in response array for API response
*/
public static function response(
array $uploads,
array $errors
): array {
if (count($uploads) + count($errors) <= 1) {
if (count($errors) > 0) {
return [
'status' => 'error',
'message' => current($errors)
];
}
return [
'status' => 'ok',
'data' => $uploads ? current($uploads) : null
];
}
if (count($errors) > 0) {
return [
'status' => 'error',
'errors' => $errors
];
}
return [
'status' => 'ok',
'data' => $uploads
];
}
/**
* Move the tmp file to a location including the extension,
* for better mime detection and return updated source path
*
* @codeCoverageIgnore
*/
public function source(string $source, string $filename): string
{
if ($this->debug === true) {
return $source;
}
$target = dirname($source) . '/' . uniqid() . '.' . $filename;
if (move_uploaded_file($source, $target)) {
return $target;
}
throw new Exception(
message: I18n::translate('upload.error.cantMove')
);
}
/**
* Returns root of directory used for
* temporarily storing (incomplete) uploads
* @codeCoverageIgnore
*/
protected static function tmpDir(): string
{
return App::instance()->root('cache') . '/.uploads';
}
/**
* Ensures the sent chunk is valid
*
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
* @throws \Kirby\Exception\InvalidArgumentException Chunk offset does not match existing tmp file
* @throws \Kirby\Exception\InvalidArgumentException The maximum file size for this blueprint was exceeded
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
*/
protected static function validateChunk(
string $source,
string $tmp,
int $total,
int $offset,
string|null $template = null
): void {
$file = new File([
'parent' => new Page(['slug' => 'tmp']),
'filename' => $filename = basename($tmp),
'template' => $template
]);
// if the blueprint `maxsize` option is set,
// ensure that the total size communicated in the header
// as well as the current tmp size after adding this chunk
// do not exceed the max limit
if (
($max = $file->blueprint()->accept()['maxsize'] ?? null) &&
(
$total > $max ||
(F::size($source) + F::size($tmp)) > $max
)
) {
throw new InvalidArgumentException(
key: 'file.maxsize'
);
}
// validate the first chunk
if ($offset === 0) {
// sent chunk is expected to be the first part,
// but tmp file already exists
if (F::exists($tmp) === true) {
throw new DuplicateException(
message: 'A tmp file upload with the same filename and upload id already exists: ' . $filename
);
}
// validate file (extension, name) for first chunk;
// will also be validate again by `$model->createFile()`
// when completely uploaded
FileRules::validFile($file, false);
// first chunk is valid
return;
}
// validate subsequent chunks:
// no tmp in place
if (F::exists($tmp) === false) {
throw new NotFoundException(
message: 'Chunk offset ' . $offset . ' for non-existing tmp file: ' . $filename
);
}
// sent chunk's offset is not the continuation of the tmp file
if ($offset !== F::size($tmp)) {
throw new InvalidArgumentException(
message: 'Chunk offset ' . $offset . ' does not match the existing tmp upload file size of ' . F::size($tmp)
);
}
}
/**
* Validate the files array for upload
*
* @throws \Exception No files were uploaded
*/
protected static function validateFiles(array $files): void
{
if ($files === []) {
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
// @codeCoverageIgnoreStart
if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(
message:
I18n::translate(
'upload.error.iniPostSize',
'The uploaded file exceeds the post_max_size directive in php.ini'
)
);
}
// @codeCoverageIgnoreEnd
throw new Exception(
message:
I18n::translate(
'upload.error.noFiles',
'No files were uploaded'
)
);
}
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace Kirby\Cache;
use Kirby\Cms\Helpers;
use Redis;
use Throwable;
/**
* Redis Cache Driver
*
* @package Kirby Cache
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class RedisCache extends Cache
{
/**
* Store for the redis connection
*/
protected Redis $connection;
/**
* Sets all parameters which are needed to connect to Redis
*
* @param array $options 'host' (default: 127.0.0.1)
* 'port' (default: 6379)
*/
public function __construct(array $options = [])
{
$options = [
'host' => '127.0.0.1',
'port' => 6379,
...$options
];
parent::__construct($options);
// available options for the redis driver
$allowed = [
'host',
'port',
'readTimeout',
'connectTimeout',
'persistent',
'auth',
'ssl',
'retryInterval',
'backoff'
];
// filters only redis supported keys
$redisOptions = array_intersect_key($options, array_flip($allowed));
// creates redis connection
$this->connection = new Redis($redisOptions);
// sets the prefix if defined
if ($prefix = $options['prefix'] ?? null) {
$this->connection->setOption(Redis::OPT_PREFIX, rtrim($prefix, '/') . '/');
}
// selects the database if defined
$database = $options['database'] ?? null;
if ($database !== null) {
$this->connection->select($database);
}
}
/**
* Returns the database number
*/
public function databaseNum(): int
{
return $this->connection->getDbNum();
}
/**
* Returns whether the cache is ready to store values
*/
public function enabled(): bool
{
try {
return Helpers::handleErrors(
fn () => $this->connection->ping(),
fn (int $errno, string $errstr) => true,
fn () => false
);
} catch (Throwable) {
return false;
}
}
/**
* Determines if an item exists in the cache
*/
public function exists(string $key): bool
{
return $this->connection->exists($this->key($key)) !== 0;
}
/**
* Removes keys from the database
* and returns whether the operation was successful
*/
public function flush(): bool
{
return $this->connection->flushDB();
}
/**
* The key is not modified, because the prefix is added by the redis driver itself
*/
protected function key(string $key): string
{
return $key;
}
/**
* Removes an item from the cache
* and returns whether the operation was successful
*/
public function remove(string $key): bool
{
return $this->connection->del($this->key($key));
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*/
public function retrieve(string $key): Value|null
{
$value = $this->connection->get($this->key($key));
return Value::fromJson($value);
}
/**
* Writes an item to the cache for a given number of minutes
* and returns whether the operation was successful
*
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
$key = $this->key($key);
$value = (new Value($value, $minutes))->toJson();
if ($minutes > 0) {
return $this->connection->setex($key, $minutes * 60, $value);
}
return $this->connection->set($key, $value);
}
}

130
kirby/src/Cms/Events.php Normal file
View file

@ -0,0 +1,130 @@
<?php
namespace Kirby\Cms;
use Closure;
/**
* The `Events` class outsources the logic of
* `App::apply()` and `App::trigger()` methods
* and makes them easier and more predictable to test.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class Events
{
protected int $level = 0;
protected array $processed = [];
public function __construct(
protected App $app
) {
}
/**
* Runs the hook and applies the result to the argument
* specified by the $modify parameter. By default, the
* first argument is modified.
*/
public function apply(
string $name,
array $args = [],
string|null $modify = null
): mixed {
// modify the first argument by default
$modify ??= array_key_first($args);
return $this->process(
$name,
$args,
// update $modify value after each hook callback
fn ($event, $result) => $event->updateArgument($modify, $result),
// return the modified value
fn ($event) => $event->argument($modify)
);
}
/**
* Returns all matching hook handlers for the given event
*/
public function hooks(Event $event): array
{
// get all hooks for the event name
$name = $event->name();
$hooks = $this->app->extensions('hooks') ?? [];
$result = $hooks[$name] ?? [];
// get all hooks for the event name wildcards
foreach ($event->nameWildcards() as $wildcard) {
$result = [
...$result,
...$hooks[$wildcard] ?? []
];
}
return $result;
}
/**
* Runs the hook
*
* @return ($return is null ? void : mixed)
*/
protected function process(
string $name,
array $args,
Closure|null $afterEach = null,
Closure|null $return = null
) {
// create the event object and get all hook callbacks for this event
$event = new Event($name, $args);
$hooks = $this->hooks($event);
$this->level++;
foreach ($hooks as $hook) {
// skip hooks that have already been processed
if (in_array($hook, $this->processed[$name] ?? []) === true) {
continue;
}
// mark the hook as processed, to avoid endless loops
$this->processed[$name][] = $hook;
// bind the Kirby instance to the hook and run it
$result = $event->call($this->app, $hook);
// run the afterEach callback
if ($afterEach !== null) {
$afterEach($event, $result);
}
}
$this->level--;
// reset the protection after the last nesting level has been closed
if ($this->level === 0) {
$this->processed = [];
}
// run the return callback
if ($return !== null) {
return $return($event);
}
}
/**
* Runs the hook without modifying the arguments
*/
public function trigger(
string $name,
array $args = []
): void {
$this->process($name, $args);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Kirby\Cms;
/**
* HasModels
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
trait HasModels
{
/**
* Registry with all custom models
*/
public static array $models = [];
/**
* Adds new models to the registry
* @internal
*/
public static function extendModels(array $models): array
{
return static::$models = [
...static::$models,
...array_change_key_case($models, CASE_LOWER)
];
}
/**
* Creates an object from model if it has been registered
*/
public static function model(string $name, array $props = []): static
{
$name = strtolower($name);
$class = static::$models[$name] ?? null;
$class ??= static::$models['default'] ?? null;
if ($class !== null) {
$object = new $class($props);
if ($object instanceof self) {
return $object;
}
}
return new static($props);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Kirby\Cms;
/**
* LanguagePermissions
*
* @package Kirby Cms
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LanguagePermissions extends ModelPermissions
{
protected const CATEGORY = 'languages';
protected function canDelete(): bool
{
return $this->model->isDeletable() === true;
}
}

View file

@ -0,0 +1,243 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\Exception;
/**
* The ModelCommit class is used to commit a given model action
* in the model action classes. It takes care of running
* the `before` and `after` hooks and updating the state
* of the given model.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ModelCommit
{
protected App $kirby;
protected string $prefix;
public function __construct(
protected ModelWithContent $model,
protected string $action
) {
$this->kirby = $this->model->kirby();
$this->prefix = $this->model::CLASS_ALIAS;
}
/**
* Runs the `after` hook and returns the result.
*/
public function after(mixed $state): mixed
{
// run the `after` hook
$arguments = $this->afterHookArguments($state);
$hook = $this->hook('after', $arguments);
// flush the page cache after any model action
$this->kirby->cache('pages')->flush();
return $hook['result'];
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given model action. It's a wrapper around the
* more specific `afterHookArgumentsFor*Actions` methods.
*/
public function afterHookArguments(mixed $state): array
{
return match (true) {
$this->model instanceof File =>
$this->afterHookArgumentsForFileActions($this->model, $this->action, $state),
$this->model instanceof Page =>
$this->afterHookArgumentsForPageActions($this->model, $this->action, $state),
$this->model instanceof Site =>
$this->afterHookArgumentsForSiteActions($this->model, $state),
$this->model instanceof User =>
$this->afterHookArgumentsForUserActions($this->model, $this->action, $state),
default =>
throw new Exception('Invalid model class')
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given page action.
*/
protected function afterHookArgumentsForPageActions(
Page $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'page' => $state
],
'duplicate' => [
'duplicatePage' => $state,
'originalPage' => $model
],
'delete' => [
'status' => $state,
'page' => $model
],
default => [
'newPage' => $state,
'oldPage' => $model
]
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given file action.
*/
protected function afterHookArgumentsForFileActions(
File $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'file' => $state
],
'delete' => [
'status' => $state,
'file' => $model
],
default => [
'newFile' => $state,
'oldFile' => $model
]
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given site action.
*/
protected function afterHookArgumentsForSiteActions(
Site $model,
mixed $state
): array {
return [
'newSite' => $state,
'oldSite' => $model
];
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given user action.
*/
protected function afterHookArgumentsForUserActions(
User $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'user' => $state
],
'delete' => [
'status' => $state,
'user' => $model
],
default => [
'newUser' => $state,
'oldUser' => $model
]
};
}
/**
* Runs the `before` hook and modifies the arguments
*/
public function before(array $arguments): array
{
// check model rules
$this->validate($arguments);
// run the `before` hook
$hook = $this->hook('before', $arguments);
// check model rules again, after the hook got applied
$this->validate($hook['arguments']);
return $hook['arguments'];
}
/**
* Handles the full call of the given action,
* runs the `before` and `after` hooks and updates
* the state of the given model.
*/
public function call(array $arguments, Closure $callback): mixed
{
// run the before hook
$arguments = $this->before($arguments);
// run the commit action
$state = $callback(...array_values($arguments));
// update the state for the after hook
ModelState::update(
method: $this->action,
current: $this->model,
next: $state
);
// run the after hook and return the result
return $this->after($state);
}
/**
* Runs the given hook and modifies the first argument
* of the given arguments array. It returns an array with
* `arguments` and `result` keys.
*/
public function hook(string $hook, array $arguments): array
{
// the very first argument (which should be the model)
// is modified by the return value from the hook (if any returned)
$appliedTo = array_key_first($arguments);
// run the hook and modify the first argument
$arguments[$appliedTo] = $this->kirby->apply(
// e.g. page.create:before
$this->prefix . '.' . $this->action . ':' . $hook,
$arguments,
$appliedTo
);
return [
'arguments' => $arguments,
'result' => $arguments[$appliedTo],
];
}
/**
* Checks the model rules for the given action
* if there's a matching rule method.
*/
public function validate(array $arguments): void
{
$rules = match (true) {
$this->model instanceof File => FileRules::class,
$this->model instanceof Page => PageRules::class,
$this->model instanceof Site => SiteRules::class,
$this->model instanceof User => UserRules::class,
default => throw new Exception('Invalid model class') // @codeCoverageIgnore
};
if (method_exists($rules, $this->action) === true) {
$rules::{$this->action}(...array_values($arguments));
}
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Kirby\Cms;
/**
* The ModelState class is used to update app-wide model states.
* It's mainly used in the `ModelCommit` class to update the
* state of the given model after the action has been
* executed.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ModelState
{
/**
* Updates the state of the given model.
*/
public static function update(
string $method,
ModelWithContent $current,
ModelWithContent|bool|null $next = null,
ModelWithContent|Site|null $parent = null
): void {
// normalize the method
$method = match ($method) {
'append', 'create' => 'append',
'remove', 'delete' => 'remove',
'duplicate' => false, // The models need to take care of this
default => 'update'
};
if ($method === false) {
return;
}
match (true) {
$current instanceof File => static::updateFile($method, $current, $next),
$current instanceof Page => static::updatePage($method, $current, $next, $parent),
$current instanceof Site => static::updateSite($current, $next),
$current instanceof User => static::updateUser($method, $current, $next),
};
}
/**
* Updates the state of the given file.
*/
protected static function updateFile(
string $method,
File $current,
File|bool|null $next = null
): void {
$next = $next instanceof File ? $next : $current;
// update the files collection
$next->parent()->files()->$method($next);
}
/**
* Updates the state of the given page.
*/
protected static function updatePage(
string $method,
Page $current,
Page|bool|null $next = null,
Page|Site|null $parent = null
): void {
$next = $next instanceof Page ? $next : $current;
$parent ??= $next->parentModel();
if ($next->isDraft() === true) {
$parent->drafts()->$method($next);
} else {
$parent->children()->$method($next);
}
// update the childrenAndDrafts() cache
$parent->childrenAndDrafts()->$method($next);
}
/**
* Updates the state of the given site.
*/
protected static function updateSite(
Site $current,
Site|null $next = null
): void {
App::instance()->setSite($next ?? $current);
}
/**
* Updates the state of the given user.
*/
protected static function updateUser(
string $method,
User $current,
User|bool|null $next = null
): void {
$next = $next instanceof User ? $next : $current;
// update the users collection
App::instance()->users()->$method($next);
}
}

236
kirby/src/Cms/PageCopy.php Normal file
View file

@ -0,0 +1,236 @@
<?php
namespace Kirby\Cms;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* Normalizes a newly generated copy of a page,
* adapting page slugs, UUIDs etc.
* (for single as well as multilang setups)
*
* @package Kirby Cms
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PageCopy
{
public function __construct(
public Page $copy,
public Page|null $original = null,
public bool $withFiles = false,
public bool $withChildren = false,
public array $uuids = []
) {
}
/**
* Converts UUIDs for copied pages,
* replacing the old UUID with a newly generated one
* for all newly generated pages and files
*/
public function convertUuids(Language|null $language): void
{
if (Uuids::enabled() === false) {
return;
}
if (
$language instanceof Language &&
$language->isDefault() === false
) {
return;
}
// store old UUID
$old = $this->copy->uuid()->toString();
// re-generate UUID for the page
$this->copy = $this->copy->save(
['uuid' => Uuid::generate()],
$language?->code()
);
// track UUID change
$this->uuids[$old] = $this->copy->uuid()->toString();
$this->convertFileUuids($language);
$this->convertChildrenUuids($language);
}
/**
* Re-generate UUIDs for each child recursively
* and merge with the tracked changed UUIDs
*/
protected function convertChildrenUuids(Language|null $language): void
{
// re-generate UUIDs and track changes
if ($this->withChildren === true) {
foreach ($this->copy->childrenAndDrafts() as $child) {
// always adapt files of subpages as they are
// currently always copied; adapt children recursively
$child = new PageCopy(
$child,
withChildren: true,
withFiles: true,
uuids: $this->uuids
);
$child->convertUuids($language);
$this->uuids = [...$this->uuids, ...$child->uuids];
}
}
// if children have not been copied over,
// track all children UUIDs from original page to
// remove/replace with empty string
if ($this->withChildren === false) {
foreach ($this->original?->index(drafts: true) ?? [] as $child) {
$this->uuids[$child->uuid()->toString()] = '';
foreach ($child->files() as $file) {
$this->uuids[$file->uuid()->toString()] = '';
}
}
}
}
/**
* Re-generate UUID for each file and track the change
*/
protected function convertFileUuids(Language|null $language): void
{
// re-generate UUIDs and track changes
if ($this->withFiles === true) {
foreach ($this->copy->files() as $file) {
// store old file UUID
$old = $file->uuid()->toString();
// re-generate UUID for the file
$file = $file->save(
['uuid' => Uuid::generate()],
$language?->code()
);
// track UUID change
$this->uuids[$old] = $file->uuid()->toString();
}
}
// if files have not been copied over,
// track file UUIDs from original page to
// remove/replace with empty string
if ($this->withFiles === false) {
foreach ($this->original?->files() ?? [] as $file) {
$this->uuids[$file->uuid()->toString()] = '';
}
}
}
/**
* Returns all languages to adapt
*
* @todo Refactor once singe-lang mode also works with a language object
*/
public function languages(): Languages|iterable
{
$kirby = App::instance();
if ($kirby->multilang() === true) {
return $kirby->languages();
}
return [null];
}
/**
* Processes the copy with all necessary adaptations.
* Main method to use if not familiar with individual steps.
*/
public static function process(
Page $copy,
Page|null $original = null,
bool $withFiles = false,
bool $withChildren = false
): Page {
$converter = new static($copy, $original, $withFiles, $withChildren);
// loop through all languages to remove slug from non-default
// languages and re-generate UUIDs (and track changes)
foreach ($converter->languages() as $language) {
$converter->removeSlug($language);
$converter->convertUuids($language);
}
// apply all tracked UUID changes at once
$converter->replaceUuids();
return $converter->copy;
}
/**
* Removes translated slug for copied page.
* This is needed to avoid translated slug
* collisions with the original page.
*/
public function removeSlug(Language|null $language): void
{
// single lang setup
if ($language === null) {
return;
}
// don't remove slug from default language
if ($language->isDefault() === true) {
return;
}
if ($this->copy->translation($language)->exists() === true) {
$this->copy = $this->copy->save(
['slug' => null],
$language->code()
);
}
}
/**
* Replace old UUIDs with new UUIDs in the content
*/
public function replaceUuids(): void
{
if (Uuids::enabled() === false) {
return;
}
foreach ($this->copy->storage()->all() as $versionId => $language) {
$this->copy->storage()->replaceStrings(
$versionId,
$language,
$this->uuids
);
}
if ($this->withFiles === true) {
foreach ($this->copy->files() as $file) {
foreach ($file->storage()->all() as $versionId => $language) {
$file->storage()->replaceStrings(
$versionId,
$language,
$this->uuids
);
}
}
}
if ($this->withChildren === true) {
foreach ($this->copy->childrenAndDrafts() as $child) {
$child = new PageCopy($child, withFiles: true, withChildren: true, uuids: $this->uuids);
$child->replaceUuids();
}
}
}
}

View file

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

View file

@ -0,0 +1,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
kirby/src/Content/Lock.php Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,211 @@
<?php
namespace Kirby\Form\Field;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\FieldClass;
use Kirby\Form\Form;
use Kirby\Form\Mixin\EmptyState;
use Kirby\Form\Mixin\Max;
use Kirby\Form\Mixin\Min;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Main class file of the entries field
*
* @package Kirby Field
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class EntriesField extends FieldClass
{
use EmptyState;
use Max;
use Min;
protected array $field;
protected Form $form;
protected bool $sortable = true;
public function __construct(array $params = [])
{
parent::__construct($params);
$this->setEmpty($params['empty'] ?? null);
$this->setField($params['field'] ?? null);
$this->setMax($params['max'] ?? null);
$this->setMin($params['min'] ?? null);
$this->setSortable($params['sortable'] ?? true);
}
public function field(): array
{
return $this->field;
}
public function fieldProps(): array
{
return $this->form()->fields()->first()->toArray();
}
/**
* @psalm-suppress MethodSignatureMismatch
* @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
*/
public function fill(mixed $value): static
{
$this->value = Data::decode($value ?? '', 'yaml');
return $this;
}
public function form(): Form
{
return $this->form ??= new Form(
fields: [$this->field()],
model: $this->model
);
}
public function props(): array
{
return [
...parent::props(),
'empty' => $this->empty(),
'field' => $this->fieldProps(),
'max' => $this->max(),
'min' => $this->min(),
'sortable' => $this->sortable(),
];
}
protected function setField(array|string|null $attrs = null): void
{
if (is_string($attrs) === true) {
$attrs = ['type' => $attrs];
}
$attrs ??= ['type' => 'text'];
if (in_array($attrs['type'], $this->supports()) === false) {
throw new InvalidArgumentException(
key: 'entries.supports',
data: ['type' => $attrs['type']]
);
}
// remove the unsupported props from the entry field
unset($attrs['counter'], $attrs['label']);
$this->field = $attrs;
}
protected function setSortable(bool|null $sortable = true): void
{
$this->sortable = $sortable;
}
public function sortable(): bool
{
return $this->sortable;
}
public function supports(): array
{
return [
'color',
'date',
'email',
'number',
'select',
'slug',
'tel',
'text',
'time',
'url'
];
}
public function toFormValue(): mixed
{
$form = $this->form();
$value = parent::toFormValue() ?? [];
return A::map(
$value,
fn ($value) => $form
->reset()
->fill(input: [$value])
->fields()
->first()
->toFormValue()
);
}
public function toStoredValue(): mixed
{
$form = $this->form();
$value = parent::toStoredValue();
return A::map(
$value,
fn ($value) => $form
->reset()
->submit(input: [$value])
->fields()
->first()
->toStoredValue()
);
}
public function validations(): array
{
return [
'entries' => function ($value) {
if ($this->min && count($value) < $this->min) {
throw new InvalidArgumentException(
key: match ($this->min) {
1 => 'entries.min.singular',
default => 'entries.min.plural'
},
data: ['min' => $this->min]
);
}
if ($this->max && count($value) > $this->max) {
throw new InvalidArgumentException(
key: match ($this->max) {
1 => 'entries.max.singular',
default => 'entries.max.plural'
},
data: ['max' => $this->max]
);
}
$form = $this->form();
foreach ($value as $index => $val) {
$form->reset()->submit(input: [$val]);
foreach ($form->fields() as $field) {
$errors = $field->errors();
if ($errors !== []) {
throw new InvalidArgumentException(
key: 'entries.validation',
data: [
'field' => $this->label() ?? Str::ucfirst($this->name()),
'index' => $index + 1
]
);
}
}
}
}
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Kirby\Form\Mixin;
trait Api
{
public function api(): array
{
return $this->routes();
}
/**
* Routes for the field API
*/
public function routes(): array
{
return [];
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Cms\App;
use Kirby\Cms\ModelWithContent;
trait Model
{
protected ModelWithContent $model;
/**
* Returns the Kirby instance
*/
public function kirby(): App
{
return $this->model->kirby();
}
/**
* Returns the parent model
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* Sets the parent model
*/
protected function setModel(ModelWithContent|null $model = null): void
{
$this->model = $model ?? App::instance()->site();
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Cms\Language;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Translatable
{
protected bool $translate = true;
/**
* Should the field be translatable into the given language?
*
* @since 5.0.0
*/
public function isTranslatable(Language $language): bool
{
if ($this->translate() === false && $language->isDefault() === false) {
return false;
}
return true;
}
/**
* Set the translatable status
*/
protected function setTranslate(bool $translate = true): void
{
$this->translate = $translate;
}
/**
* Should the field be translatable?
*/
public function translate(): bool
{
return $this->translate;
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace Kirby\Form\Mixin;
use Closure;
use Exception;
use Kirby\Form\Validations;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\V;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Validation
{
protected bool $required;
/**
* Runs all validations and returns an array of
* error messages
*/
public function errors(): array
{
$validations = $this->validations();
$value = $this->value();
$errors = [];
// validate required values
if ($this->needsValue() === true) {
$errors['required'] = I18n::translate('error.validation.required');
}
foreach ($validations as $key => $validation) {
if (is_int($key) === true) {
// predefined validation
try {
Validations::$validation($this, $value);
} catch (Exception $e) {
$errors[$validation] = $e->getMessage();
}
continue;
}
if ($validation instanceof Closure) {
try {
$validation->call($this, $value);
} catch (Exception $e) {
$errors[$key] = $e->getMessage();
}
}
}
if (
empty($this->validate) === false &&
($this->isEmpty() === false || $this->isRequired() === true)
) {
$rules = A::wrap($this->validate);
$errors = [
...$errors,
...V::errors($value, $rules)
];
}
return $errors;
}
/**
* Checks if the field is required
*/
public function isRequired(): bool
{
return $this->required;
}
/**
* Checks if the field is invalid
*/
public function isInvalid(): bool
{
return $this->errors() !== [];
}
/**
* Checks if the field is valid
*/
public function isValid(): bool
{
return $this->errors() === [];
}
/**
* Getter for the required property
*/
public function required(): bool
{
return $this->required;
}
protected function setRequired(bool $required = false): void
{
$this->required = $required;
}
/**
* Defines all validation rules
*/
protected function validations(): array
{
return [];
}
}

View file

@ -0,0 +1,220 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Cms\Language;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Value
{
protected mixed $default = null;
protected mixed $value = null;
/**
* @deprecated 5.0.0 Use `::toStoredValue()` instead to receive
* the value in the format that will be needed for content files.
*
* If you need to get the value with the default as fallback, you should use
* the fill method first `$field->fill($field->default())->toStoredValue()`
*/
public function data(bool $default = false): mixed
{
if ($default === true && $this->isEmpty() === true) {
$this->fill($this->default());
}
return $this->toStoredValue();
}
/**
* Returns the default value of the field
*/
public function default(): mixed
{
if (is_string($this->default) === false) {
return $this->default;
}
return $this->model->toString($this->default);
}
/**
* Sets a new value for the field
*/
public function fill(mixed $value): static
{
$this->value = $value;
return $this;
}
/**
* Checks if the field has a value
*/
public function hasValue(): bool
{
return true;
}
/**
* Checks if the field is empty
*/
public function isEmpty(): bool
{
return $this->isEmptyValue($this->toFormValue());
}
/**
* Checks if the given value is considered empty
*/
public function isEmptyValue(mixed $value = null): bool
{
return in_array($value, [null, '', []], true);
}
/**
* Checks if the field can be stored in the given language.
*/
public function isStorable(Language $language): bool
{
// the field cannot be stored at all if it has no value
if ($this->hasValue() === false) {
return false;
}
// the field cannot be translated into the given language
if ($this->isTranslatable($language) === false) {
return false;
}
// We don't need to check if the field is disabled.
// A disabled field can still have a value and that value
// should still be stored. But that value must not be changed
// on submit. That's why we check for the disabled state
// in the isSubmittable method.
return true;
}
/**
* A field might have a value, but can still not be submitted
* because it is disabled, not translatable into the given
* language or not active due to a `when` rule.
*/
public function isSubmittable(Language $language): bool
{
if ($this->hasValue() === false) {
return false;
}
if ($this->isDisabled() === true) {
return false;
}
if ($this->isTranslatable($language) === false) {
return false;
}
if ($this->isActive() === false) {
return false;
}
return true;
}
/**
* Checks if the field needs a value before being saved;
* this is the case if all of the following requirements are met:
* - The field has a value
* - The field is required
* - The field is currently empty
* - The field is not currently inactive because of a `when` rule
*/
protected function needsValue(): bool
{
if (
$this->hasValue() === false ||
$this->isRequired() === false ||
$this->isEmpty() === false ||
$this->isActive() === false
) {
return false;
}
return true;
}
/**
* Checks if the field is saveable
* @deprecated 5.0.0 Use `::hasValue()` instead
*/
public function save(): bool
{
return $this->hasValue();
}
protected function setDefault(mixed $default = null): void
{
$this->default = $default;
}
/**
* Submits a new value for the field.
* Fields can overwrite this method to provide custom
* submit logic. This is useful if the field component
* sends data that needs to be processed before being
* stored.
*
* @since 5.0.0
*/
public function submit(mixed $value): static
{
return $this->fill($value);
}
/**
* Returns the value of the field in a format to be used in forms
* (e.g. used as data for Panel Vue components)
*/
public function toFormValue(): mixed
{
if ($this->hasValue() === false) {
return null;
}
return $this->value;
}
/**
* Returns the value of the field in a format
* to be stored by our storage classes
*/
public function toStoredValue(): mixed
{
return $this->toFormValue();
}
/**
* Returns the value of the field if it has a value
* otherwise it returns null
*
* @see `self::toFormValue()`
* @todo might get deprecated or reused later. Use `self::toFormValue()` instead.
*
* If you need the form value with the default as fallback, you should use
* the fill method first `$field->fill($field->default())->toFormValue()`
*/
public function value(bool $default = false): mixed
{
if ($default === true && $this->isEmpty() === true) {
$this->fill($this->default());
}
return $this->toFormValue();
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Kirby\Form\Mixin;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait When
{
protected array|null $when = null;
/**
* Checks if the field is currently active
* or hidden because of a `when` condition
*/
public function isActive(): bool
{
if ($this->when === null || $this->when === []) {
return true;
}
$siblings = $this->siblings();
foreach ($this->when as $field => $value) {
$field = $siblings->get($field);
$input = $field?->value() ?? '';
// if the input data doesn't match the requested `when` value,
// that means that this field is not required and can be saved
// (*all* `when` conditions must be met for this field to be required)
if ($input !== $value) {
return false;
}
}
return true;
}
/**
* Setter for the `when` condition
*/
protected function setWhen(array|null $when = null): void
{
$this->when = $when;
}
/**
* Returns the `when` condition of the field
*/
public function when(): array|null
{
return $this->when;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Kirby\Panel\Controller;
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Toolkit\I18n;
/**
* The PageTree controller takes care of the request logic
* for the `k-page-tree` component and similar
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PageTree
{
protected Site $site;
public function __construct(
) {
$this->site = App::instance()->site();
}
/**
* Returns children for the parent as entries
*/
public function children(
string|null $parent = null,
string|null $moving = null
): array {
if ($moving !== null) {
$moving = Find::parent($moving);
}
if ($parent === null) {
return [
$this->entry($this->site, $moving)
];
}
return Find::parent($parent)
->childrenAndDrafts()
->filterBy('isListable', true)
->values(
fn ($child) => $this->entry($child, $moving)
);
}
/**
* Returns the properties to display the site or page
* as an entry in the page tree component
*/
public function entry(
Site|Page $entry,
Page|null $moving = null
): array {
$panel = $entry->panel();
$id = $entry->id() ?? '/';
$uuid = $entry->uuid()?->toString();
$url = $entry->url();
$value = $uuid ?? $id;
return [
'children' => $panel->url(true),
'disabled' => $moving?->isMovableTo($entry) === false,
'hasChildren' =>
$entry->hasChildren() === true ||
$entry->hasDrafts() === true,
'icon' => match (true) {
$entry instanceof Site => 'home',
default => $panel->image()['icon'] ?? null
},
'id' => $id,
'open' => false,
'label' => match (true) {
$entry instanceof Site => I18n::translate('view.site'),
default => $entry->title()->value()
},
'url' => $url,
'uuid' => $uuid,
'value' => $value
];
}
/**
* Returns the UUIDs/ids for all parents of the page
*/
public function parents(
string|null $page = null,
bool $includeSite = false,
): array {
$page = $this->site->page($page);
$parents = $page?->parents()->flip();
$parents = $parents?->values(
fn ($parent) => $parent->uuid()?->toString() ?? $parent->id()
);
$parents ??= [];
if ($includeSite === true) {
array_unshift($parents, $this->site->uuid()?->toString() ?? '/');
}
return [
'data' => $parents
];
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Kirby\Panel\Controller;
use Kirby\Cms\App;
use Kirby\Toolkit\Escape;
/**
* The Search controller takes care of the logic
* for delivering Panel search results
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @unstable
*/
class Search
{
public static function files(
string|null $query = null,
int|null $limit = null,
int $page = 1
): array {
$kirby = App::instance();
$files = $kirby->site()
->index(true)
->filter('isListable', true)
->files();
// add site files which aren't considered by the index
$files = $files->add($kirby->site()->files());
// filter and search among those files
$files = $files->filter('isListable', true)->search($query);
if ($limit !== null) {
$files = $files->paginate($limit, $page);
}
return [
'results' => $files->values(fn ($file) => [
'image' => $file->panel()->image(),
'text' => Escape::html($file->filename()),
'link' => $file->panel()->url(true),
'info' => Escape::html($file->id()),
'uuid' => $file->uuid()->toString(),
]),
'pagination' => $files->pagination()?->toArray()
];
}
public static function pages(
string|null $query = null,
int|null $limit = null,
int $page = 1
): array {
$kirby = App::instance();
$pages = $kirby->site()
->index(true)
->search($query)
->filter('isListable', true);
if ($limit !== null) {
$pages = $pages->paginate($limit, $page);
}
return [
'results' => $pages->values(fn ($page) => [
'image' => $page->panel()->image(),
'text' => Escape::html($page->title()->value()),
'link' => $page->panel()->url(true),
'info' => Escape::html($page->id()),
'uuid' => $page->uuid()?->toString(),
]),
'pagination' => $pages->pagination()?->toArray()
];
}
public static function users(
string|null $query = null,
int|null $limit = null,
int $page = 1
): array {
$kirby = App::instance();
$users = $kirby->users()->search($query);
if ($limit !== null) {
$users = $users->paginate($limit, $page);
}
return [
'results' => $users->values(fn ($user) => [
'image' => $user->panel()->image(),
'text' => Escape::html($user->username()),
'link' => $user->panel()->url(true),
'info' => Escape::html($user->role()->title()),
'uuid' => $user->uuid()->toString(),
]),
'pagination' => $users->pagination()?->toArray()
];
}
}

194
kirby/src/Panel/Lab/Doc.php Normal file
View file

@ -0,0 +1,194 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Cms\App;
use Kirby\Data\Data;
use Kirby\Panel\Lab\Doc\Event;
use Kirby\Panel\Lab\Doc\Method;
use Kirby\Panel\Lab\Doc\Prop;
use Kirby\Panel\Lab\Doc\Slot;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Documentation for a single Vue component
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Doc
{
protected array $data;
public function __construct(
public string $name,
public string $source,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $docBlock = null,
public array $events = [],
public array $examples = [],
public bool $isUnstable = false,
public array $methods = [],
public array $props = [],
public string|null $since = null,
public array $slots = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
$this->docBlock = Doc::kt($this->docBlock ?? '');
}
/**
* Checks if a documentation file exists for the component
*/
public static function exists(string $name): bool
{
return
file_exists(static::file($name, 'dist')) ||
file_exists(static::file($name, 'dev'));
}
public static function factory(string $name): static|null
{
// protect against path traversal
$name = basename($name);
// read data
$file = static::file($name, 'dev');
if (file_exists($file) === false) {
$file = static::file($name, 'dist');
}
$data = Data::read($file);
// filter internal components
if (isset($data['tags']['internal']) === true) {
return null;
}
// helper function for gathering parts
$gather = function (string $part, string $class) use ($data) {
$parts = A::map(
$data[$part] ?? [],
fn ($x) => $class::factory($x)?->toArray()
);
$parts = array_filter($parts);
usort($parts, fn ($a, $b) => $a['name'] <=> $b['name']);
return $parts;
};
return new static(
name: $name,
source: $data['sourceFile'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
docBlock: $data['docsBlocks'][0] ?? null,
examples: $data['tags']['examples'] ?? [],
events: $gather('events', Event::class),
isUnstable: isset($data['tags']['unstable']) === true,
methods: $gather('methods', Method::class),
props: $gather('props', Prop::class),
since: $data['tags']['since'][0]['description'] ?? null,
slots: $gather('slots', Slot::class)
);
}
/**
* Returns the path to the documentation file for the component
*/
public static function file(string $name, string $context): string
{
$root = match ($context) {
'dev' => App::instance()->root('panel') . '/tmp',
'dist' => App::instance()->root('panel') . '/dist/ui',
};
$name = Str::after($name, 'k-');
$name = Str::kebabToCamel($name);
return $root . '/' . $name . '.json';
}
/**
* Helper to resolve KirbyText
*/
public static function kt(string $text, bool $inline = false): string
{
return App::instance()->kirbytext($text, [
'markdown' => [
'breaks' => false,
'inline' => $inline,
]
]);
}
/**
* Returns the path to the Lab examples, if available
*/
public function lab(): string|null
{
$root = App::instance()->root('panel') . '/lab';
foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) {
$props = require $example;
if (($props['docs'] ?? null) === $this->name) {
return Str::before(Str::after($example, $root), 'index.php');
}
}
return null;
}
public function source(): string
{
return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->source;
}
/**
* Returns the data for this documentation
*/
public function toArray(): array
{
return [
'component' => $this->name,
'deprecated' => $this->deprecated,
'description' => $this->description,
'docBlock' => $this->docBlock,
'events' => $this->events,
'examples' => $this->examples,
'isUnstable' => $this->isUnstable,
'methods' => $this->methods,
'props' => $this->props,
'since' => $this->since,
'slots' => $this->slots,
'source' => $this->source(),
];
}
/**
* Returns the information to display as
* entry in a collection (e.g. on the Lab index view)
*/
public function toItem(): array
{
return [
'image' => [
'icon' => $this->isUnstable ? 'lab' : 'book',
'back' => 'light-dark(white, var(--color-gray-800))',
],
'text' => $this->name,
'link' => '/lab/docs/' . $this->name,
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
/**
* Documentation for a single argument for an event, slot or method
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Argument
{
public function __construct(
public string $name,
public string|null $type = null,
public string|null $description = null,
) {
$this->description = Doc::kt($this->description ?? '', true);
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
type: $data['type']['names'][0] ?? null,
description: $data['description'] ?? null,
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
];
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
/**
* Documentation for a single Vue emittable event
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Event
{
public function __construct(
public string $name,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $since = null,
public array $properties = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
since: $data['tags']['since'][0]['description'] ?? null,
properties: A::map(
$data['properties'] ?? [],
fn ($property) => Argument::factory($property)
)
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'deprecated' => $this->deprecated,
'properties' => $this->properties,
'since' => $this->since,
];
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
/**
* Documentation for a single Vue component method
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Method
{
public function __construct(
public string $name,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $since = null,
public string|null $returns = null,
public array $params = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
since: $data['tags']['since'][0]['description'] ?? null,
returns: $data['returns']['type']['name'] ?? null,
params: A::map(
$data['params'] ?? [],
fn ($param) => Argument::factory($param)
),
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'deprecated' => $this->deprecated,
'params' => $this->params,
'returns' => $this->returns,
'since' => $this->since,
];
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Documentation for a single Vue component prop
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Prop
{
public function __construct(
public string $name,
public string|null $type = null,
public string|null $description = null,
public string|null $default = null,
public string|null $deprecated = null,
public string|null $example = null,
public bool $required = false,
public string|null $since = null,
public string|null $value = null,
public array $values = []
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static|null
{
// filter internal props
if (isset($data['tags']['internal']) === true) {
return null;
}
// filter unset props
if (($type = $data['type']['name'] ?? null) === 'null') {
return null;
}
return new static(
name: $data['name'],
type: $type,
default: self::normalizeDefault($data['defaultValue']['value'] ?? null, $type),
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
example: $data['tags']['example'][0]['description'] ?? null,
required: $data['required'] ?? false,
since: $data['tags']['since'][0]['description'] ?? null,
value: $data['tags']['value'][0]['description'] ?? null,
values: $data['values'] ?? []
);
}
protected static function normalizeDefault(
string|null $default,
string|null $type
): string|null {
if ($default === null) {
// if type is boolean primarily and no default
// value has been set, add `false` as default
// for clarity
if (Str::startsWith($type, 'boolean')) {
return 'false';
}
return null;
}
// normalize longform function
if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) {
return $matches[1];
}
// normalize object shorthand function
if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) {
return $matches[1];
}
// normalize all other defaults from shorthand function
if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) {
return $matches[1];
}
return $default;
}
public function toArray(): array
{
return [
'name' => $this->name,
'default' => $this->default,
'description' => $this->description,
'deprecated' => $this->deprecated,
'example' => $this->example,
'required' => $this->required,
'since' => $this->since,
'type' => $this->type,
'value' => $this->value,
'values' => $this->values,
];
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
/**
* Documentation for a single Vue slot
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Slot
{
public function __construct(
public string $name,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $since = null,
public array $bindings = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
since: $data['tags']['since'][0]['description'] ?? null,
bindings: A::map(
$data['bindings'] ?? [],
fn ($binding) => Argument::factory($binding)
)
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'bindings' => $this->bindings,
'description' => $this->description,
'deprecated' => $this->deprecated,
'since' => $this->since,
];
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Toolkit\I18n;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class Button extends Component
{
public function __construct(
public string $component = 'k-button',
public array|null $badge = null,
public string|null $class = null,
public string|bool|null $current = null,
public string|null $dialog = null,
public bool $disabled = false,
public string|null $drawer = null,
public bool|null $dropdown = null,
public string|null $icon = null,
public string|null $link = null,
public bool|string $responsive = true,
public string|null $size = null,
public string|null $style = null,
public string|null $target = null,
public string|array|null $text = null,
public string|null $theme = null,
public string|array|null $title = null,
public string $type = 'button',
public string|null $variant = null,
...$attrs
) {
$this->attrs = $attrs;
}
public function props(): array
{
return [
...parent::props(),
'badge' => $this->badge,
'current' => $this->current,
'dialog' => $this->dialog,
'disabled' => $this->disabled,
'drawer' => $this->drawer,
'dropdown' => $this->dropdown,
'icon' => $this->icon,
'link' => $this->link,
'responsive' => $this->responsive,
'size' => $this->size,
'target' => $this->target,
'text' => I18n::translate($this->text, $this->text),
'theme' => $this->theme,
'title' => I18n::translate($this->title, $this->title),
'type' => $this->type,
'variant' => $this->variant,
];
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Toolkit\I18n;
/**
* View button to create a new language
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguageCreateButton extends ViewButton
{
public function __construct()
{
$user = App::instance()->user();
$permission = $user?->role()->permissions()->for('languages', 'create');
parent::__construct(
dialog: 'languages/create',
disabled: $permission !== true,
icon: 'add',
text: I18n::translate('language.create'),
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Toolkit\I18n;
/**
* View button to delete a language
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguageDeleteButton extends ViewButton
{
public function __construct(Language $language)
{
$user = App::instance()->user();
$permission = $user?->role()->permissions()->for('languages', 'delete');
parent::__construct(
dialog: 'languages/' . $language->id() . '/delete',
disabled: $permission !== true,
icon: 'trash',
title: I18n::translate('delete'),
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Toolkit\I18n;
/**
* View button to update settings of a language
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguageSettingsButton extends ViewButton
{
public function __construct(Language $language)
{
$user = App::instance()->user();
$permission = $user?->role()->permissions()->for('languages', 'update');
parent::__construct(
dialog: 'languages/' . $language->id() . '/update',
disabled: $permission !== true,
icon: 'cog',
title: I18n::translate('settings'),
);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\Str;
/**
* View button to switch content translation languages
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguagesDropdown extends ViewButton
{
protected App $kirby;
public function __construct(
ModelWithContent $model
) {
$this->kirby = $model->kirby();
parent::__construct(
component: 'k-languages-dropdown',
model: $model,
class: 'k-languages-dropdown',
icon: 'translate',
// Fiber dropdown endpoint to load options
// only when dropdown is opened
options: $model->panel()->url(true) . '/languages',
responsive: 'text',
text: Str::upper($this->kirby->language()?->code())
);
}
/**
* Returns if any translation other than the current one has unsaved changes
* (the current language has to be handled in `k-languages-dropdown` as its
* state can change dynamically without another backend request)
*/
public function hasDiff(): bool
{
foreach (Languages::ensure() as $language) {
if ($this->kirby->language()?->code() !== $language->code()) {
if ($this->model->version('changes')->exists($language) === true) {
return true;
}
}
}
return false;
}
public function option(Language $language): array
{
$changes = $this->model->version('changes');
return [
'text' => $language->name(),
'code' => $language->code(),
'current' => $language->code() === $this->kirby->language()?->code(),
'default' => $language->isDefault(),
'changes' => $changes->exists($language),
'lock' => $changes->isLocked('*')
];
}
/**
* Options are used in the Fiber dropdown routes
*/
public function options(): array
{
$languages = $this->kirby->languages();
$options = [];
if ($this->kirby->multilang() === false) {
return $options;
}
// add the primary/default language first
if ($default = $languages->default()) {
$options[] = $this->option($default);
$options[] = '-';
$languages = $languages->not($default);
}
// add all secondary languages after the separator
foreach ($languages as $language) {
$options[] = $this->option($language);
}
return $options;
}
public function props(): array
{
return [
...parent::props(),
'hasDiff' => $this->hasDiff()
];
}
public function render(): array|null
{
// hides the language selector when there are less than 2 languages
if ($this->kirby->languages()->count() < 2) {
return null;
}
return parent::render();
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Toolkit\I18n;
/**
* Open view button
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class OpenButton extends ViewButton
{
public function __construct(
public string|null $link,
public string|null $target = '_blank'
) {
parent::__construct(
class: 'k-open-view-button',
icon: 'open',
link: $link,
target: $target,
title: I18n::translate('open')
);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\Page;
use Kirby\Toolkit\I18n;
/**
* Status view button for pages
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PageStatusButton extends ViewButton
{
public function __construct(
Page $page
) {
$status = $page->status();
$blueprint = $page->blueprint()->status()[$status] ?? null;
$disabled = $page->permissions()->cannot('changeStatus');
$text = $blueprint['label'] ?? I18n::translate('page.status.' . $status);
$title = I18n::translate('page.status') . ': ' . $text;
if ($disabled === true) {
$title .= ' (' . I18n::translate('disabled') . ')';
}
parent::__construct(
class: 'k-status-view-button k-page-status-button',
component: 'k-status-view-button',
dialog: $page->panel()->url(true) . '/changeStatus',
disabled: $disabled,
icon: 'status-' . $status,
style: '--icon-size: 15px',
text: $text,
title: $title,
theme: match($status) {
'draft' => 'negative-icon',
'unlisted' => 'info-icon',
'listed' => 'positive-icon'
}
);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Toolkit\I18n;
/**
* Preview view button
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PreviewButton extends ViewButton
{
public function __construct(
public string|null $link
) {
parent::__construct(
class: 'k-preview-view-button',
icon: 'window',
link: $link,
title: I18n::translate('preview')
);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\I18n;
/**
* Settings view button for models
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class SettingsButton extends ViewButton
{
public function __construct(
ModelWithContent $model
) {
parent::__construct(
component: 'k-settings-view-button',
class: 'k-settings-view-button',
icon: 'cog',
options: $model->panel()->url(true),
title: I18n::translate('settings'),
);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\ModelWithContent;
use Kirby\Content\VersionId;
use Kirby\Toolkit\I18n;
/**
* Versions view button for models
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionsButton extends ViewButton
{
public function __construct(
ModelWithContent $model,
VersionId|string $versionId = 'latest'
) {
$versionId = $versionId === 'compare' ? 'compare' : VersionId::from($versionId)->value();
$viewUrl = $model->panel()->url(true) . '/preview';
parent::__construct(
class: 'k-versions-view-button',
icon: $versionId === 'compare' ? 'layout-columns' : 'git-branch',
options: [
[
'label' => I18n::translate('version.latest'),
'icon' => 'git-branch',
'link' => $viewUrl . '/latest',
'current' => $versionId === 'latest'
],
[
'label' => I18n::translate('version.changes'),
'icon' => 'git-branch',
'link' => $viewUrl . '/changes',
'current' => $versionId === 'changes'
],
'-',
[
'label' => I18n::translate('version.compare'),
'icon' => 'layout-columns',
'link' => $viewUrl . '/compare',
'current' => $versionId === 'compare'
],
],
text: I18n::translate('version.' . $versionId),
);
}
}

View file

@ -0,0 +1,215 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Closure;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Panel\Panel;
use Kirby\Panel\Ui\Button;
use Kirby\Toolkit\Controller;
/**
* A view button is a UI button, by default small in size and filles,
* that optionally defines options for a dropdown
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class ViewButton extends Button
{
public function __construct(
public string $component = 'k-view-button',
public readonly ModelWithContent|Language|null $model = null,
public array|null $badge = null,
public string|null $class = null,
public string|bool|null $current = null,
public string|null $dialog = null,
public bool $disabled = false,
public string|null $drawer = null,
public bool|null $dropdown = null,
public string|null $icon = null,
public string|null $link = null,
public array|string|null $options = null,
public bool|string $responsive = true,
public string|null $size = 'sm',
public string|null $style = null,
public string|null $target = null,
public string|array|null $text = null,
public string|null $theme = null,
public string|array|null $title = null,
public string $type = 'button',
public string|null $variant = 'filled',
...$attrs
) {
$this->attrs = $attrs;
}
/**
* Creates new view button by looking up
* the button in all areas, if referenced by name
* and resolving to proper instance
*/
public static function factory(
string|array|Closure|bool $button = true,
string|int|null $name = null,
string|null $view = null,
ModelWithContent|Language|null $model = null,
array $data = []
): static|null {
// if referenced by name (`name: false`),
// don't render anything
if ($button === false) {
return null;
}
// transform `- name` notation to `name: true`
if (
is_string($name) === false &&
is_string($button) === true
) {
$name = $button;
$button = true;
}
// if referenced by name (`name: true`),
// try to get button definition from areas or config
if ($button === true) {
$button = static::find($name, $view);
}
// resolve Closure to button object or array
if ($button instanceof Closure) {
$button = static::resolve($button, $model, $data);
}
if (
$button === null ||
$button instanceof ViewButton
) {
return $button;
}
// flatten array into list of arguments for this class
$button = static::normalize($button);
// if button definition has a name, use it for the component name
if (is_string($name) === true) {
// if this specific component does not exist,
// `k-view-buttons` will fall back to `k-view-button` again
$button['component'] ??= 'k-' . $name . '-view-button';
}
return new static(...$button, model: $model);
}
/**
* Finds a view button by name
* among the defined buttons from all areas
* @unstable
*/
public static function find(
string $name,
string|null $view = null
): array|Closure {
// collect all buttons from areas and config
$buttons = [
...Panel::buttons(),
...App::instance()->option('panel.viewButtons.' . $view, [])
];
// try to find by full name (view-prefixed)
if ($view && $button = $buttons[$view . '.' . $name] ?? null) {
return $button;
}
// try to find by just name
if ($button = $buttons[$name] ?? null) {
return $button;
}
// assume it must be a custom view button component
return ['component' => 'k-' . $name . '-view-button'];
}
/**
* Transforms an array to be used as
* named arguments in the constructor
* @unstable
*/
public static function normalize(array $button): array
{
// if component and props are both not set, assume shortcut
// where props were directly passed on top-level
if (
isset($button['component']) === false &&
isset($button['props']) === false
) {
return $button;
}
// flatten array
if ($props = $button['props'] ?? null) {
$button = [...$props, ...$button];
unset($button['props']);
}
return $button;
}
public function props(): array
{
// helper for props that support Kirby queries
$resolve = fn ($value) =>
$value ?
$this->model?->toSafeString($value) ?? $value :
null;
return [
...$props = parent::props(),
'dialog' => $resolve($props['dialog']),
'drawer' => $resolve($props['drawer']),
'icon' => $resolve($props['icon']),
'link' => $resolve($props['link']),
'text' => $resolve($props['text']),
'theme' => $resolve($props['theme']),
'options' => $this->options
];
}
/**
* Transforms a closure to the actual view button
* by calling it with the provided arguments
*/
public static function resolve(
Closure $button,
ModelWithContent|Language|null $model = null,
array $data = []
): static|array|null {
$kirby = App::instance();
$controller = new Controller($button);
if (
$model instanceof ModelWithContent ||
$model instanceof Language
) {
$data = [
'model' => $model,
$model::CLASS_ALIAS => $model,
...$data
];
}
return $controller->call(data: [
'kirby' => $kirby,
'site' => $kirby->site(),
'user' => $kirby->user(),
...$data
]);
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Panel\Model;
/**
* Collects view buttons for a specific view
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class ViewButtons
{
public function __construct(
public readonly string $view,
public readonly ModelWithContent|Language|null $model = null,
public array|false|null $buttons = null,
public array $data = []
) {
// if no specific buttons are passed,
// use default buttons for this view from config
$this->buttons ??= App::instance()->option(
'panel.viewButtons.' . $view
);
}
/**
* Adds data passed to view button closures
*
* @return $this
*/
public function bind(array $data): static
{
$this->data = [...$this->data, ...$data];
return $this;
}
/**
* Sets the default buttons
*
* @return $this
*/
public function defaults(string ...$defaults): static
{
$this->buttons ??= $defaults;
return $this;
}
/**
* Returns array of button component-props definitions
*/
public function render(): array
{
// hides all buttons when `buttons: false` set
if ($this->buttons === false) {
return [];
}
$buttons = [];
foreach ($this->buttons ?? [] as $name => $button) {
$buttons[] = ViewButton::factory(
button: $button,
name: $name,
view: $this->view,
model: $this->model,
data: $this->data
)?->render();
}
return array_values(array_filter($buttons));
}
/**
* Creates new instance for a view
* with special support for model views
*/
public static function view(
string|Model $view,
ModelWithContent|Language|null $model = null
): static {
if ($view instanceof Model) {
$model = $view->model();
$blueprint = $model->blueprint()->buttons();
$view = $model::CLASS_ALIAS;
}
return new static(
view: $view,
model: $model ?? null,
buttons: $blueprint ?? null
);
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Exception\LogicException;
use Kirby\Toolkit\Str;
/**
* Component that can be passed as component-props array
* to the Vue Panel frontend
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
abstract class Component
{
protected string $key;
public array $attrs = [];
public function __construct(
public string $component,
public string|null $class = null,
public string|null $style = null,
...$attrs
) {
$this->attrs = $attrs;
}
/**
* Magic setter and getter for component properties
*
* ```php
* $component->class('my-class')
* ```
*/
public function __call(string $name, array $args = [])
{
if (property_exists($this, $name) === false) {
throw new LogicException(
message: 'The property "' . $name . '" does not exist on the UI component "' . $this->component . '"'
);
}
// getter
if ($args === []) {
return $this->$name;
}
// setter
$this->$name = $args[0];
return $this;
}
/**
* Returns a (unique) key that can be used
* for Vue's `:key` attribute
*/
public function key(): string
{
return $this->key ??= Str::random(10, 'alphaNum');
}
/**
* Returns the props that will be passed to the Vue component
*/
public function props(): array
{
return [
'class' => $this->class,
'style' => $this->style,
...$this->attrs
];
}
/**
* Returns array with the Vue component name and props array
*/
public function render(): array|null
{
return [
'component' => $this->component,
'key' => $this->key(),
'props' => array_filter($this->props())
];
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Cms\App;
use Kirby\Cms\File;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Panel\Ui\FilePreviews\DefaultFilePreview;
use Kirby\Toolkit\I18n;
/**
* Defines a component that implements a file preview
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
abstract class FilePreview extends Component
{
public function __construct(
public File $file,
public string $component
) {
}
/**
* Returns true if this class should
* handle the preview of this file
*/
abstract public static function accepts(File $file): bool;
/**
* Returns detail information about the file
*/
public function details(): array
{
return [
[
'title' => I18n::translate('template'),
'text' => $this->file->template() ?? '—'
],
[
'title' => I18n::translate('mime'),
'text' => $this->file->mime()
],
[
'title' => I18n::translate('url'),
'link' => $link = $this->file->previewUrl(),
'text' => $link,
],
[
'title' => I18n::translate('size'),
'text' => $this->file->niceSize()
],
];
}
/**
* Returns a file preview instance by going through all
* available handler classes and finding the first that
* accepts the file
*/
final public static function factory(File $file): static
{
// get file preview classes providers from plugins
$handlers = App::instance()->extensions('filePreviews');
foreach ($handlers as $handler) {
if (is_subclass_of($handler, self::class) === false) {
throw new InvalidArgumentException(
message: 'File preview handler "' . $handler . '" must extend ' . self::class
);
}
if ($handler::accepts($file) === true) {
return new $handler($file);
}
}
return new DefaultFilePreview($file);
}
/**
* Icon or image to display as thumbnail
*/
public function image(): array|null
{
return $this->file->panel()->image([
'back' => 'transparent',
'ratio' => '1/1'
], 'cards');
}
public function props(): array
{
return [
'details' => $this->details(),
'image' => $this->image(),
'url' => $this->file->previewUrl()
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class AudioFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-audio-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->type() === 'audio';
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* Fallback file preview component
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class DefaultFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-default-file-preview'
) {
}
/**
* Accepts any file as last resort
*/
public static function accepts(File $file): bool
{
return true;
}
public function props(): array
{
return [
...parent::props(),
'image' => $this->image()
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
use Kirby\Toolkit\I18n;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class ImageFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-image-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->type() === 'image';
}
public function details(): array
{
return [
...parent::details(),
[
'title' => I18n::translate('dimensions'),
'text' => $this->file->dimensions() . ' ' . I18n::translate('pixel')
],
[
'title' => I18n::translate('orientation'),
'text' => I18n::translate('orientation.' . $this->file->dimensions()->orientation())
]
];
}
public function props(): array
{
return [
...parent::props(),
'focusable' => $this->file->panel()->isFocusable()
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PdfFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-pdf-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->extension() === 'pdf';
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VideoFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-video-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->type() === 'video';
}
}

124
kirby/src/Plugin/Asset.php Normal file
View file

@ -0,0 +1,124 @@
<?php
namespace Kirby\Plugin;
use Kirby\Filesystem\F;
use Stringable;
/**
* Representing a plugin asset with methods
* to manage the asset file between the plugin
* and media folder
*
* @package Kirby Plugin
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Asset implements Stringable
{
public function __construct(
protected string $path,
protected string $root,
protected Plugin $plugin
) {
}
public function extension(): string
{
return F::extension($this->path());
}
public function filename(): string
{
return F::filename($this->path());
}
/**
* Create a unique media hash
*/
public function mediaHash(): string
{
return crc32($this->filename()) . '-' . $this->modified();
}
/**
* Absolute path to the asset file in the media folder
*/
public function mediaRoot(): string
{
return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path();
}
/**
* Public accessible url path for the asset
*/
public function mediaUrl(): string
{
return $this->plugin()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->path();
}
/**
* Timestamp when asset file was last modified
*/
public function modified(): int|false
{
return F::modified($this->root());
}
public function path(): string
{
return $this->path;
}
public function plugin(): Plugin
{
return $this->plugin;
}
/**
* Publishes the asset file to the plugin's media folder
* by creating a symlink
*/
public function publish(): void
{
F::link($this->root(), $this->mediaRoot(), 'symlink');
}
/**
* @internal
* @since 4.0.0
* @deprecated 4.0.0
* @codeCoverageIgnore
*/
public function publishAt(string $path): void
{
F::link(
$this->root(),
$this->plugin()->mediaRoot() . '/' . $path,
'symlink'
);
}
public function root(): string
{
return $this->root;
}
/**
* @see self::mediaUrl()
*/
public function url(): string
{
return $this->mediaUrl();
}
/**
* @see self::url()
*/
public function __toString(): string
{
return $this->url();
}
}

188
kirby/src/Plugin/Assets.php Normal file
View file

@ -0,0 +1,188 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Kirby\Cms\App;
use Kirby\Cms\Collection;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Toolkit\Str;
/**
* Plugin assets are automatically copied/linked
* to the media folder, to make them publicly
* available. This class handles the magic around that.
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Plugin\Asset>
*/
class Assets extends Collection
{
/**
* Clean old/deprecated assets on every resolve
*/
public static function clean(string $pluginName): void
{
if ($plugin = App::instance()->plugin($pluginName)) {
$media = $plugin->mediaRoot();
$assets = $plugin->assets();
// get all media files
$files = Dir::index($media, true);
// get all active assets' paths from the plugin
$active = $assets->values(
function ($asset) {
$path = $asset->mediaHash() . '/' . $asset->path();
$paths = [];
$parts = explode('/', $path);
// collect all path segments
// (e.g. foo/, foo/bar/, foo/bar/baz.css) for the asset
for ($i = 1, $max = count($parts); $i <= $max; $i++) {
$paths[] = implode('/', array_slice($parts, 0, $i));
// TODO: remove when media hash is enforced as mandatory
$paths[] = implode('/', array_slice($parts, 1, $i));
}
return $paths;
}
);
// flatten the array and remove duplicates
$active = array_unique(array_merge(...array_values($active)));
// get outdated media files by comparing all
// files in the media folder against the set of asset paths
$stale = array_diff($files, $active);
foreach ($stale as $file) {
$root = $media . '/' . $file;
if (is_file($root) === true) {
F::remove($root);
} else {
Dir::remove($root);
}
}
}
}
/**
* Filters assets collection by CSS files
*/
public function css(): static
{
return $this->filter(fn ($asset) => $asset->extension() === 'css');
}
/**
* Creates a new collection for the plugin's assets
* by considering the plugin's `asset` extension
* (and `assets` directory as fallback)
*/
public static function factory(Plugin $plugin): static
{
// get assets defined in the plugin extension
if ($assets = $plugin->extends()['assets'] ?? null) {
if ($assets instanceof Closure) {
$assets = $assets();
}
// normalize array: use relative path as
// key when no key is defined
foreach ($assets as $key => $root) {
if (is_int($key) === true) {
unset($assets[$key]);
$path = Str::after($root, $plugin->root() . '/');
$assets[$path] = $root;
}
}
}
// fallback: if no assets are defined in the plugin extension,
// use all files in the plugin's `assets` directory
if ($assets === null) {
$assets = [];
$root = $plugin->root() . '/assets';
foreach (Dir::index($root, true) as $path) {
if (is_file($root . '/' . $path) === true) {
$assets[$path] = $root . '/' . $path;
}
}
}
$collection = new static([], $plugin);
foreach ($assets as $path => $root) {
$collection->data[$path] = new Asset($path, $root, $plugin);
}
return $collection;
}
/**
* Filters assets collection by JavaScript files
*/
public function js(): static
{
return $this->filter(fn ($asset) => $asset->extension() === 'js');
}
public function plugin(): Plugin
{
return $this->parent;
}
/**
* Create a symlink for a plugin asset and
* return the public URL
*/
public static function resolve(
string $pluginName,
string $hash,
string $path
): Response|null {
if ($plugin = App::instance()->plugin($pluginName)) {
// do some spring cleaning for older files
static::clean($pluginName);
// @codeCoverageIgnoreStart
// TODO: deprecated media URL without hash
if (empty($hash) === true) {
$asset = $plugin->asset($path);
$asset->publishAt($path);
return Response::file($asset->root());
}
// TODO: deprecated media URL with hash (but path)
if ($asset = $plugin->asset($hash . '/' . $path)) {
$asset->publishAt($hash . '/' . $path);
return Response::file($asset->root());
}
// @codeCoverageIgnoreEnd
if ($asset = $plugin->asset($path)) {
if ($asset->mediaHash() === $hash) {
// create a symlink if possible
$asset->publish();
// return the file response
return Response::file($asset->root());
}
}
}
return null;
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Stringable;
/**
* Represents the license of a plugin.
* Used to display the license in the Panel system view
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class License implements Stringable
{
protected LicenseStatus $status;
public function __construct(
protected Plugin $plugin,
protected string $name,
protected string|null $link = null,
LicenseStatus|null $status = null
) {
$this->status = $status ?? LicenseStatus::from('unknown');
}
/**
* Returns the string representation of the license
*/
public function __toString(): string
{
return $this->name();
}
/**
* Creates a license instance from a given value
*/
public static function from(
Plugin $plugin,
Closure|array|string|null $license
): static {
if ($license instanceof Closure) {
return $license($plugin);
}
if (is_array($license)) {
return new static(
plugin: $plugin,
name: $license['name'] ?? '',
link: $license['link'] ?? null,
status: LicenseStatus::from($license['status'] ?? 'active')
);
}
if ($license === null || $license === '-') {
return new static(
plugin: $plugin,
name: '-',
status: LicenseStatus::from('unknown')
);
}
return new static(
plugin: $plugin,
name: $license,
status: LicenseStatus::from('active')
);
}
/**
* Get the license link. This can be the
* license terms or a link to a shop to
* purchase a license.
*/
public function link(): string|null
{
return $this->link;
}
/**
* Get the license name
*/
public function name(): string
{
return $this->name;
}
/**
* Get the license status
*/
public function status(): LicenseStatus
{
return $this->status;
}
/**
* Returns the license information as an array
*/
public function toArray(): array
{
return [
'link' => $this->link(),
'name' => $this->name(),
'status' => $this->status()->toArray()
];
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Kirby\Plugin;
use Kirby\Cms\LicenseStatus as SystemLicenseStatus;
use Stringable;
/**
* Represents the license status of a plugin.
* Used to display the status in the Panel system view
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class LicenseStatus implements Stringable
{
public function __construct(
protected string $value,
protected string $icon,
protected string $label,
protected string|null $link = null,
protected string|null $dialog = null,
protected string|null $drawer = null,
protected string|null $theme = null
) {
}
/**
* Returns the status label
*/
public function __toString(): string
{
return $this->label();
}
/**
* Returns the status dialog
*/
public function dialog(): string|null
{
return $this->dialog;
}
/**
* Returns the status drawer
*/
public function drawer(): string|null
{
return $this->drawer;
}
/**
* Returns a status by its name
*/
public static function from(LicenseStatus|string|array|null $status): static
{
if ($status instanceof LicenseStatus) {
return $status;
}
if (is_array($status) === true) {
return new static(...$status);
}
$status = SystemLicenseStatus::from($status ?? 'unknown');
$status ??= SystemLicenseStatus::Unknown;
return new static(
value: $status->value,
icon: $status->icon(),
label: $status->label(),
theme: $status->theme()
);
}
/**
* Returns the status icon
*/
public function icon(): string
{
return $this->icon;
}
/**
* Returns the status label
*/
public function label(): string
{
return $this->label;
}
/**
* Returns the status link
*/
public function link(): string|null
{
return $this->link;
}
/**
* Returns the theme
*/
public function theme(): string|null
{
return $this->theme;
}
/**
* Returns the status information as an array
*/
public function toArray(): array
{
return [
'dialog' => $this->dialog(),
'drawer' => $this->drawer(),
'icon' => $this->icon(),
'label' => $this->label(),
'link' => $this->link(),
'theme' => $this->theme(),
'value' => $this->value(),
];
}
/**
* Returns the status value
*/
public function value(): string
{
return $this->value;
}
}

354
kirby/src/Plugin/Plugin.php Normal file
View file

@ -0,0 +1,354 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Composer\InstalledVersions;
use Kirby\Cms\App;
use Kirby\Cms\Helpers;
use Kirby\Cms\System\UpdateStatus;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;
/**
* Represents a Plugin and handles parsing of
* the composer.json. It also creates the prefix
* and media url for the plugin.
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Plugin
{
protected Assets $assets;
protected License|Closure|array|string $license;
protected UpdateStatus|null $updateStatus = null;
/**
* @param string $name Plugin name within Kirby (`vendor/plugin`)
* @param array $extends Associative array of plugin extensions
*
* @throws \Kirby\Exception\InvalidArgumentException If the plugin name has an invalid format
*/
public function __construct(
protected string $name,
protected array $extends = [],
protected array $info = [],
Closure|string|array|null $license = null,
protected string|null $root = null,
protected string|null $version = null,
) {
static::validateName($name);
// TODO: Remove in v7
if ($root = $extends['root'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing the `root` inside the `extends` array has been deprecated. Pass it directly as named argument `root`.', 'plugin-extends-root');
$this->root ??= $root;
unset($this->extends['root']);
}
$this->root ??= dirname(debug_backtrace()[0]['file']);
// TODO: Remove in v7
if ($info = $extends['info'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing an `info` array inside the `extends` array has been deprecated. Pass the individual entries directly as named `info` argument.', 'plugin-extends-root');
if (empty($info) === false && is_array($info) === true) {
$this->info = [...$info, ...$this->info];
}
unset($this->extends['info']);
}
// read composer.json and use as info fallback
$info = Data::read($this->manifest(), fail: false);
$this->info = [...$info, ...$this->info];
$this->license = $license ?? $this->info['license'] ?? '-';
}
/**
* Allows access to any composer.json field by method call
*/
public function __call(string $key, array|null $arguments = null): mixed
{
return $this->info()[$key] ?? null;
}
/**
* Returns the plugin asset object for a specific asset
*/
public function asset(string $path): Asset|null
{
return $this->assets()->get($path);
}
/**
* Returns the plugin assets collection
*/
public function assets(): Assets
{
return $this->assets ??= Assets::factory($this);
}
/**
* Returns the array with author information
* from the composer.json file
*/
public function authors(): array
{
return $this->info()['authors'] ?? [];
}
/**
* Returns a comma-separated list with all author names
*/
public function authorsNames(): string
{
$names = [];
foreach ($this->authors() as $author) {
$names[] = $author['name'] ?? null;
}
return implode(', ', array_filter($names));
}
/**
* Returns the associative array of extensions the plugin bundles
*/
public function extends(): array
{
return $this->extends;
}
/**
* Returns the unique ID for the plugin
* (alias for the plugin name)
*/
public function id(): string
{
return $this->name();
}
/**
* Returns the info data (from composer.json)
*/
public function info(): array
{
return $this->info;
}
/**
* Current $kirby instance
*/
public function kirby(): App
{
return App::instance();
}
/**
* Returns the link to the plugin homepage
*/
public function link(): string|null
{
$info = $this->info();
$homepage = $info['homepage'] ?? null;
$docs = $info['support']['docs'] ?? null;
$source = $info['support']['source'] ?? null;
$link = $homepage ?? $docs ?? $source;
return V::url($link) ? $link : null;
}
/**
* Returns the license object
*/
public function license(): License
{
// resolve license info from Closure, array or string
return License::from(
plugin: $this,
license: $this->license
);
}
/**
* Returns the path to the plugin's composer.json
*/
public function manifest(): string
{
return $this->root() . '/composer.json';
}
/**
* Returns the root where plugin assets are copied to
*/
public function mediaRoot(): string
{
return $this->kirby()->root('media') . '/plugins/' . $this->name();
}
/**
* Returns the base URL for plugin assets
*/
public function mediaUrl(): string
{
return $this->kirby()->url('media') . '/plugins/' . $this->name();
}
/**
* Returns the plugin name (`vendor/plugin`)
*/
public function name(): string
{
return $this->name;
}
/**
* Returns a Kirby option value for this plugin
*/
public function option(string $key)
{
return $this->kirby()->option($this->prefix() . '.' . $key);
}
/**
* Returns the option prefix (`vendor.plugin`)
*/
public function prefix(): string
{
return str_replace('/', '.', $this->name());
}
/**
* Returns the root where the plugin files are stored
*/
public function root(): string
{
return $this->root;
}
/**
* Returns all available plugin metadata
*/
public function toArray(): array
{
return [
'authors' => $this->authors(),
'description' => $this->description(),
'name' => $this->name(),
'license' => $this->license()->toArray(),
'link' => $this->link(),
'root' => $this->root(),
'version' => $this->version()
];
}
/**
* Returns the update status object unless the
* update check has been disabled for the plugin
* @since 3.8.0
*
* @param array|null $data Custom override for the getkirby.com update data
*/
public function updateStatus(array|null $data = null): UpdateStatus|null
{
if ($this->updateStatus !== null) {
return $this->updateStatus;
}
$kirby = $this->kirby();
$option = $kirby->option('updates.plugins');
// specific configuration per plugin
if (is_array($option) === true) {
// filter all option values by glob match
$option = A::filter(
$option,
fn ($value, $key) => fnmatch($key, $this->name()) === true
);
// sort the matches by key length (with longest key first)
$keys = array_map('strlen', array_keys($option));
array_multisort($keys, SORT_DESC, $option);
if ($option !== []) {
// use the first and therefore longest key (= most specific match)
$option = reset($option);
} else {
// fallback to the default option value
$option = true;
}
}
$option ??= $kirby->option('updates') ?? true;
if ($option !== true) {
return null;
}
return $this->updateStatus = new UpdateStatus($this, false, $data);
}
/**
* Checks if the name follows the required pattern
* and throws an exception if not
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function validateName(string $name): void
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException(
message: 'The plugin name must follow the format "a-z0-9-/a-z0-9-"'
);
}
}
/**
* Returns the normalized version number
* from the composer.json file
*/
public function version(): string|null
{
$name = $this->info()['name'] ?? null;
try {
// try to get version from "vendor/composer/installed.php",
// this is the most reliable source for the version
$version = InstalledVersions::getPrettyVersion($name);
} catch (Throwable) {
$version = null;
}
// fallback to the version provided in the plugin's index.php: as named
// argument, entry in the info array or from the composer.json file
$version ??= $this->version ?? $this->info()['version'] ?? null;
if (
is_string($version) !== true ||
$version === '' ||
Str::endsWith($version, '+no-version-set')
) {
return null;
}
// normalize the version number to be without leading `v`
$version = ltrim($version, 'vV');
// ensure that the version number now starts with a digit
if (preg_match('/^[0-9]/', $version) !== 1) {
return null;
}
return $version;
}
}