ajout d'un toggle pour désactiver le bandeau du haut de la page
This commit is contained in:
parent
c5ae72945d
commit
f771bb3f24
95 changed files with 22574 additions and 1 deletions
137
kirby/src/Api/Controller/Changes.php
Normal file
137
kirby/src/Api/Controller/Changes.php
Normal 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
436
kirby/src/Api/Upload.php
Normal 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'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
kirby/src/Cache/RedisCache.php
Normal file
160
kirby/src/Cache/RedisCache.php
Normal 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
130
kirby/src/Cms/Events.php
Normal 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);
|
||||
}
|
||||
}
|
||||
53
kirby/src/Cms/HasModels.php
Normal file
53
kirby/src/Cms/HasModels.php
Normal 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);
|
||||
}
|
||||
}
|
||||
22
kirby/src/Cms/LanguagePermissions.php
Normal file
22
kirby/src/Cms/LanguagePermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
243
kirby/src/Cms/ModelCommit.php
Normal file
243
kirby/src/Cms/ModelCommit.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
107
kirby/src/Cms/ModelState.php
Normal file
107
kirby/src/Cms/ModelState.php
Normal 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
236
kirby/src/Cms/PageCopy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
kirby/src/Content/Changes.php
Normal file
197
kirby/src/Content/Changes.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cache\Cache;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Files;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Pages;
|
||||
use Kirby\Cms\Users;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* The Changes class tracks changed models
|
||||
* in the Site's changes field.
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Changes
|
||||
{
|
||||
protected App $kirby;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->kirby = App::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Access helper for the cache, in which changes are stored
|
||||
*/
|
||||
public function cache(): Cache
|
||||
{
|
||||
return $this->kirby->cache('changes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the cache has been populated
|
||||
*/
|
||||
public function cacheExists(): bool
|
||||
{
|
||||
return $this->cache()->get('__updated__') !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache key for a given model
|
||||
*/
|
||||
public function cacheKey(ModelWithContent $model): string
|
||||
{
|
||||
return $model::CLASS_ALIAS . 's';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the tracked model still really has changes.
|
||||
* If not, untrack and remove from collection.
|
||||
*
|
||||
* @template T of \Kirby\Cms\Files|\Kirby\Cms\Pages|\Kirby\Cms\Users
|
||||
* @param T $tracked
|
||||
* @return T
|
||||
*/
|
||||
public function ensure(Files|Pages|Users $tracked): Files|Pages|Users
|
||||
{
|
||||
foreach ($tracked as $model) {
|
||||
if ($model->version('changes')->exists('*') === false) {
|
||||
$this->untrack($model);
|
||||
$tracked->remove($model);
|
||||
}
|
||||
}
|
||||
|
||||
return $tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all files with unsaved changes
|
||||
*/
|
||||
public function files(): Files
|
||||
{
|
||||
$files = new Files([]);
|
||||
|
||||
foreach ($this->read('files') as $id) {
|
||||
if ($file = $this->kirby->file($id)) {
|
||||
$files->add($file);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->ensure($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the cache by finding all models with changes version
|
||||
*/
|
||||
public function generateCache(): void
|
||||
{
|
||||
$models = [
|
||||
'files' => [],
|
||||
'pages' => [],
|
||||
'users' => []
|
||||
];
|
||||
|
||||
foreach ($this->kirby->models() as $model) {
|
||||
if ($model->version('changes')->exists('*') === true) {
|
||||
$models[$this->cacheKey($model)][] = (string)($model->uuid() ?? $model->id());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($models as $key => $changes) {
|
||||
$this->update($key, $changes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all pages with unsaved changes
|
||||
*/
|
||||
public function pages(): Pages
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Pages $pages
|
||||
*/
|
||||
$pages = $this->kirby->site()->find(
|
||||
false,
|
||||
false,
|
||||
...$this->read('pages')
|
||||
);
|
||||
|
||||
return $this->ensure($pages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the changes for a given model type
|
||||
*/
|
||||
public function read(string $key): array
|
||||
{
|
||||
return $this->cache()->get($key) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new model to the list of unsaved changes
|
||||
*/
|
||||
public function track(ModelWithContent $model): void
|
||||
{
|
||||
$key = $this->cacheKey($model);
|
||||
|
||||
$changes = $this->read($key);
|
||||
$changes[] = (string)($model->uuid() ?? $model->id());
|
||||
|
||||
$this->update($key, $changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a model from the list of unsaved changes
|
||||
*/
|
||||
public function untrack(ModelWithContent $model): void
|
||||
{
|
||||
// get the cache key for the model type
|
||||
$key = $this->cacheKey($model);
|
||||
|
||||
// remove the model from the list of changes
|
||||
$changes = A::filter(
|
||||
$this->read($key),
|
||||
fn ($id) => $id !== (string)($model->uuid() ?? $model->id())
|
||||
);
|
||||
|
||||
$this->update($key, $changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the changes field
|
||||
*/
|
||||
public function update(string $key, array $changes): void
|
||||
{
|
||||
$changes = array_unique($changes);
|
||||
$changes = array_values($changes);
|
||||
|
||||
$this->cache()->set($key, $changes);
|
||||
$this->cache()->set('__updated__', time());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all users with unsaved changes
|
||||
*/
|
||||
public function users(): Users
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Users $users
|
||||
*/
|
||||
$users = $this->kirby->users()->find(
|
||||
false,
|
||||
false,
|
||||
...$this->read('users')
|
||||
);
|
||||
|
||||
return $this->ensure($users);
|
||||
}
|
||||
}
|
||||
90
kirby/src/Content/ImmutableMemoryStorage.php
Normal file
90
kirby/src/Content/ImmutableMemoryStorage.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Exception\LogicException;
|
||||
|
||||
/**
|
||||
* @package Kirby Content
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class ImmutableMemoryStorage extends MemoryStorage
|
||||
{
|
||||
public function __construct(
|
||||
protected ModelWithContent $model,
|
||||
protected ModelWithContent|null $nextModel = null
|
||||
) {
|
||||
parent::__construct($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable storage entries cannot be deleted
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public function delete(VersionId $versionId, Language $language): void
|
||||
{
|
||||
$this->preventMutation('deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable storage entries cannot be moved
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public function move(
|
||||
VersionId $fromVersionId,
|
||||
Language $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
): void {
|
||||
$this->preventMutation('moved');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next state of the model if the
|
||||
* reference is given
|
||||
*/
|
||||
public function nextModel(): ModelWithContent|null
|
||||
{
|
||||
return $this->nextModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception to avoid the mutation of storage data
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
protected function preventMutation(string $mutation): void
|
||||
{
|
||||
throw new LogicException(
|
||||
message: 'Storage for the ' . $this->model::CLASS_ALIAS . ' is immutable and cannot be ' . $mutation . '. Make sure to use the last alteration of the object.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable storage entries cannot be touched
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public function touch(VersionId $versionId, Language $language): void
|
||||
{
|
||||
$this->preventMutation('touched');
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable storage entries cannot be updated
|
||||
*
|
||||
* @throws \Kirby\Exception\LogicException
|
||||
*/
|
||||
public function update(VersionId $versionId, Language $language, array $fields): void
|
||||
{
|
||||
$this->preventMutation('updated');
|
||||
}
|
||||
}
|
||||
229
kirby/src/Content/Lock.php
Normal file
229
kirby/src/Content/Lock.php
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Languages;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Lock class provides information about the
|
||||
* locking state of a content version, depending
|
||||
* on the timestamp and locked user id
|
||||
*
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Lock
|
||||
{
|
||||
public function __construct(
|
||||
protected User|null $user = null,
|
||||
protected int|null $modified = null,
|
||||
protected bool $legacy = false
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lock for the given version by
|
||||
* reading the modification timestamp and
|
||||
* lock user id from the version.
|
||||
*/
|
||||
public static function for(
|
||||
Version $version,
|
||||
Language|string $language = 'default'
|
||||
): static {
|
||||
|
||||
if ($legacy = static::legacy($version->model())) {
|
||||
return $legacy;
|
||||
}
|
||||
|
||||
// wildcard to search for a lock in any language
|
||||
// the first locked one will be preferred
|
||||
if ($language === '*') {
|
||||
foreach (Languages::ensure() as $language) {
|
||||
$lock = static::for($version, $language);
|
||||
|
||||
// return the first locked lock if any exists
|
||||
if ($lock->isLocked() === true) {
|
||||
return $lock;
|
||||
}
|
||||
}
|
||||
|
||||
// return the last lock if no lock was found
|
||||
return $lock;
|
||||
}
|
||||
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// if the version does not exist, it cannot be locked
|
||||
if ($version->exists($language) === false) {
|
||||
// create an open lock for the current user
|
||||
return new static(
|
||||
user: App::instance()->user(),
|
||||
);
|
||||
}
|
||||
|
||||
// Read the locked user id from the version
|
||||
if ($userId = ($version->read($language)['lock'] ?? null)) {
|
||||
$user = App::instance()->user($userId);
|
||||
}
|
||||
|
||||
return new static(
|
||||
user: $user ?? null,
|
||||
modified: $version->modified($language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the lock is still active because
|
||||
* recent changes have been made to the content
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
$minutes = 10;
|
||||
return $this->modified > time() - (60 * $minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content locking is enabled at all
|
||||
*/
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return App::instance()->option('content.locking', true) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the lock is coming from an old .lock file
|
||||
*/
|
||||
public function isLegacy(): bool
|
||||
{
|
||||
return $this->legacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the lock is actually locked
|
||||
*/
|
||||
public function isLocked(): bool
|
||||
{
|
||||
// if locking is disabled globally,
|
||||
// the lock is always open
|
||||
if (static::isEnabled() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// the version is not locked if the editing user
|
||||
// is the currently logged in user
|
||||
if ($this->user === App::instance()->user()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the lock is still active due to the
|
||||
// content currently being edited.
|
||||
if ($this->isActive() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for old .lock files and tries to create a
|
||||
* usable lock instance from them
|
||||
*/
|
||||
public static function legacy(ModelWithContent $model): static|null
|
||||
{
|
||||
$kirby = $model->kirby();
|
||||
$file = static::legacyFile($model);
|
||||
$id = '/' . $model->id();
|
||||
|
||||
// no legacy lock file? no lock.
|
||||
if (file_exists($file) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = Data::read($file, 'yml', fail: false)[$id] ?? [];
|
||||
|
||||
// no valid lock entry? no lock.
|
||||
if (isset($data['lock']) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// has the lock been unlocked? no lock.
|
||||
if (isset($data['unlock']) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new static(
|
||||
user: $kirby->user($data['lock']['user']),
|
||||
modified: $data['lock']['time'],
|
||||
legacy: true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to a legacy lock file
|
||||
*/
|
||||
public static function legacyFile(ModelWithContent $model): string
|
||||
{
|
||||
$root = match ($model::CLASS_ALIAS) {
|
||||
'file' => dirname($model->root()),
|
||||
default => $model->root()
|
||||
};
|
||||
return $root . '/.lock';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timestamp when the locked content has
|
||||
* been updated. You can pass a format to get a useful,
|
||||
* formatted date back.
|
||||
*/
|
||||
public function modified(
|
||||
string|null $format = null,
|
||||
string|null $handler = null
|
||||
): int|string|false|null {
|
||||
if ($this->modified === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::date($this->modified, $format, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the lock info to an array. This is directly
|
||||
* usable for Panel view props.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'isLegacy' => $this->isLegacy(),
|
||||
'isLocked' => $this->isLocked(),
|
||||
'modified' => $this->modified('c', 'date'),
|
||||
'user' => [
|
||||
'id' => $this->user?->id(),
|
||||
'email' => $this->user?->email()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user to whom this lock belongs
|
||||
*/
|
||||
public function user(): User|null
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
31
kirby/src/Content/LockedContentException.php
Normal file
31
kirby/src/Content/LockedContentException.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Exception\LogicException;
|
||||
|
||||
/**
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class LockedContentException extends LogicException
|
||||
{
|
||||
protected static string $defaultKey = 'content.lock';
|
||||
protected static string $defaultFallback = 'The version is locked';
|
||||
protected static int $defaultHttpCode = 423;
|
||||
|
||||
public function __construct(
|
||||
Lock $lock,
|
||||
string|null $key = null,
|
||||
string|null $message = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
message: $message,
|
||||
key: $key,
|
||||
details: $lock->toArray()
|
||||
);
|
||||
}
|
||||
}
|
||||
99
kirby/src/Content/MemoryStorage.php
Normal file
99
kirby/src/Content/MemoryStorage.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cache\MemoryCache;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
|
||||
/**
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class MemoryStorage extends Storage
|
||||
{
|
||||
/**
|
||||
* Cache instance, used to store content in memory
|
||||
*/
|
||||
protected MemoryCache $cache;
|
||||
|
||||
/**
|
||||
* Sets up the cache instance
|
||||
*/
|
||||
public function __construct(protected ModelWithContent $model)
|
||||
{
|
||||
parent::__construct($model);
|
||||
$this->cache = new MemoryCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unique id for a combination
|
||||
* of the version id, the language code and the model id
|
||||
*/
|
||||
protected function cacheId(VersionId $versionId, Language $language): string
|
||||
{
|
||||
return $versionId->value() . '/' . $language->code() . '/' . $this->model->id() . '/' . spl_object_hash($this->model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing version in an idempotent way if it was already deleted
|
||||
*/
|
||||
public function delete(VersionId $versionId, Language $language): void
|
||||
{
|
||||
$this->cache->remove($this->cacheId($versionId, $language));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version exists
|
||||
*/
|
||||
public function exists(VersionId $versionId, Language $language): bool
|
||||
{
|
||||
return $this->cache->exists($this->cacheId($versionId, $language));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modification timestamp of a version if it exists
|
||||
*/
|
||||
public function modified(VersionId $versionId, Language $language): int|null
|
||||
{
|
||||
if ($this->exists($versionId, $language) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->cache->modified($this->cacheId($versionId, $language));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored content fields
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function read(VersionId $versionId, Language $language): array
|
||||
{
|
||||
return $this->cache->get($this->cacheId($versionId, $language)) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modification timestamp of an existing version
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function touch(VersionId $versionId, Language $language): void
|
||||
{
|
||||
$fields = $this->read($versionId, $language);
|
||||
$this->write($versionId, $language, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the content fields of an existing version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*/
|
||||
protected function write(VersionId $versionId, Language $language, array $fields): void
|
||||
{
|
||||
$this->cache->set($this->cacheId($versionId, $language), $fields);
|
||||
}
|
||||
}
|
||||
331
kirby/src/Content/PlainTextStorage.php
Normal file
331
kirby/src/Content/PlainTextStorage.php
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
|
||||
/**
|
||||
* Content storage handler using plain text files
|
||||
* stored in the content folder
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 4.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class PlainTextStorage extends Storage
|
||||
{
|
||||
/**
|
||||
* Creates the absolute directory path for the model
|
||||
*/
|
||||
protected function contentDirectory(VersionId $versionId): string
|
||||
{
|
||||
$directory = match (true) {
|
||||
$this->model instanceof File
|
||||
=> dirname($this->model->root()),
|
||||
default
|
||||
=> $this->model->root()
|
||||
};
|
||||
|
||||
if ($versionId->is('changes')) {
|
||||
$directory .= '/_changes';
|
||||
}
|
||||
|
||||
return $directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the content file
|
||||
* @internal To be made `protected` when the CMS core no longer relies on it
|
||||
*/
|
||||
public function contentFile(VersionId $versionId, Language $language): string
|
||||
{
|
||||
// get the filename without extension and language code
|
||||
return match (true) {
|
||||
$this->model instanceof File => $this->contentFileForFile($this->model, $versionId, $language),
|
||||
$this->model instanceof Page => $this->contentFileForPage($this->model, $versionId, $language),
|
||||
$this->model instanceof Site => $this->contentFileForSite($this->model, $versionId, $language),
|
||||
$this->model instanceof User => $this->contentFileForUser($this->model, $versionId, $language),
|
||||
// @codeCoverageIgnoreStart
|
||||
default => throw new LogicException(
|
||||
message: 'Cannot determine content file for model type "' . $this->model::CLASS_ALIAS . '"'
|
||||
)
|
||||
// @codeCoverageIgnoreEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the content file of a file model
|
||||
*/
|
||||
protected function contentFileForFile(File $model, VersionId $versionId, Language $language): string
|
||||
{
|
||||
return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->filename(), $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the content file of a page model
|
||||
*/
|
||||
protected function contentFileForPage(Page $model, VersionId $versionId, Language $language): string
|
||||
{
|
||||
return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->intendedTemplate()->name(), $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the content file of a site model
|
||||
*/
|
||||
protected function contentFileForSite(Site $model, VersionId $versionId, Language $language): string
|
||||
{
|
||||
return $this->contentDirectory($versionId) . '/' . $this->contentFilename('site', $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the content file of a user model
|
||||
*/
|
||||
protected function contentFileForUser(User $model, VersionId $versionId, Language $language): string
|
||||
{
|
||||
return $this->contentDirectory($versionId) . '/' . $this->contentFilename('user', $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a filename with extension and optional language code
|
||||
* in a multi-language installation
|
||||
*/
|
||||
protected function contentFilename(string $name, Language $language): string
|
||||
{
|
||||
$kirby = $this->model->kirby();
|
||||
$extension = $kirby->contentExtension();
|
||||
|
||||
if ($language->isSingle() === false) {
|
||||
return $name . '.' . $language->code() . '.' . $extension;
|
||||
}
|
||||
|
||||
return $name . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with content files of all languages
|
||||
* @internal To be made `protected` when the CMS core no longer relies on it
|
||||
*/
|
||||
public function contentFiles(VersionId $versionId): array
|
||||
{
|
||||
if ($this->model->kirby()->multilang() === true) {
|
||||
return $this->model->kirby()->languages()->values(
|
||||
fn ($language) => $this->contentFile($versionId, $language)
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
$this->contentFile($versionId, Language::single())
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing version in an idempotent way if it was already deleted
|
||||
*/
|
||||
public function delete(VersionId $versionId, Language $language): void
|
||||
{
|
||||
$contentFile = $this->contentFile($versionId, $language);
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
if (F::unlink($contentFile) !== true) {
|
||||
throw new Exception(message: 'Could not delete content file');
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
$contentDirectory = $this->contentDirectory($versionId);
|
||||
|
||||
// clean up empty content directories (_changes or the page/user directory)
|
||||
$this->deleteEmptyDirectory($contentDirectory);
|
||||
|
||||
// delete empty _drafts directories for pages
|
||||
if (
|
||||
$versionId->is('latest') === true &&
|
||||
$this->model instanceof Page &&
|
||||
$this->model->isDraft() === true
|
||||
) {
|
||||
$this->deleteEmptyDirectory(dirname($contentDirectory));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to delete empty _changes directories
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception if the directory cannot be deleted
|
||||
*/
|
||||
protected function deleteEmptyDirectory(string $directory): void
|
||||
{
|
||||
if (
|
||||
Dir::exists($directory) === true &&
|
||||
Dir::isEmpty($directory) === true
|
||||
) {
|
||||
// @codeCoverageIgnoreStart
|
||||
if (Dir::remove($directory) !== true) {
|
||||
throw new Exception(
|
||||
message: 'Could not delete empty content directory'
|
||||
);
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version exists
|
||||
*/
|
||||
public function exists(VersionId $versionId, Language $language): bool
|
||||
{
|
||||
$contentFile = $this->contentFile($versionId, $language);
|
||||
|
||||
// The version definitely exists, if there's a
|
||||
// matching content file
|
||||
if (file_exists($contentFile) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A changed version or non-default language version does not exist
|
||||
// if the content file was not found
|
||||
if (
|
||||
$versionId->is('latest') === false ||
|
||||
$language->isDefault() === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether the default version exists,
|
||||
// depends on different cases for each model.
|
||||
// Page, Site and User exist as soon as the folder is there.
|
||||
// A File exists as soon as the file is there.
|
||||
return match (true) {
|
||||
$this->model instanceof File => is_file($this->model->root()) === true,
|
||||
$this->model instanceof Page,
|
||||
$this->model instanceof Site,
|
||||
$this->model instanceof User => is_dir($this->model->root()) === true,
|
||||
// @codeCoverageIgnoreStart
|
||||
default => throw new LogicException(
|
||||
message: 'Cannot determine existence for model type "' . $this->model::CLASS_ALIAS . '"'
|
||||
)
|
||||
// @codeCoverageIgnoreEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version-language-storage combinations
|
||||
*/
|
||||
public function isSameStorageLocation(
|
||||
VersionId $fromVersionId,
|
||||
Language $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
) {
|
||||
// fallbacks to allow keeping the method call lean
|
||||
$toVersionId ??= $fromVersionId;
|
||||
$toLanguage ??= $fromLanguage;
|
||||
$toStorage ??= $this;
|
||||
|
||||
// no need to compare content files if the new
|
||||
// storage type is different
|
||||
if ($toStorage instanceof self === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$contentFileA = $this->contentFile($fromVersionId, $fromLanguage);
|
||||
$contentFileB = $toStorage->contentFile($toVersionId, $toLanguage);
|
||||
|
||||
return $contentFileA === $contentFileB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modification timestamp of a version
|
||||
* if it exists
|
||||
*/
|
||||
public function modified(VersionId $versionId, Language $language): int|null
|
||||
{
|
||||
$modified = F::modified($this->contentFile($versionId, $language));
|
||||
|
||||
if (is_int($modified) === true) {
|
||||
return $modified;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored content fields
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function read(VersionId $versionId, Language $language): array
|
||||
{
|
||||
$contentFile = $this->contentFile($versionId, $language);
|
||||
|
||||
if (file_exists($contentFile) === true) {
|
||||
return Data::read($contentFile);
|
||||
}
|
||||
|
||||
// For existing versions that don't have a content file yet,
|
||||
// we can safely return an empty array that can be filled later.
|
||||
// This might be the case for pages that only have a directory
|
||||
// so far, or for files that don't have any metadata yet.
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modification timestamp of an existing version
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If the file cannot be touched
|
||||
*/
|
||||
public function touch(VersionId $versionId, Language $language): void
|
||||
{
|
||||
$success = touch($this->contentFile($versionId, $language));
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
if ($success !== true) {
|
||||
throw new Exception(
|
||||
message: 'Could not touch existing content file'
|
||||
);
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the content fields of an existing version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If the content cannot be written
|
||||
*/
|
||||
protected function write(VersionId $versionId, Language $language, array $fields): void
|
||||
{
|
||||
// only store non-null value fields
|
||||
$fields = array_filter($fields, fn ($field) => $field !== null);
|
||||
|
||||
// Content for files is only stored when there are any fields.
|
||||
// Otherwise, the storage handler will take care here of cleaning up
|
||||
// unnecessary content files.
|
||||
if ($this->model instanceof File && $fields === []) {
|
||||
$this->delete($versionId, $language);
|
||||
return;
|
||||
}
|
||||
|
||||
$success = Data::write($this->contentFile($versionId, $language), $fields);
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
if ($success !== true) {
|
||||
throw new Exception(message: 'Could not write the content file');
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
}
|
||||
325
kirby/src/Content/Storage.php
Normal file
325
kirby/src/Content/Storage.php
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Generator;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Languages;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* Abstract for content storage handlers;
|
||||
* note that it is so far not viable to build custom
|
||||
* handlers because the CMS core relies on the filesystem
|
||||
* and cannot fully benefit from this abstraction yet
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 4.0.0
|
||||
* @unstable
|
||||
*/
|
||||
abstract class Storage
|
||||
{
|
||||
public function __construct(protected ModelWithContent $model)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns generator for all existing version-language combinations
|
||||
*
|
||||
* @return Generator<\Kirby\Content\VersionId, \Kirby\Cms\Language>
|
||||
*/
|
||||
public function all(): Generator
|
||||
{
|
||||
foreach (Languages::ensure() as $language) {
|
||||
foreach ($this->model->versions() as $version) {
|
||||
if ($this->exists($version->id(), $language) === true) {
|
||||
yield $version->id() => $language;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies content from one version-language combination to another
|
||||
*/
|
||||
public function copy(
|
||||
VersionId $fromVersionId,
|
||||
Language $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
): void {
|
||||
// fallbacks to allow keeping the method call lean
|
||||
$toVersionId ??= $fromVersionId;
|
||||
$toLanguage ??= $fromLanguage;
|
||||
$toStorage ??= $this;
|
||||
|
||||
// don't copy content to the same version-language-storage combination
|
||||
if ($this->isSameStorageLocation(
|
||||
fromVersionId: $fromVersionId,
|
||||
fromLanguage: $fromLanguage,
|
||||
toVersionId: $toVersionId,
|
||||
toLanguage: $toLanguage,
|
||||
toStorage: $toStorage
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// read the existing fields
|
||||
$content = $this->read($fromVersionId, $fromLanguage);
|
||||
|
||||
// create the new version
|
||||
$toStorage->create($toVersionId, $toLanguage, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies all content to another storage
|
||||
*/
|
||||
public function copyAll(Storage $to): void
|
||||
{
|
||||
foreach ($this->all() as $versionId => $language) {
|
||||
$this->copy($versionId, $language, toStorage: $to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*/
|
||||
public function create(VersionId $versionId, Language $language, array $fields): void
|
||||
{
|
||||
$this->write($versionId, $language, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing version in an idempotent way if it was already deleted
|
||||
*/
|
||||
abstract public function delete(VersionId $versionId, Language $language): void;
|
||||
|
||||
/**
|
||||
* Deletes all versions when deleting a language
|
||||
* @unstable
|
||||
* @todo Move to `Language` class
|
||||
*/
|
||||
public function deleteLanguage(Language $language): void
|
||||
{
|
||||
foreach ($this->model->versions() as $version) {
|
||||
$this->delete($version->id(), $language);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version exists
|
||||
*/
|
||||
abstract public function exists(VersionId $versionId, Language $language): bool;
|
||||
|
||||
/**
|
||||
* Creates a new storage instance with all the versions
|
||||
* from the given storage instance.
|
||||
*/
|
||||
public static function from(self $fromStorage): static
|
||||
{
|
||||
$toStorage = new static(
|
||||
model: $fromStorage->model()
|
||||
);
|
||||
|
||||
// copy all versions from the given storage instance
|
||||
// and add them to the new storage instance.
|
||||
$fromStorage->copyAll($toStorage);
|
||||
|
||||
return $toStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version-language-storage combinations
|
||||
*/
|
||||
public function isSameStorageLocation(
|
||||
VersionId $fromVersionId,
|
||||
Language $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
) {
|
||||
// fallbacks to allow keeping the method call lean
|
||||
$toVersionId ??= $fromVersionId;
|
||||
$toLanguage ??= $fromLanguage;
|
||||
$toStorage ??= $this;
|
||||
|
||||
if (
|
||||
$fromVersionId->is($toVersionId) &&
|
||||
$fromLanguage->is($toLanguage) &&
|
||||
$this === $toStorage
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the related model
|
||||
*/
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modification timestamp of a version if it exists
|
||||
*/
|
||||
abstract public function modified(VersionId $versionId, Language $language): int|null;
|
||||
|
||||
/**
|
||||
* Moves content from one version-language combination to another
|
||||
*/
|
||||
public function move(
|
||||
VersionId $fromVersionId,
|
||||
Language $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
): void {
|
||||
// fallbacks to allow keeping the method call lean
|
||||
$toVersionId ??= $fromVersionId;
|
||||
$toLanguage ??= $fromLanguage;
|
||||
$toStorage ??= $this;
|
||||
|
||||
// don't move content to the same version-language-storage combination
|
||||
if ($this->isSameStorageLocation(
|
||||
fromVersionId: $fromVersionId,
|
||||
fromLanguage: $fromLanguage,
|
||||
toVersionId: $toVersionId,
|
||||
toLanguage: $toLanguage,
|
||||
toStorage: $toStorage
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// copy content to new version
|
||||
$this->copy(
|
||||
$fromVersionId,
|
||||
$fromLanguage,
|
||||
$toVersionId,
|
||||
$toLanguage,
|
||||
$toStorage
|
||||
);
|
||||
|
||||
// clean up the old version
|
||||
$this->delete($fromVersionId, $fromLanguage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves all content to another storage
|
||||
*/
|
||||
public function moveAll(Storage $to): void
|
||||
{
|
||||
foreach ($this->all() as $versionId => $language) {
|
||||
$this->move($versionId, $language, toStorage: $to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts all versions when converting languages
|
||||
* @unstable
|
||||
* @todo Move to `Language` class
|
||||
*/
|
||||
public function moveLanguage(
|
||||
Language $fromLanguage,
|
||||
Language $toLanguage
|
||||
): void {
|
||||
foreach ($this->model->versions() as $version) {
|
||||
if ($this->exists($version->id(), $fromLanguage) === true) {
|
||||
$this->move(
|
||||
$version->id(),
|
||||
$fromLanguage,
|
||||
toLanguage: $toLanguage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored content fields
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
abstract public function read(VersionId $versionId, Language $language): array;
|
||||
|
||||
/**
|
||||
* Searches and replaces one or multiple strings
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function replaceStrings(
|
||||
VersionId $versionId,
|
||||
Language $language,
|
||||
array $map
|
||||
): void {
|
||||
$fields = $this->read($versionId, $language);
|
||||
$fields = A::map(
|
||||
$fields,
|
||||
function ($value) use ($map) {
|
||||
// skip fields with null values
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
array_keys($map),
|
||||
array_values($map),
|
||||
$value
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
$this->update($versionId, $language, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modification timestamp of an existing version
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
abstract public function touch(VersionId $versionId, Language $language): void;
|
||||
|
||||
/**
|
||||
* Touches all versions of a language
|
||||
* @unstable
|
||||
* @todo Move to `Language` class
|
||||
*/
|
||||
public function touchLanguage(Language $language): void
|
||||
{
|
||||
foreach ($this->model->versions() as $version) {
|
||||
if ($this->exists($version->id(), $language) === true) {
|
||||
$this->touch($version->id(), $language);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content fields of an existing version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If the file cannot be written
|
||||
*/
|
||||
public function update(VersionId $versionId, Language $language, array $fields): void
|
||||
{
|
||||
$this->write($versionId, $language, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the content fields of an existing version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If the content cannot be written
|
||||
*/
|
||||
abstract protected function write(VersionId $versionId, Language $language, array $fields): void;
|
||||
}
|
||||
191
kirby/src/Content/Translation.php
Normal file
191
kirby/src/Content/Translation.php
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Helpers;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Exception\Exception;
|
||||
|
||||
/**
|
||||
* Each page, file or site can have multiple
|
||||
* translated versions of their content,
|
||||
* represented by this class
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Translation
|
||||
{
|
||||
/**
|
||||
* Creates a new translation object
|
||||
*/
|
||||
public function __construct(
|
||||
protected ModelWithContent $model,
|
||||
protected Version $version,
|
||||
protected Language $language
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Improve `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language code of the
|
||||
* translation
|
||||
*/
|
||||
public function code(): string
|
||||
{
|
||||
return $this->language->code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translation content
|
||||
* as plain array
|
||||
*/
|
||||
public function content(): array
|
||||
{
|
||||
return $this->version->content($this->language)->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path to the translation content file
|
||||
*
|
||||
* @deprecated 5.0.0
|
||||
*/
|
||||
public function contentFile(): string
|
||||
{
|
||||
Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods');
|
||||
return $this->version->contentFile($this->language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Translation for the given model
|
||||
*
|
||||
* @todo Needs to be refactored as soon as Version::create becomes static
|
||||
* (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
|
||||
*/
|
||||
public static function create(
|
||||
ModelWithContent $model,
|
||||
Version $version,
|
||||
Language $language,
|
||||
array $fields,
|
||||
string|null $slug = null
|
||||
): static {
|
||||
// add the custom slug to the fields array
|
||||
if ($slug !== null) {
|
||||
$fields['slug'] = $slug;
|
||||
}
|
||||
|
||||
$version->save($fields, $language);
|
||||
|
||||
return new static(
|
||||
model: $model,
|
||||
version: $version,
|
||||
language: $language,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the translation file exists
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->version->exists($this->language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translation code as id
|
||||
*/
|
||||
public function id(): string
|
||||
{
|
||||
return $this->language->code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the this is the default translation
|
||||
* of the model
|
||||
*
|
||||
* @deprecated 5.0.0 Use `::language()->isDefault()` instead
|
||||
*/
|
||||
public function isDefault(): bool
|
||||
{
|
||||
Helpers::deprecated('`$translation->isDefault()` has been deprecated. Use `$translation->language()->isDefault()` instead.', 'translation-methods');
|
||||
return $this->language->isDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the language
|
||||
*/
|
||||
public function language(): Language
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent page, file or site object
|
||||
*/
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.0.0 Use `$translation->model()` instead
|
||||
*/
|
||||
public function parent(): ModelWithContent
|
||||
{
|
||||
throw new Exception(
|
||||
message: '`$translation->parent()` has been deprecated. Please use `$translation->model()` instead'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom translation slug
|
||||
*/
|
||||
public function slug(): string|null
|
||||
{
|
||||
return $this->version->read($this->language)['slug'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the most important translation
|
||||
* props to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'code' => $this->language->code(),
|
||||
'content' => $this->content(),
|
||||
'exists' => $this->exists(),
|
||||
'slug' => $this->slug(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 5.0.0 Use `$model->version()->update()` instead
|
||||
*/
|
||||
public function update(array|null $data = null, bool $overwrite = false): static
|
||||
{
|
||||
throw new Exception(
|
||||
message: '`$translation->update()` has been deprecated. Please use `$model->version()->update()` instead'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the version
|
||||
*/
|
||||
public function version(): Version
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
}
|
||||
79
kirby/src/Content/Translations.php
Normal file
79
kirby/src/Content/Translations.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Collection;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Languages;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
|
||||
/**
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Collection<\Kirby\Content\Translation>
|
||||
*/
|
||||
class Translations extends Collection
|
||||
{
|
||||
/**
|
||||
* Creates a new Translations collection from
|
||||
* an array of translations properties. This is
|
||||
* used in ModelWithContent::setTranslations to properly
|
||||
* normalize an array definition.
|
||||
*
|
||||
* @todo Needs to be refactored as soon as Version::create becomes static
|
||||
* (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
|
||||
*/
|
||||
public static function create(
|
||||
ModelWithContent $model,
|
||||
Version $version,
|
||||
array $translations
|
||||
): static {
|
||||
foreach ($translations as $translation) {
|
||||
Translation::create(
|
||||
model: $model,
|
||||
version: $version,
|
||||
language: Language::ensure($translation['code'] ?? 'default'),
|
||||
fields: $translation['content'] ?? [],
|
||||
slug: $translation['slug'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return static::load(
|
||||
model: $model,
|
||||
version: $version
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplifies `Translations::find` by allowing to pass
|
||||
* Language codes that will be properly validated here.
|
||||
*/
|
||||
public function findByKey(string $key): Translation|null
|
||||
{
|
||||
return parent::get(Language::ensure($key)->code());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all available translations for a given model
|
||||
*/
|
||||
public static function load(
|
||||
ModelWithContent $model,
|
||||
Version $version
|
||||
): static {
|
||||
$translations = [];
|
||||
|
||||
foreach (Languages::ensure() as $language) {
|
||||
$translations[] = new Translation(
|
||||
model: $model,
|
||||
version: $version,
|
||||
language: $language
|
||||
);
|
||||
}
|
||||
|
||||
return new static($translations);
|
||||
}
|
||||
}
|
||||
687
kirby/src/Content/Version.php
Normal file
687
kirby/src/Content/Version.php
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Languages;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Http\Uri;
|
||||
|
||||
/**
|
||||
* The Version class handles all actions for a single
|
||||
* version and is identified by a VersionId instance
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class Version
|
||||
{
|
||||
public function __construct(
|
||||
protected ModelWithContent $model,
|
||||
protected VersionId $id
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Content object for the given language
|
||||
*/
|
||||
public function content(Language|string $language = 'default'): Content
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
$fields = $this->read($language) ?? [];
|
||||
|
||||
// This is where we merge content from the default language
|
||||
// to provide a fallback for missing/untranslated fields.
|
||||
//
|
||||
// @todo This is the critical point that needs to be removed/refactored
|
||||
// in the future, to provide multi-language support with truly
|
||||
// individual versions of pages and no longer enforce the fallback.
|
||||
if ($language->isDefault() === false) {
|
||||
// merge the fields with the default language
|
||||
$fields = [
|
||||
...$this->read('default') ?? [],
|
||||
...$fields
|
||||
];
|
||||
}
|
||||
|
||||
// remove fields that should not be used for the Content object
|
||||
unset($fields['lock']);
|
||||
|
||||
return new Content(
|
||||
parent: $this->model,
|
||||
data: $fields,
|
||||
normalize: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides simplified access to the absolute content file path.
|
||||
* This should stay an internal method and be removed as soon as
|
||||
* the dependency on file storage methods is resolved more clearly.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public function contentFile(Language|string $language = 'default'): string
|
||||
{
|
||||
return $this->model->storage()->contentFile(
|
||||
$this->id,
|
||||
Language::ensure($language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that all field names are converted to lower
|
||||
* case to be able to merge and filter them properly
|
||||
*/
|
||||
protected function convertFieldNamesToLowerCase(array $fields): array
|
||||
{
|
||||
return array_change_key_case($fields, CASE_LOWER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new version for the given language
|
||||
* @todo Convert to a static method that creates the version initially with all relevant languages
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*/
|
||||
public function create(
|
||||
array $fields,
|
||||
Language|string $language = 'default'
|
||||
): void {
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if creating is allowed
|
||||
VersionRules::create($this, $fields, $language);
|
||||
|
||||
// track the changes
|
||||
if ($this->id->is('changes') === true) {
|
||||
(new Changes())->track($this->model);
|
||||
}
|
||||
|
||||
$this->model->storage()->create(
|
||||
versionId: $this->id,
|
||||
language: $language,
|
||||
fields: $this->prepareFieldsBeforeWrite($fields, $language)
|
||||
);
|
||||
|
||||
// make sure that an older version does not exist in the cache
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a version for a specific language
|
||||
*/
|
||||
public function delete(Language|string $language = 'default'): void
|
||||
{
|
||||
if ($language === '*') {
|
||||
foreach (Languages::ensure() as $language) {
|
||||
$this->delete($language);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if deleting is allowed
|
||||
VersionRules::delete($this, $language);
|
||||
|
||||
$this->model->storage()->delete($this->id, $language);
|
||||
|
||||
// untrack the changes if the version does no longer exist
|
||||
// in any of the available languages
|
||||
if (
|
||||
$this->id->is('changes') === true &&
|
||||
$this->exists('*') === false
|
||||
) {
|
||||
(new Changes())->untrack($this->model);
|
||||
}
|
||||
|
||||
// Remove the version from the cache
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all validation errors for the given language
|
||||
*/
|
||||
public function errors(Language|string $language = 'default'): array
|
||||
{
|
||||
$fields = Fields::for($this->model, $language);
|
||||
$fields->fill(
|
||||
input: $this->content($language)->toArray()
|
||||
);
|
||||
|
||||
return $fields->errors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version exists for the given language
|
||||
*/
|
||||
public function exists(Language|string $language = 'default'): bool
|
||||
{
|
||||
// go through all possible languages to check if this
|
||||
// version exists in any language
|
||||
if ($language === '*') {
|
||||
foreach (Languages::ensure() as $language) {
|
||||
if ($this->exists($language) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->model->storage()->exists(
|
||||
$this->id,
|
||||
Language::ensure($language)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the VersionId instance for this version
|
||||
*/
|
||||
public function id(): VersionId
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the content of both versions
|
||||
* is identical
|
||||
*/
|
||||
public function isIdentical(
|
||||
Version|VersionId|string $version,
|
||||
Language|string $language = 'default'
|
||||
): bool {
|
||||
if (is_string($version) === true) {
|
||||
$version = VersionId::from($version);
|
||||
}
|
||||
|
||||
if ($version instanceof VersionId) {
|
||||
$version = $this->sibling($version);
|
||||
}
|
||||
|
||||
if ($version->id()->is($this->id) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$language = Language::ensure($language);
|
||||
$fields = Fields::for($this->model, $language);
|
||||
|
||||
// read fields low-level from storage
|
||||
$a = $this->read($language) ?? [];
|
||||
$b = $version->read($language) ?? [];
|
||||
|
||||
// remove fields that should not be
|
||||
// considered in the comparison
|
||||
unset(
|
||||
$a['lock'],
|
||||
$b['lock'],
|
||||
$a['uuid'],
|
||||
$b['uuid']
|
||||
);
|
||||
|
||||
$a = $fields->reset()->fill(input: $a)->toFormValues();
|
||||
$b = $fields->reset()->fill(input: $b)->toFormValues();
|
||||
|
||||
ksort($a);
|
||||
ksort($b);
|
||||
|
||||
return $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the version is the latest version
|
||||
*/
|
||||
public function isLatest(): bool
|
||||
{
|
||||
return $this->id->is('latest');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the version is locked for the current user
|
||||
*/
|
||||
public function isLocked(Language|string $language = 'default'): bool
|
||||
{
|
||||
return $this->lock($language)->isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are any validation errors for the given language
|
||||
*/
|
||||
public function isValid(Language|string $language = 'default'): bool
|
||||
{
|
||||
return $this->errors($language) === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lock object for the version
|
||||
*/
|
||||
public function lock(Language|string $language = 'default'): Lock
|
||||
{
|
||||
return Lock::for($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parent model
|
||||
*/
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modification timestamp of a version
|
||||
* if it exists
|
||||
*/
|
||||
public function modified(
|
||||
Language|string $language = 'default'
|
||||
): int|null {
|
||||
if ($this->exists($language) === true) {
|
||||
return $this->model->storage()->modified(
|
||||
$this->id,
|
||||
Language::ensure($language)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the version to a new language and/or version
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function move(
|
||||
Language|string $fromLanguage,
|
||||
VersionId|null $toVersionId = null,
|
||||
Language|string|null $toLanguage = null,
|
||||
Storage|null $toStorage = null
|
||||
): void {
|
||||
$fromVersion = $this;
|
||||
$fromLanguage = Language::ensure($fromLanguage);
|
||||
$toLanguage = Language::ensure($toLanguage ?? $fromLanguage);
|
||||
$toVersion = $this->sibling($toVersionId ?? $this->id);
|
||||
|
||||
// check if moving is allowed
|
||||
VersionRules::move(
|
||||
fromVersion: $fromVersion,
|
||||
fromLanguage: $fromLanguage,
|
||||
toVersion: $toVersion,
|
||||
toLanguage: $toLanguage
|
||||
);
|
||||
|
||||
$this->model->storage()->move(
|
||||
fromVersionId: $fromVersion->id(),
|
||||
fromLanguage: $fromLanguage,
|
||||
toVersionId: $toVersion->id(),
|
||||
toLanguage: $toLanguage,
|
||||
toStorage: $toStorage
|
||||
);
|
||||
|
||||
// remove both versions from the cache
|
||||
VersionCache::remove($fromVersion, $fromLanguage);
|
||||
VersionCache::remove($toVersion, $toLanguage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare fields to be written by removing unwanted fields
|
||||
* depending on the language or model and by cleaning the field names
|
||||
*/
|
||||
protected function prepareFieldsBeforeWrite(
|
||||
array $fields,
|
||||
Language $language
|
||||
): array {
|
||||
// convert all field names to lower case
|
||||
$fields = $this->convertFieldNamesToLowerCase($fields);
|
||||
|
||||
// make sure to store the right fields for the model
|
||||
$fields = $this->model->contentFileData($fields, $language);
|
||||
|
||||
// add the editing user
|
||||
if (
|
||||
Lock::isEnabled() === true &&
|
||||
$this->id->is('changes') === true
|
||||
) {
|
||||
$fields['lock'] = $this->model->kirby()->user()?->id();
|
||||
|
||||
// remove the lock field for any other version or
|
||||
// if locking is disabled
|
||||
} else {
|
||||
unset($fields['lock']);
|
||||
}
|
||||
|
||||
// the default language stores all fields
|
||||
if ($language->isDefault() === true) {
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// remove all untranslatable fields
|
||||
foreach ($this->model->blueprint()->fields() as $field) {
|
||||
if (($field['translate'] ?? true) === false) {
|
||||
unset($fields[strtolower($field['name'])]);
|
||||
}
|
||||
}
|
||||
|
||||
// remove UUID for non-default languages
|
||||
unset($fields['uuid']);
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that reading from storage will always
|
||||
* return a usable set of fields with clean field names
|
||||
*/
|
||||
protected function prepareFieldsAfterRead(array $fields, Language $language): array
|
||||
{
|
||||
$fields = $this->convertFieldNamesToLowerCase($fields);
|
||||
|
||||
// ignore all fields with null values
|
||||
return array_filter($fields, fn ($field) => $field !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a verification token for the authentication
|
||||
* of draft and version previews
|
||||
* @unstable
|
||||
*/
|
||||
public function previewToken(): string
|
||||
{
|
||||
if ($this->model instanceof Site) {
|
||||
// the site itself does not render; its preview is the home page
|
||||
$homePage = $this->model->homePage();
|
||||
|
||||
if ($homePage === null) {
|
||||
throw new NotFoundException('The home page does not exist');
|
||||
}
|
||||
|
||||
return $homePage->version($this->id)->previewToken();
|
||||
}
|
||||
|
||||
if (($this->model instanceof Page) === false) {
|
||||
throw new LogicException('Invalid model type');
|
||||
}
|
||||
|
||||
return $this->previewTokenFromUrl($this->model->url());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a verification token for the authentication
|
||||
* of draft and version previews from a raw URL
|
||||
*/
|
||||
protected function previewTokenFromUrl(string $url): string
|
||||
{
|
||||
// get rid of all modifiers after the path
|
||||
$uri = new Uri($url);
|
||||
$uri->fragment = null;
|
||||
$uri->params = null;
|
||||
$uri->query = null;
|
||||
|
||||
$data = [
|
||||
'url' => $uri->toString(),
|
||||
'versionId' => $this->id->value()
|
||||
];
|
||||
|
||||
$token = $this->model->kirby()->contentToken(
|
||||
null,
|
||||
json_encode($data, JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
return substr($token, 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method can only be applied to the "changes" version.
|
||||
* It will copy all fields over to the "latest" version and delete
|
||||
* this version afterwards.
|
||||
*/
|
||||
public function publish(Language|string $language = 'default'): void
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if publishing is allowed
|
||||
VersionRules::publish($this, $language);
|
||||
|
||||
$latest = $this->sibling('latest')->read($language) ?? [];
|
||||
$changes = $this->read($language) ?? [];
|
||||
|
||||
// overwrite all fields that are not in the `changes` version
|
||||
// with a null value. The ModelWithContent::update method will merge
|
||||
// the input with the existing content fields and setting null values
|
||||
// for removed fields will take care of not inheriting old values.
|
||||
foreach ($latest as $key => $value) {
|
||||
if (isset($changes[$key]) === false) {
|
||||
$changes[$key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// update the latest version
|
||||
$this->model = $this->model->update(
|
||||
input: $changes,
|
||||
languageCode: $language->code(),
|
||||
validate: true
|
||||
);
|
||||
|
||||
// delete the changes
|
||||
$this->delete($language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored content fields
|
||||
*
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
public function read(Language|string $language = 'default'): array|null
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
|
||||
try {
|
||||
// make sure that the version exists
|
||||
VersionRules::read($this, $language);
|
||||
|
||||
$fields = VersionCache::get($this, $language);
|
||||
|
||||
if ($fields === null) {
|
||||
$fields = $this->model->storage()->read($this->id, $language);
|
||||
$fields = $this->prepareFieldsAfterRead($fields, $language);
|
||||
|
||||
if ($fields !== null) {
|
||||
VersionCache::set($this, $language, $fields);
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
} catch (NotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the content of the current version with the given fields
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function replace(
|
||||
array $fields,
|
||||
Language|string $language = 'default'
|
||||
): void {
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if replacing is allowed
|
||||
VersionRules::replace($this, $fields, $language);
|
||||
|
||||
$this->model->storage()->update(
|
||||
versionId: $this->id,
|
||||
language: $language,
|
||||
fields: $this->prepareFieldsBeforeWrite($fields, $language)
|
||||
);
|
||||
|
||||
// remove the version from the cache to read
|
||||
// a fresh version next time
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around ::create, ::replace and ::update.
|
||||
*/
|
||||
public function save(
|
||||
array $fields,
|
||||
Language|string $language = 'default',
|
||||
bool $overwrite = false
|
||||
): void {
|
||||
if ($this->exists($language) === false) {
|
||||
$this->create($fields, $language);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($overwrite === true) {
|
||||
$this->replace($fields, $language);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update($fields, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sibling version for the same model
|
||||
*/
|
||||
public function sibling(VersionId|string $id): Version
|
||||
{
|
||||
return new Version(
|
||||
model: $this->model,
|
||||
id: VersionId::from($id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the modification timestamp of an existing version
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function touch(Language|string $language = 'default'): void
|
||||
{
|
||||
$language = Language::ensure($language);
|
||||
|
||||
VersionRules::touch($this, $language);
|
||||
|
||||
$this->model->storage()->touch($this->id, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content fields of an existing version
|
||||
*
|
||||
* @param array<string, string> $fields Content fields
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public function update(
|
||||
array $fields,
|
||||
Language|string $language = 'default'
|
||||
): void {
|
||||
$language = Language::ensure($language);
|
||||
|
||||
// check if updating is allowed
|
||||
VersionRules::update($this, $fields, $language);
|
||||
|
||||
// merge the previous state with the new state to always
|
||||
// update to a complete version
|
||||
$fields = [
|
||||
...$this->read($language),
|
||||
...$fields
|
||||
];
|
||||
|
||||
$this->model->storage()->update(
|
||||
versionId: $this->id,
|
||||
language: $language,
|
||||
fields: $this->prepareFieldsBeforeWrite($fields, $language)
|
||||
);
|
||||
|
||||
// remove the version from the cache to read
|
||||
// a fresh version next time
|
||||
VersionCache::remove($this, $language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview URL with authentication for drafts and versions
|
||||
* @unstable
|
||||
*/
|
||||
public function url(): string|null
|
||||
{
|
||||
if (
|
||||
($this->model instanceof Page || $this->model instanceof Site) === false
|
||||
) {
|
||||
throw new LogicException('Only pages and the site have a content preview URL');
|
||||
}
|
||||
|
||||
$url = $this->model->blueprint()->preview();
|
||||
|
||||
// preview was disabled
|
||||
if ($url === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// we only need to add a token for draft and changes previews
|
||||
if (
|
||||
($this->model instanceof Site || $this->model->isDraft() === false) &&
|
||||
$this->id->is('changes') === false
|
||||
) {
|
||||
return match (true) {
|
||||
is_string($url) => $url,
|
||||
default => $this->model->url()
|
||||
};
|
||||
}
|
||||
|
||||
// check if the URL was customized
|
||||
if (is_string($url) === true) {
|
||||
return $this->urlFromOption($url);
|
||||
}
|
||||
|
||||
// it wasn't, use the safer/more reliable model-based preview token
|
||||
return $this->urlWithQueryParams($this->model->url(), $this->previewToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the preview URL based on an arbitrary URL from
|
||||
* the blueprint option
|
||||
*/
|
||||
protected function urlFromOption(string $url): string
|
||||
{
|
||||
// try to determine a token for a local preview
|
||||
// (we cannot determine the token for external previews)
|
||||
if ($token = $this->previewTokenFromUrl($url)) {
|
||||
return $this->urlWithQueryParams($url, $token);
|
||||
}
|
||||
|
||||
// fall back to the URL as defined in the blueprint
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the preview URL with the added `_token` and `_version`
|
||||
* query params, no matter if the base URL already contains query params
|
||||
*/
|
||||
protected function urlWithQueryParams(string $baseUrl, string $token): string
|
||||
{
|
||||
$uri = new Uri($baseUrl);
|
||||
$uri->query->_token = $token;
|
||||
|
||||
if ($this->id->is('changes') === true) {
|
||||
$uri->query->_version = 'changes';
|
||||
}
|
||||
|
||||
return $uri->toString();
|
||||
}
|
||||
}
|
||||
81
kirby/src/Content/VersionCache.php
Normal file
81
kirby/src/Content/VersionCache.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use WeakMap;
|
||||
|
||||
/**
|
||||
* The Version cache class keeps content fields
|
||||
* to avoid multiple storage reads for the same
|
||||
* content.
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class VersionCache
|
||||
{
|
||||
/**
|
||||
* All cache values for all versions
|
||||
* and language combinations
|
||||
*/
|
||||
protected static WeakMap $cache;
|
||||
|
||||
/**
|
||||
* Tries to receive a fields for a version/language combination
|
||||
*/
|
||||
public static function get(Version $version, Language $language): array|null
|
||||
{
|
||||
$model = $version->model();
|
||||
$key = $version->id() . ':' . $language->code();
|
||||
|
||||
return static::$cache[$model][$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes fields for a version/language combination
|
||||
*/
|
||||
public static function remove(Version $version, Language $language): void
|
||||
{
|
||||
$model = $version->model();
|
||||
|
||||
if (isset(static::$cache[$model]) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid indirect manipulation of WeakMap
|
||||
$key = $version->id() . ':' . $language->code();
|
||||
$map = static::$cache[$model];
|
||||
unset($map[$key]);
|
||||
static::$cache[$model] = $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cache
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
static::$cache = new WeakMap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps fields for a version/language combination
|
||||
*/
|
||||
public static function set(
|
||||
Version $version,
|
||||
Language $language,
|
||||
array $fields = []
|
||||
): void {
|
||||
$model = $version->model();
|
||||
$key = $version->id() . ':' . $language->code();
|
||||
|
||||
static::$cache ??= new WeakMap();
|
||||
static::$cache[$model] ??= [];
|
||||
static::$cache[$model][$key] = $fields;
|
||||
}
|
||||
}
|
||||
121
kirby/src/Content/VersionId.php
Normal file
121
kirby/src/Content/VersionId.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* The Version ID identifies a version of content.
|
||||
* This can be the currently latest version or changes
|
||||
* to the content. In the future, we also plan to use this
|
||||
* for older revisions of the content.
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class VersionId implements Stringable
|
||||
{
|
||||
/**
|
||||
* Latest stable version of the content
|
||||
*/
|
||||
public const LATEST = 'latest';
|
||||
|
||||
/**
|
||||
* Latest changes to the content (optional)
|
||||
*/
|
||||
public const CHANGES = 'changes';
|
||||
|
||||
/**
|
||||
* A global store for a version id that should be
|
||||
* rendered for each model in a live preview scenario.
|
||||
*/
|
||||
public static self|null $render = null;
|
||||
|
||||
/**
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the version ID is not valid
|
||||
*/
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
if (in_array($value, [static::CHANGES, static::LATEST], true) === false) {
|
||||
throw new InvalidArgumentException(message: 'Invalid Version ID');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the VersionId instance to a simple string value
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VersionId instance for the latest content changes
|
||||
*/
|
||||
public static function changes(): static
|
||||
{
|
||||
return new static(static::CHANGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VersionId instance from a simple string value
|
||||
*/
|
||||
public static function from(VersionId|string $value): static
|
||||
{
|
||||
if ($value instanceof VersionId) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return new static($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares a VersionId object or string value with this id
|
||||
*/
|
||||
public function is(VersionId|string $id): bool
|
||||
{
|
||||
return static::from($id)->value === $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a VersionId instance for the latest stable version of the content
|
||||
*/
|
||||
public static function latest(): static
|
||||
{
|
||||
return new static(static::LATEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily sets the version ID for preview rendering
|
||||
* only for the logic in the callback
|
||||
*/
|
||||
public static function render(VersionId|string $versionId, Closure $callback): mixed
|
||||
{
|
||||
$original = static::$render;
|
||||
static::$render = static::from($versionId);
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
// ensure that the render version ID is *always* reset
|
||||
// to the original value, even if an error occurred
|
||||
static::$render = $original;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID value
|
||||
*/
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
161
kirby/src/Content/VersionRules.php
Normal file
161
kirby/src/Content/VersionRules.php
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
|
||||
/**
|
||||
* The VersionRules class handles the validation for all
|
||||
* modification actions on a single version
|
||||
*
|
||||
* @package Kirby Content
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class VersionRules
|
||||
{
|
||||
public static function create(
|
||||
Version $version,
|
||||
array $fields,
|
||||
Language $language
|
||||
): void {
|
||||
if ($version->exists($language) === true) {
|
||||
throw new LogicException(
|
||||
message: 'The version already exists'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a version/language combination exists and otherwise
|
||||
* will throw a `NotFoundException`
|
||||
*
|
||||
* @throws \Kirby\Exception\NotFoundException If the version does not exist
|
||||
*/
|
||||
public static function ensure(Version $version, Language $language): void
|
||||
{
|
||||
if ($version->exists($language) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = match($version->model()->kirby()->multilang()) {
|
||||
true => 'Version "' . $version->id() . ' (' . $language->code() . ')" does not already exist',
|
||||
false => 'Version "' . $version->id() . '" does not already exist',
|
||||
};
|
||||
|
||||
throw new NotFoundException($message);
|
||||
}
|
||||
|
||||
public static function delete(
|
||||
Version $version,
|
||||
Language $language
|
||||
): void {
|
||||
if ($version->isLocked('*') === true) {
|
||||
throw new LockedContentException(
|
||||
lock: $version->lock('*'),
|
||||
key: 'content.lock.delete'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function move(
|
||||
Version $fromVersion,
|
||||
Language $fromLanguage,
|
||||
Version $toVersion,
|
||||
Language $toLanguage
|
||||
): void {
|
||||
// make sure that the source version exists
|
||||
static::ensure($fromVersion, $fromLanguage);
|
||||
|
||||
// check if the source version is locked in any language
|
||||
if ($fromVersion->isLocked('*') === true) {
|
||||
throw new LockedContentException(
|
||||
lock: $fromVersion->lock('*'),
|
||||
key: 'content.lock.move'
|
||||
);
|
||||
}
|
||||
|
||||
// check if the target version is locked in any language
|
||||
if ($toVersion->isLocked('*') === true) {
|
||||
throw new LockedContentException(
|
||||
lock: $toVersion->lock('*'),
|
||||
key: 'content.lock.update'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function publish(
|
||||
Version $version,
|
||||
Language $language
|
||||
): void {
|
||||
// the latest version is already published
|
||||
if ($version->isLatest() === true) {
|
||||
throw new LogicException(
|
||||
message: 'This version is already published'
|
||||
);
|
||||
}
|
||||
|
||||
// make sure that the version exists
|
||||
static::ensure($version, $language);
|
||||
|
||||
// check if the version is locked in any language
|
||||
if ($version->isLocked('*') === true) {
|
||||
throw new LockedContentException(
|
||||
lock: $version->lock('*'),
|
||||
key: 'content.lock.publish'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function read(
|
||||
Version $version,
|
||||
Language $language
|
||||
): void {
|
||||
static::ensure($version, $language);
|
||||
}
|
||||
|
||||
public static function replace(
|
||||
Version $version,
|
||||
array $fields,
|
||||
Language $language
|
||||
): void {
|
||||
// make sure that the version exists
|
||||
static::ensure($version, $language);
|
||||
|
||||
// check if the version is locked in any language
|
||||
if ($version->isLocked('*') === true) {
|
||||
throw new LockedContentException(
|
||||
lock: $version->lock('*'),
|
||||
key: 'content.lock.replace'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function touch(
|
||||
Version $version,
|
||||
Language $language
|
||||
): void {
|
||||
static::ensure($version, $language);
|
||||
}
|
||||
|
||||
public static function update(
|
||||
Version $version,
|
||||
array $fields,
|
||||
Language $language
|
||||
): void {
|
||||
static::ensure($version, $language);
|
||||
|
||||
if ($version->isLocked('*') === true) {
|
||||
throw new LockedContentException(
|
||||
lock: $version->lock('*'),
|
||||
key: 'content.lock.update'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
kirby/src/Content/Versions.php
Normal file
49
kirby/src/Content/Versions.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Content;
|
||||
|
||||
use Kirby\Cms\Collection;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
|
||||
/**
|
||||
* @package Kirby Content
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*
|
||||
* @extends \Kirby\Cms\Collection<\Kirby\Content\Version>
|
||||
*/
|
||||
class Versions extends Collection
|
||||
{
|
||||
/**
|
||||
* Deletes all versions in the collection
|
||||
*/
|
||||
public function delete(): void
|
||||
{
|
||||
foreach ($this->data as $version) {
|
||||
$version->delete('*');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all available versions for a given model
|
||||
*
|
||||
* Versions need to be loaded in the order `changes`, `latest`
|
||||
* to ensure that models are deleted correctly. The `latest`
|
||||
* version always needs to be deleted last, otherwise the
|
||||
* PlainTextStorage handler will not be able to clean up
|
||||
* content directories.
|
||||
*/
|
||||
public static function load(
|
||||
ModelWithContent $model
|
||||
): static {
|
||||
return new static(
|
||||
objects: [
|
||||
$model->version('changes'),
|
||||
$model->version('latest'),
|
||||
],
|
||||
parent: $model
|
||||
);
|
||||
}
|
||||
}
|
||||
211
kirby/src/Form/Field/EntriesField.php
Normal file
211
kirby/src/Form/Field/EntriesField.php
Normal 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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
19
kirby/src/Form/Mixin/Api.php
Normal file
19
kirby/src/Form/Mixin/Api.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
35
kirby/src/Form/Mixin/Model.php
Normal file
35
kirby/src/Form/Mixin/Model.php
Normal 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();
|
||||
}
|
||||
}
|
||||
47
kirby/src/Form/Mixin/Translatable.php
Normal file
47
kirby/src/Form/Mixin/Translatable.php
Normal 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;
|
||||
}
|
||||
}
|
||||
117
kirby/src/Form/Mixin/Validation.php
Normal file
117
kirby/src/Form/Mixin/Validation.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
220
kirby/src/Form/Mixin/Value.php
Normal file
220
kirby/src/Form/Mixin/Value.php
Normal 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();
|
||||
}
|
||||
}
|
||||
58
kirby/src/Form/Mixin/When.php
Normal file
58
kirby/src/Form/Mixin/When.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
kirby/src/Panel/Controller/PageTree.php
Normal file
113
kirby/src/Panel/Controller/PageTree.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
104
kirby/src/Panel/Controller/Search.php
Normal file
104
kirby/src/Panel/Controller/Search.php
Normal 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
194
kirby/src/Panel/Lab/Doc.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
kirby/src/Panel/Lab/Doc/Argument.php
Normal file
46
kirby/src/Panel/Lab/Doc/Argument.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
kirby/src/Panel/Lab/Doc/Event.php
Normal file
57
kirby/src/Panel/Lab/Doc/Event.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
60
kirby/src/Panel/Lab/Doc/Method.php
Normal file
60
kirby/src/Panel/Lab/Doc/Method.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
113
kirby/src/Panel/Lab/Doc/Prop.php
Normal file
113
kirby/src/Panel/Lab/Doc/Prop.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
kirby/src/Panel/Lab/Doc/Slot.php
Normal file
57
kirby/src/Panel/Lab/Doc/Slot.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
64
kirby/src/Panel/Ui/Button.php
Normal file
64
kirby/src/Panel/Ui/Button.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
33
kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php
Normal file
33
kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php
Normal file
34
kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php
Normal file
34
kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php
Normal file
120
kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php
Normal 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();
|
||||
}
|
||||
}
|
||||
32
kirby/src/Panel/Ui/Buttons/OpenButton.php
Normal file
32
kirby/src/Panel/Ui/Buttons/OpenButton.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
50
kirby/src/Panel/Ui/Buttons/PageStatusButton.php
Normal file
50
kirby/src/Panel/Ui/Buttons/PageStatusButton.php
Normal 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'
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
30
kirby/src/Panel/Ui/Buttons/PreviewButton.php
Normal file
30
kirby/src/Panel/Ui/Buttons/PreviewButton.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
32
kirby/src/Panel/Ui/Buttons/SettingsButton.php
Normal file
32
kirby/src/Panel/Ui/Buttons/SettingsButton.php
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
kirby/src/Panel/Ui/Buttons/VersionsButton.php
Normal file
57
kirby/src/Panel/Ui/Buttons/VersionsButton.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
kirby/src/Panel/Ui/Buttons/ViewButton.php
Normal file
215
kirby/src/Panel/Ui/Buttons/ViewButton.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
104
kirby/src/Panel/Ui/Buttons/ViewButtons.php
Normal file
104
kirby/src/Panel/Ui/Buttons/ViewButtons.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
90
kirby/src/Panel/Ui/Component.php
Normal file
90
kirby/src/Panel/Ui/Component.php
Normal 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())
|
||||
];
|
||||
}
|
||||
}
|
||||
105
kirby/src/Panel/Ui/FilePreview.php
Normal file
105
kirby/src/Panel/Ui/FilePreview.php
Normal 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
29
kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php
Normal file
29
kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php
Normal 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';
|
||||
}
|
||||
}
|
||||
42
kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php
Normal file
42
kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php
Normal 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
53
kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php
Normal file
53
kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php
Normal 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
29
kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php
Normal file
29
kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php
Normal 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';
|
||||
}
|
||||
}
|
||||
29
kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php
Normal file
29
kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php
Normal 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
124
kirby/src/Plugin/Asset.php
Normal 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
188
kirby/src/Plugin/Assets.php
Normal 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;
|
||||
}
|
||||
}
|
||||
112
kirby/src/Plugin/License.php
Normal file
112
kirby/src/Plugin/License.php
Normal 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
135
kirby/src/Plugin/LicenseStatus.php
Normal file
135
kirby/src/Plugin/LicenseStatus.php
Normal 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
354
kirby/src/Plugin/Plugin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue