composer update

This commit is contained in:
isUnknown 2025-09-23 08:15:07 +02:00
parent 0b3c362c5e
commit a1f0701630
142 changed files with 4530 additions and 1195 deletions

View file

@ -443,15 +443,15 @@ class App
array $arguments = [],
string $contentType = 'html'
): array {
$name = basename(strtolower($name));
$name = strtolower($name);
$data = [];
// always use the site controller as defaults, if available
$site = $this->controllerLookup('site', $contentType);
$site ??= $this->controllerLookup('site');
if ($site !== null) {
$data = (array)$site->call($this, $arguments);
// (unless the controller is a snippet controller)
if (strpos($name, '/') === false) {
$site = $this->controllerLookup('site', $contentType);
$site ??= $this->controllerLookup('site');
$data = (array)$site?->call($this, $arguments) ?? [];
}
// try to find a specific representation controller
@ -460,14 +460,10 @@ class App
// let's try the html controller instead
$controller ??= $this->controllerLookup($name);
if ($controller !== null) {
return [
...$data,
...(array)$controller->call($this, $arguments)
];
}
return $data;
return [
...$data,
...(array)$controller?->call($this, $arguments) ?? []
];
}
/**
@ -482,7 +478,11 @@ class App
}
// controller from site root
$controller = Controller::load($this->root('controllers') . '/' . $name . '.php', $this->root('controllers'));
$controller = Controller::load(
file: $this->root('controllers') . '/' . $name . '.php',
in: $this->root('controllers')
);
// controller from extension
$controller ??= $this->extension('controllers', $name);
@ -580,7 +580,16 @@ class App
$visitor = $this->visitor();
foreach ($visitor->acceptedLanguages() as $acceptedLang) {
$closure = static function ($language) use ($acceptedLang) {
// Find locale matches (e.g. en_GB => en_GB)
$matchLocale = function ($language) use ($acceptedLang) {
$languageLocale = $language->locale(LC_ALL);
$acceptedLocale = $acceptedLang->locale();
return Str::substr($languageLocale, 0, 5) === Str::substr($acceptedLocale, 0, 5);
};
// Find language matches (e.g. en_GB => en)
$matchLanguage = function ($language) use ($acceptedLang) {
$languageLocale = $language->locale(LC_ALL);
$acceptedLocale = $acceptedLang->locale();
@ -589,7 +598,11 @@ class App
$acceptedLocale === Str::substr($languageLocale, 0, 2);
};
if ($language = $languages->filter($closure)?->first()) {
if ($language = $languages->filter($matchLocale)?->first()) {
return $language;
}
if ($language = $languages->filter($matchLanguage)?->first()) {
return $language;
}
}
@ -768,15 +781,7 @@ class App
// Responses
if ($input instanceof Response) {
$data = $input->toArray();
// inject headers from the global response configuration
// lazily (only if they are not already set);
// the case-insensitive nature of headers will be
// handled by PHP's `header()` function
$data['headers'] = [...$response->headers(), ...$data['headers']];
return new Response($data);
return $response->send($input);
}
// Pages

View file

@ -88,6 +88,12 @@ trait AppTranslations
*/
public function panelLanguage(): string
{
$translation = $this->request()->get('translation');
if ($translation !== null && $this->translations()->find($translation)) {
return $translation;
}
if ($this->multilang() === true) {
$defaultCode = $this->defaultLanguage()->code();

View file

@ -12,6 +12,7 @@ use Kirby\Cms\Auth\TotpChallenge;
use Kirby\Form\Field\BlocksField;
use Kirby\Form\Field\EntriesField;
use Kirby\Form\Field\LayoutField;
use Kirby\Form\Field\StatsField;
use Kirby\Panel\Ui\FilePreviews\AudioFilePreview;
use Kirby\Panel\Ui\FilePreviews\ImageFilePreview;
use Kirby\Panel\Ui\FilePreviews\PdfFilePreview;
@ -270,6 +271,7 @@ class Core
'range' => $this->root . '/fields/range.php',
'select' => $this->root . '/fields/select.php',
'slug' => $this->root . '/fields/slug.php',
'stats' => StatsField::class,
'structure' => $this->root . '/fields/structure.php',
'tags' => $this->root . '/fields/tags.php',
'tel' => $this->root . '/fields/tel.php',

View file

@ -31,6 +31,7 @@ use Kirby\Toolkit\Str;
* @license https://getkirby.com/license
*
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Files>
* @method \Kirby\Uuid\FileUuid uuid()
*/
class File extends ModelWithContent
{
@ -513,7 +514,7 @@ class File extends ModelWithContent
*/
public function permalink(): string|null
{
return $this->uuid()?->url();
return $this->uuid()?->toPermalink();
}
/**

View file

@ -116,6 +116,15 @@ class LanguageVariable
return $this->key;
}
/**
* Returns the parent language
* @since 5.1.0
*/
public function language(): Language
{
return $this->language;
}
/**
* Sets a new value for the language variable
*/

View file

@ -30,6 +30,8 @@ class License
protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX';
protected App $kirby;
// cache
protected LicenseStatus $status;
protected LicenseType $type;
@ -50,6 +52,8 @@ class License
if ($email !== null) {
$this->email = $this->normalizeEmail($email);
}
$this->kirby = App::instance();
}
/**
@ -100,6 +104,15 @@ class License
return $this->date !== null ? Str::date(strtotime($this->date), $format, $handler) : null;
}
/**
* Deletes the license file if it exists
* @since 5.1.0
*/
public function delete(): bool
{
return F::remove($this->root());
}
/**
* Returns the activation domain if available
*/
@ -179,7 +192,7 @@ class License
}
// get release date of current major version
$major = Str::before(App::instance()->version(), '.');
$major = Str::before($this->kirby->version(), '.');
$release = strtotime(static::HISTORY[$major] ?? '');
// if there's no matching version in the history
@ -219,7 +232,7 @@ class License
}
// compare domains
if ($this->normalizeDomain(App::instance()->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) {
if ($this->normalizeDomain($this->kirby->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) {
return false;
}
@ -236,7 +249,7 @@ class License
}
// get the public key
$pubKey = F::read(App::instance()->root('kirby') . '/kirby.pub');
$pubKey = F::read($this->kirby->root('kirby') . '/kirby.pub');
// verify the license signature
$data = json_encode($this->signatureData());
@ -328,7 +341,7 @@ class License
public static function read(): static
{
try {
$license = Json::read(App::instance()->root('license'));
$license = Json::read(static::root());
} catch (Throwable) {
return new static();
}
@ -409,6 +422,15 @@ class License
// @codeCoverageIgnoreEnd
}
/**
* Returns the root path to the license file
* @since 5.1.0
*/
public static function root(): string
{
return App::instance()->root('license');
}
/**
* Saves the license in the config folder
*/
@ -420,11 +442,11 @@ class License
);
}
// where to store the license file
$file = App::instance()->root('license');
// save the license information
return Json::write($file, $this->content());
return Json::write(
file: $this->root(),
data: $this->content()
);
}
/**

View file

@ -30,6 +30,7 @@ use Throwable;
* @license https://getkirby.com/license
*
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Pages>
* @method \Kirby\Uuid\PageUuid uuid()
*/
class Page extends ModelWithContent
{
@ -871,7 +872,7 @@ class Page extends ModelWithContent
*/
public function permalink(): string|null
{
return $this->uuid()?->url();
return $this->uuid()?->toPermalink();
}
/**

View file

@ -865,6 +865,9 @@ trait PageActions
'template' => $this->intendedTemplate()->name(),
]);
// remove the media directory
Dir::remove($this->mediaRoot());
// actually do it on disk
if ($this->exists() === true) {
if (Dir::move($this->root(), $page->root()) !== true) {

View file

@ -4,6 +4,7 @@ namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Filesystem\Mime;
use Kirby\Http\Response as HttpResponse;
use Kirby\Toolkit\Str;
use Stringable;
@ -337,8 +338,15 @@ class Responder implements Stringable
/**
* Creates and returns the response object from the config
*/
public function send(string|null $body = null): Response
public function send(HttpResponse|string|null $body = null): HttpResponse
{
if ($body instanceof HttpResponse) {
// inject headers from the responder into the response
// (only if they are not already set);
$body->setHeaderFallbacks($this->headers());
return $body;
}
if ($body !== null) {
$this->body($body);
}

View file

@ -20,6 +20,8 @@ use Kirby\Toolkit\A;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @method \Kirby\Uuid\SiteUuid uuid()
*/
class Site extends ModelWithContent
{

View file

@ -26,6 +26,7 @@ use SensitiveParameter;
* @license https://getkirby.com/license
*
* @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Users>
* @method \Kirby\Uuid\UserUuid uuid()
*/
class User extends ModelWithContent
{

View file

@ -99,7 +99,7 @@ class Mime
'tgz' => ['application/x-tar', 'application/x-gzip-compressed'],
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'wav' => 'audio/x-wav',
'wav' => ['audio/wav', 'audio/x-wav', 'audio/vnd.wave', 'audio/wave'],
'wbxml' => 'application/wbxml',
'webm' => ['video/webm', 'audio/webm'],
'webp' => 'image/webp',

View file

@ -0,0 +1,74 @@
<?php
namespace Kirby\Form\Field;
use Kirby\Form\FieldClass;
use Kirby\Panel\Ui\Stats;
/**
* Stats field
*
* @package Kirby Field
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class StatsField extends FieldClass
{
/**
* Array or query string for reports. Each report needs a `label` and `value` and can have additional `info`, `link`, `icon` and `theme` settings.
*/
protected array|string $reports;
/**
* The size of the report cards. Available sizes: `tiny`, `small`, `medium`, `large`
*/
protected string $size;
/**
* Cache for the Stats UI component
*/
protected Stats $stats;
public function __construct(array $params)
{
parent::__construct($params);
$this->reports = $params['reports'] ?? [];
$this->size = $params['size'] ?? 'large';
}
public function hasValue(): bool
{
return false;
}
public function reports(): array
{
return $this->stats()->reports();
}
public function size(): string
{
return $this->stats()->size();
}
public function stats(): Stats
{
return $this->stats ??= Stats::from(
model: $this->model,
reports: $this->reports,
size: $this->size
);
}
public function props(): array
{
return [
...parent::props(),
...$this->stats()->props()
];
}
}

View file

@ -4,7 +4,6 @@ namespace Kirby\Form;
use Kirby\Cms\HasSiblings;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Abstract field class to be used instead
@ -22,24 +21,24 @@ use Kirby\Toolkit\Str;
abstract class FieldClass
{
use HasSiblings;
use Mixin\After;
use Mixin\Api;
use Mixin\Autofocus;
use Mixin\Before;
use Mixin\Help;
use Mixin\Icon;
use Mixin\Label;
use Mixin\Model;
use Mixin\Placeholder;
use Mixin\Translatable;
use Mixin\Validation;
use Mixin\Value;
use Mixin\When;
use Mixin\Width;
protected string|null $after;
protected bool $autofocus;
protected string|null $before;
protected bool $disabled;
protected string|null $help;
protected string|null $icon;
protected string|null $label;
protected string|null $name;
protected string|null $placeholder;
protected Fields $siblings;
protected string|null $width;
public function __construct(
protected array $params = []
@ -75,21 +74,6 @@ abstract class FieldClass
return $this->params[$param] ?? null;
}
public function after(): string|null
{
return $this->stringTemplate($this->after);
}
public function autofocus(): bool
{
return $this->autofocus;
}
public function before(): string|null
{
return $this->stringTemplate($this->before);
}
/**
* Returns optional dialog routes for the field
*/
@ -114,33 +98,11 @@ abstract class FieldClass
return [];
}
/**
* Optional help text below the field
*/
public function help(): string|null
{
if (empty($this->help) === false) {
$help = $this->stringTemplate($this->help);
$help = $this->kirby()->kirbytext($help);
return $help;
}
return null;
}
protected function i18n(string|array|null $param = null): string|null
{
return empty($param) === false ? I18n::translate($param, $param) : null;
}
/**
* Optional icon that will be shown at the end of the field
*/
public function icon(): string|null
{
return $this->icon;
}
public function id(): string
{
return $this->name();
@ -156,16 +118,6 @@ abstract class FieldClass
return false;
}
/**
* The field label can be set as string or associative array with translations
*/
public function label(): string
{
return $this->stringTemplate(
$this->label ?? Str::ucfirst($this->name())
);
}
/**
* Returns the field name
*/
@ -182,14 +134,6 @@ abstract class FieldClass
return $this->params;
}
/**
* Optional placeholder value that will be shown when the field is empty
*/
public function placeholder(): string|null
{
return $this->stringTemplate($this->placeholder);
}
/**
* Define the props that will be sent to
* the Vue component
@ -217,67 +161,21 @@ abstract class FieldClass
];
}
protected function setAfter(array|string|null $after = null): void
{
$this->after = $this->i18n($after);
}
protected function setAutofocus(bool $autofocus = false): void
{
$this->autofocus = $autofocus;
}
protected function setBefore(array|string|null $before = null): void
{
$this->before = $this->i18n($before);
}
protected function setDisabled(bool $disabled = false): void
{
$this->disabled = $disabled;
}
protected function setHelp(array|string|null $help = null): void
{
$this->help = $this->i18n($help);
}
protected function setIcon(string|null $icon = null): void
{
$this->icon = $icon;
}
protected function setLabel(array|string|null $label = null): void
{
$this->label = $this->i18n($label);
}
protected function setName(string|null $name = null): void
{
$this->name = strtolower($name ?? $this->type());
}
protected function setPlaceholder(array|string|null $placeholder = null): void
{
$this->placeholder = $this->i18n($placeholder);
}
protected function setSiblings(Fields|null $siblings = null): void
{
$this->siblings = $siblings ?? new Fields([$this]);
}
/**
* Setter for the field width
*/
protected function setWidth(string|null $width = null): void
{
$this->width = $width;
}
/**
* Returns all sibling fields for the HasSiblings trait
*/
protected function siblingsCollection(): Fields
{
return $this->siblings;
@ -314,13 +212,4 @@ abstract class FieldClass
{
return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class)));
}
/**
* Returns the width of the field in
* the Panel grid
*/
public function width(): string
{
return $this->width ?? '1/1';
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Kirby\Form\Mixin;
trait After
{
/**
* Optional text that will be shown after the input
*/
protected string|null $after;
public function after(): string|null
{
return $this->stringTemplate($this->after);
}
protected function setAfter(array|string|null $after = null): void
{
$this->after = $this->i18n($after);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Kirby\Form\Mixin;
trait Autofocus
{
/**
* Sets the focus on this field when the form loads. Only the first field with this label gets
*/
protected bool $autofocus;
public function autofocus(): bool
{
return $this->autofocus;
}
protected function setAutofocus(bool $autofocus = false): void
{
$this->autofocus = $autofocus;
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Kirby\Form\Mixin;
trait Before
{
/**
* Optional text that will be shown before the input
*/
protected string|null $before;
public function before(): string|null
{
return $this->stringTemplate($this->before);
}
protected function setBefore(array|string|null $before = null): void
{
$this->before = $this->i18n($before);
}
}

View file

@ -4,6 +4,9 @@ namespace Kirby\Form\Mixin;
trait EmptyState
{
/**
* Sets the text for the empty state box
*/
protected string|null $empty;
protected function setEmpty(string|array|null $empty = null): void

View file

@ -0,0 +1,34 @@
<?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 Help
{
/**
* Optional help text below the field
*/
protected string|null $help;
public function help(): string|null
{
if (empty($this->help) === false) {
$help = $this->stringTemplate($this->help);
$help = $this->kirby()->kirbytext($help);
return $help;
}
return null;
}
protected function setHelp(array|string|null $help = null): void
{
$this->help = $this->i18n($help);
}
}

View file

@ -0,0 +1,28 @@
<?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 Icon
{
/**
* Optional icon that will be shown at the end of the field
*/
protected string|null $icon;
public function icon(): string|null
{
return $this->icon;
}
protected function setIcon(string|null $icon = null): void
{
$this->icon = $icon;
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Toolkit\Str;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Label
{
/**
* The field label can be set as string or associative array with translations
*/
protected string|null $label;
public function label(): string|null
{
return $this->stringTemplate(
$this->label ?? Str::ucfirst($this->name())
);
}
protected function setLabel(array|string|null $label = null): void
{
$this->label = $this->i18n($label);
}
}

View file

@ -4,6 +4,9 @@ namespace Kirby\Form\Mixin;
trait Max
{
/**
* Sets the maximum number of allowed items in the field
*/
protected int|null $max;
public function max(): int|null

View file

@ -4,6 +4,9 @@ namespace Kirby\Form\Mixin;
trait Min
{
/**
* Sets the minimum number of required items in the field
*/
protected int|null $min;
public function min(): int|null

View file

@ -0,0 +1,30 @@
<?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 Placeholder
{
/**
* Optional placeholder value that will be shown when the field is empty
*/
protected array|string|null $placeholder;
public function placeholder(): string|null
{
return $this->stringTemplate(
$this->placeholder
);
}
protected function setPlaceholder(array|string|null $placeholder = null): void
{
$this->placeholder = $this->i18n($placeholder);
}
}

View file

@ -13,6 +13,9 @@ use Kirby\Cms\Language;
*/
trait Translatable
{
/**
* Should the field be translatable?
*/
protected bool $translate = true;
/**
@ -29,17 +32,11 @@ trait Translatable
return true;
}
/**
* Set the translatable status
*/
protected function setTranslate(bool $translate = true): void
{
$this->translate = $translate;
}
/**
* Should the field be translatable?
*/
public function translate(): bool
{
return $this->translate;

View file

@ -18,6 +18,9 @@ use Kirby\Toolkit\V;
*/
trait Validation
{
/**
* If `true`, the field has to be filled in correctly to be saved.
*/
protected bool $required;
/**
@ -94,9 +97,6 @@ trait Validation
return $this->errors() === [];
}
/**
* Getter for the required property
*/
public function required(): bool
{
return $this->required;

View file

@ -13,7 +13,14 @@ use Kirby\Cms\Language;
*/
trait Value
{
/**
* Default value for the field, which will be used when a page/file/user is created
*/
protected mixed $default = null;
/**
* The value of the field
*/
protected mixed $value = null;
/**

View file

@ -11,6 +11,11 @@ namespace Kirby\Form\Mixin;
*/
trait When
{
/**
* Conditions when the field will be shown
*
* @since 3.1.0
*/
protected array|null $when = null;
/**
@ -40,17 +45,11 @@ trait When
return true;
}
/**
* Setter for the `when` condition
*/
protected function setWhen(array|null $when = null): void
{
$this->when = $when;
}
/**
* Returns the `when` condition of the field
*/
public function when(): array|null
{
return $this->when;

View file

@ -0,0 +1,29 @@
<?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 Width
{
/**
* The width of the field in the field grid.
* Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4`
*/
protected string|null $width;
protected function setWidth(string|null $width = null): void
{
$this->width = $width;
}
public function width(): string
{
return $this->width ?? '1/1';
}
}

View file

@ -222,7 +222,6 @@ class Cookie
protected static function trackUsage(string $key): void
{
// lazily request the instance for non-CMS use cases
$kirby = App::instance(null, true);
$kirby?->response()->usesCookie($key);
App::instance(lazy: true)?->response()->usesCookie($key);
}
}

View file

@ -13,13 +13,13 @@ use Kirby\Toolkit\Str;
* secure host and base URL detection, as
* well as loading the dedicated
* environment options.
* @since 3.7.0
*
* @package Kirby Http
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
* @since 3.7.0
*/
class Environment
{

View file

@ -68,7 +68,7 @@ class Header
$header = 'Content-type: ' . $mime;
if (empty($charset) === false) {
if ($charset !== '') {
$header .= '; charset=' . $charset;
}

View file

@ -38,7 +38,7 @@ class Params extends Obj implements Stringable
*/
public static function extract(string|array|null $path = null): array
{
if (empty($path) === true) {
if ($path === null || $path === '' || $path === []) {
return [
'path' => null,
'params' => null,
@ -62,12 +62,16 @@ class Params extends Obj implements Stringable
continue;
}
$paramParts = Str::split($p, $separator);
$paramKey = $paramParts[0] ?? null;
$paramValue = $paramParts[1] ?? null;
$parts = Str::split($p, $separator);
if ($paramKey !== null) {
$params[rawurldecode($paramKey)] = $paramValue !== null ? rawurldecode($paramValue) : null;
if ($key = $parts[0] ?? null) {
$key = rawurldecode($key);
if ($value = $parts[1] ?? null) {
$value = rawurldecode($value);
}
$params[$key] = $value;
}
unset($path[$index]);
@ -89,7 +93,7 @@ class Params extends Obj implements Stringable
public function isEmpty(): bool
{
return empty((array)$this) === true;
return (array)$this === [];
}
public function isNotEmpty(): bool
@ -97,6 +101,23 @@ class Params extends Obj implements Stringable
return $this->isEmpty() === false;
}
/**
* Merges the current params with the given params
* @since 5.1.0
*
* @return $this
*/
public function merge(array|string|null $params): static
{
$params = new static($params);
foreach ($params as $key => $value) {
$this->$key = $value;
}
return $this;
}
/**
* Returns the param separator according
* to the operating system.
@ -106,15 +127,7 @@ class Params extends Obj implements Stringable
*/
public static function separator(): string
{
if (static::$separator !== null) {
return static::$separator;
}
if (DIRECTORY_SEPARATOR === '/') {
return static::$separator = ':';
}
return static::$separator = ';';
return static::$separator ??= DIRECTORY_SEPARATOR === '/' ? ':' : ';';
}
/**
@ -134,7 +147,9 @@ class Params extends Obj implements Stringable
foreach ($this as $key => $value) {
if ($value !== null && $value !== '') {
$params[] = rawurlencode($key) . $separator . rawurlencode($value);
$key = rawurlencode($key);
$value = rawurlencode($value);
$params[] = $key . $separator . $value;
}
}

View file

@ -29,7 +29,7 @@ class Query extends Obj implements Stringable
public function isEmpty(): bool
{
return empty((array)$this) === true;
return (array)$this === [];
}
public function isNotEmpty(): bool
@ -37,11 +37,28 @@ class Query extends Obj implements Stringable
return $this->isEmpty() === false;
}
/**
* Merges the current query with the given query
* @since 5.1.0
*
* @return $this
*/
public function merge(string|array|null $query): static
{
$query = new static($query);
foreach ($query as $key => $value) {
$this->$key = $value;
}
return $this;
}
public function toString(bool $questionMark = false): string
{
$query = http_build_query($this, '', '&', PHP_QUERY_RFC3986);
if (empty($query) === true) {
if ($query === '') {
return '';
}

View file

@ -47,13 +47,14 @@ class Remote
public string $errorMessage;
public array $headers = [];
public array $info = [];
public array $options = [];
/**
* @throws \Exception when the curl request failed
*/
public function __construct(string $url, array $options = [])
{
public function __construct(
string $url,
public array $options = []
) {
$defaults = static::$defaults;
// use the system CA store by default if
@ -71,11 +72,8 @@ class Remote
$defaults = [...$defaults, ...$app->option('remote', [])];
}
// set all options
$this->options = [...$defaults, ...$options];
// add the url
$this->options['url'] = $url;
// set all options, incl. url
$this->options = [...$defaults, ...$options, 'url' => $url];
// send the request
$this->fetch();
@ -277,7 +275,7 @@ class Remote
$query = http_build_query($options['data']);
if (empty($query) === false) {
if ($query !== '') {
$url = match (Url::hasQuery($url)) {
true => $url . '&' . $query,
default => $url . '?' . $query
@ -339,7 +337,7 @@ class Remote
*/
protected function postfields($data)
{
if (is_object($data) || is_array($data)) {
if (is_object($data) === true || is_array($data) === true) {
return http_build_query($data);
}

View file

@ -67,12 +67,6 @@ class Request
*/
protected string $method;
/**
* All options that have been passed to
* the request in the constructor
*/
protected array $options;
/**
* The Query object is a wrapper around
* the URL query string, which parses the
@ -96,9 +90,9 @@ class Request
* data via the $options array or use
* the data from the incoming request.
*/
public function __construct(array $options = [])
{
$this->options = $options;
public function __construct(
protected array $options = []
) {
$this->method = $this->detectRequestMethod($options['method'] ?? null);
if (isset($options['body']) === true) {
@ -155,7 +149,7 @@ class Request
}
// lazily request the instance for non-CMS use cases
$kirby = App::instance(null, true);
$kirby = App::instance(lazy: true);
// tell the CMS responder that the response relies on
// the `Authorization` header and its value (even if
@ -224,13 +218,26 @@ class Request
public function detectRequestMethod(string|null $method = null): string
{
// all possible methods
$methods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'];
$methods = [
'CONNECT',
'DELETE',
'GET',
'HEAD',
'OPTIONS',
'PATCH',
'POST',
'PUT',
'TRACE',
];
// the request method can be overwritten with a header
$methodOverride = strtoupper(Environment::getGlobally('HTTP_X_HTTP_METHOD_OVERRIDE', ''));
if ($method === null) {
$override = Environment::getGlobally('HTTP_X_HTTP_METHOD_OVERRIDE', '');
$override = strtoupper($override);
if (in_array($methodOverride, $methods, true) === true) {
$method ??= $methodOverride;
if (in_array($override, $methods, true) === true) {
$method = $override;
}
}
// final chain of options to detect the method
@ -410,14 +417,15 @@ class Request
// both variants need to be checked separately
// because empty strings are treated as invalid
// but the `??` operator wouldn't do the fallback
$option = $this->options['auth'] ?? null;
if (empty($option) === false) {
if (is_string($option) === true && $option !== '') {
return $option;
}
$header = $this->header('authorization');
if (empty($header) === false) {
if (is_string($header) === true && $header !== '') {
return $header;
}

View file

@ -20,11 +20,6 @@ class Body implements Stringable
{
use Data;
/**
* The raw body content
*/
protected string|array|null $contents;
/**
* The parsed content as array
*/
@ -36,10 +31,12 @@ class Body implements Stringable
* If null is being passed, the class will
* fetch the body either from the $_POST global
* or from php://input.
*
* @param array|string|null $contents The raw body content
*/
public function __construct(array|string|null $contents = null)
{
$this->contents = $contents;
public function __construct(
protected array|string|null $contents = null
) {
}
/**
@ -52,7 +49,7 @@ class Body implements Stringable
return $this->contents;
}
if (empty($_POST) === false) {
if ($_POST !== []) {
return $this->contents = $_POST;
}
@ -90,7 +87,7 @@ class Body implements Stringable
// try to parse the body as query string
parse_str($contents, $parsed);
if (is_array($parsed)) {
if (is_array($parsed) === true) {
return $this->data = $parsed;
}
}

View file

@ -45,9 +45,11 @@ trait Data
{
if (is_array($key) === true) {
$result = [];
foreach ($key as $k) {
$result[$k] = $this->get($k);
}
return $result;
}

View file

@ -34,7 +34,7 @@ class Files
$files ??= $_FILES;
foreach ($files as $key => $file) {
if (is_array($file['name'])) {
if (is_array($file['name']) === true) {
foreach ($file['name'] as $i => $name) {
$this->files[$key][] = [
'name' => $file['name'][$i] ?? null,

View file

@ -22,7 +22,7 @@ class Query implements Stringable
/**
* The Query data array
*/
protected array|null $data = null;
protected array $data;
/**
* Creates a new Query object.
@ -56,7 +56,7 @@ class Query implements Stringable
*/
public function isEmpty(): bool
{
return empty($this->data) === true;
return $this->data === [];
}
/**
@ -64,7 +64,7 @@ class Query implements Stringable
*/
public function isNotEmpty(): bool
{
return empty($this->data) === false;
return $this->data !== [];
}
/**

View file

@ -281,8 +281,11 @@ class Response implements Stringable
*
* @since 5.0.3
*/
public static function refresh(string $location = '/', int $code = 302, int $refresh = 0): static
{
public static function refresh(
string $location = '/',
int $code = 302,
int $refresh = 0
): static {
return new static([
'code' => $code,
'headers' => [
@ -312,6 +315,19 @@ class Response implements Stringable
return $this->body();
}
/**
* Sets the provided headers in case they are not already set
* @internal
* @return $this
*/
public function setHeaderFallbacks(array $headers): static
{
// the case-insensitive nature of headers will be
// handled by PHP's `header()` functions
$this->headers = [...$headers, ...$this->headers];
return $this;
}
/**
* Converts all relevant response attributes
* to an associative array for debugging,

View file

@ -13,26 +13,11 @@ use Closure;
*/
class Route
{
/**
* The callback action function
*/
protected Closure $action;
/**
* Listed of parsed arguments
*/
protected array $arguments = [];
/**
* An array of all passed attributes
*/
protected array $attributes = [];
/**
* The registered request method
*/
protected string $method;
/**
* The registered pattern
*/
@ -74,14 +59,11 @@ class Route
*/
public function __construct(
string $pattern,
string $method,
Closure $action,
array $attributes = []
protected string $method,
protected Closure $action,
protected array $attributes = []
) {
$this->action = $action;
$this->attributes = $attributes;
$this->method = $method;
$this->pattern = $this->regex(ltrim($pattern, '/'));
$this->pattern = $this->regex(ltrim($pattern, '/'));
}
/**

View file

@ -158,6 +158,9 @@ class Router
* The Route's arguments method is used to
* find matches and return all the found
* arguments in the path.
*
* @param array|null $ignore (Passing null has been deprecated)
* @todo Remove support for `$ignore = null` in v6
*/
public function find(
string $path,
@ -172,16 +175,14 @@ class Router
}
// remove leading and trailing slashes
$path = trim($path, '/');
$path = trim($path, '/');
$ignore ??= [];
foreach ($this->routes[$method] as $route) {
$arguments = $route->parse($route->pattern(), $path);
if ($arguments !== false) {
if (
empty($ignore) === true ||
in_array($route, $ignore, true) === false
) {
if (in_array($route, $ignore, true) === false) {
return $this->route = $route;
}
}

View file

@ -216,10 +216,10 @@ class Uri implements Stringable
if ($app = App::instance(null, true)) {
$environment = $app->environment();
} else {
$environment = new Environment();
}
$environment ??= new Environment();
return new static($environment->requestUrl(), $props);
}
@ -230,7 +230,7 @@ class Uri implements Stringable
*/
public function domain(): string|null
{
if (empty($this->host) === true || $this->host === '/') {
if ($this->host === null || $this->host === '' || $this->host === '/') {
return null;
}
@ -255,7 +255,7 @@ class Uri implements Stringable
public function hasFragment(): bool
{
return empty($this->fragment) === false;
return $this->fragment !== null && $this->fragment !== '';
}
public function hasPath(): bool
@ -281,8 +281,9 @@ class Uri implements Stringable
*/
public function idn(): static
{
if (empty($this->host) === false) {
$this->setHost(Idn::decode($this->host));
if ($this->isAbsolute() === true) {
$host = Idn::decode($this->host);
$this->setHost($host);
}
return $this;
}
@ -295,10 +296,10 @@ class Uri implements Stringable
{
if ($app = App::instance(null, true)) {
$url = $app->url('index');
} else {
$url = (new Environment())->baseUrl();
}
$url ??= (new Environment())->baseUrl();
return new static($url, $props);
}
@ -307,7 +308,16 @@ class Uri implements Stringable
*/
public function isAbsolute(): bool
{
return empty($this->host) === false;
return $this->host !== null && $this->host !== '';
}
/**
* Returns the fragment after the hash
* @since 5.1.0
*/
public function fragment(): string|null
{
return $this->fragment;
}
/**
@ -465,7 +475,7 @@ class Uri implements Stringable
$url = $this->base();
$slash = true;
if (empty($url) === true) {
if ($url === null || $url === '') {
$url = '/';
$slash = false;
}
@ -479,8 +489,8 @@ class Uri implements Stringable
$url .= $path;
$url .= $this->query->toString(true);
if (empty($this->fragment) === false) {
$url .= '#' . $this->fragment;
if ($this->hasFragment() === true) {
$url .= '#' . $this->fragment();
}
return $url;
@ -494,8 +504,9 @@ class Uri implements Stringable
*/
public function unIdn(): static
{
if (empty($this->host) === false) {
$this->setHost(Idn::encode($this->host));
if ($this->isAbsolute() === true) {
$host = Idn::encode($this->host);
$this->setHost($host);
}
return $this;
}

View file

@ -110,8 +110,10 @@ class Url
/**
* Convert a relative path into an absolute URL
*/
public static function makeAbsolute(string|null $path = null, string|null $home = null): string
{
public static function makeAbsolute(
string|null $path = null,
string|null $home = null
): string {
if ($path === '' || $path === '/' || $path === null) {
return $home ?? static::home();
}
@ -120,7 +122,7 @@ class Url
return $path;
}
if (static::isAbsolute($path)) {
if (static::isAbsolute($path) === true) {
return $path;
}
@ -128,11 +130,15 @@ class Url
$path = ltrim($path, '/');
$home ??= static::home();
if (empty($path) === true) {
if ($path === '') {
return $home;
}
return $home === '/' ? '/' . $path : $home . '/' . $path;
if ($home === '/') {
return '/' . $path;
}
return $home . '/' . $path;
}
/**

View file

@ -165,16 +165,16 @@ class Visitor
*/
public function preferredMimeType(string ...$mimeTypes): string|null
{
foreach ($this->acceptedMimeTypes() as $acceptedMime) {
foreach ($this->acceptedMimeTypes() as $accepted) {
// look for direct matches
if (in_array($acceptedMime->type(), $mimeTypes, true)) {
return $acceptedMime->type();
if (in_array($accepted->type(), $mimeTypes, true) === true) {
return $accepted->type();
}
// test each option against wildcard `Accept` values
foreach ($mimeTypes as $expectedMime) {
if (Mime::matches($expectedMime, $acceptedMime->type()) === true) {
return $expectedMime;
foreach ($mimeTypes as $expected) {
if (Mime::matches($expected, $accepted->type()) === true) {
return $expected;
}
}
}

View file

@ -5,6 +5,7 @@ namespace Kirby\Image;
use Exception;
use Kirby\Image\Darkroom\GdLib;
use Kirby\Image\Darkroom\ImageMagick;
use Kirby\Image\Darkroom\Imagick;
/**
* A wrapper around resizing and cropping
@ -19,8 +20,9 @@ use Kirby\Image\Darkroom\ImageMagick;
class Darkroom
{
public static array $types = [
'gd' => GdLib::class,
'im' => ImageMagick::class
'gd' => GdLib::class,
'imagick' => Imagick::class,
'im' => ImageMagick::class
];
public function __construct(
@ -30,19 +32,18 @@ class Darkroom
}
/**
* Creates a new Darkroom instance for the given
* type/driver
* Creates a new Darkroom instance
* for the given type/driver
*
* @throws \Exception
*/
public static function factory(string $type, array $settings = []): object
public static function factory(string $type, array $settings = []): static
{
if (isset(static::$types[$type]) === false) {
throw new Exception(message: 'Invalid Darkroom type');
}
$class = static::$types[$type];
return new $class($settings);
return new static::$types[$type]($settings);
}
/**
@ -69,7 +70,12 @@ class Darkroom
*/
protected function options(array $options = []): array
{
$options = [...$this->settings, ...$options];
$options = [
...$this->settings,
...$options,
// ensure quality isn't unset by provided options
'quality' => $options['quality'] ?? $this->settings['quality']
];
// normalize the crop option
if ($options['crop'] === true) {
@ -81,7 +87,7 @@ class Darkroom
$options['blur'] = 10;
}
// normalize the greyscale option
// normalize the grayscale option
if (isset($options['greyscale']) === true) {
$options['grayscale'] = $options['greyscale'];
unset($options['greyscale']);
@ -98,8 +104,6 @@ class Darkroom
$options['sharpen'] = 50;
}
$options['quality'] ??= $this->settings['quality'];
return $options;
}

View file

@ -8,7 +8,7 @@ use Kirby\Image\Darkroom;
use Kirby\Image\Focus;
/**
* GdLib
* GdLib darkroom driver
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>

View file

@ -8,13 +8,16 @@ use Kirby\Image\Darkroom;
use Kirby\Image\Focus;
/**
* ImageMagick
* Legacy ImageMagick driver using the convert CLI
*
* @package Kirby Image
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @deprecated 5.1.0 Use `imagick` in the `thumbs.driver` config option instead
* @todo Remove in 7.0.0
*/
class ImageMagick extends Darkroom
{

View file

@ -0,0 +1,292 @@
<?php
namespace Kirby\Image\Darkroom;
use Exception;
use Imagick as Image;
use Kirby\Image\Darkroom;
use Kirby\Image\Focus;
/**
* Imagick darkroom driver
*
* @package Kirby Image
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
*/
class Imagick extends Darkroom
{
protected function autoOrient(Image $image): Image
{
switch ($image->getImageOrientation()) {
case Image::ORIENTATION_TOPLEFT:
break;
case Image::ORIENTATION_TOPRIGHT:
$image->flopImage();
break;
case Image::ORIENTATION_BOTTOMRIGHT:
$image->rotateImage('#000', 180);
break;
case Image::ORIENTATION_BOTTOMLEFT:
$image->flopImage();
$image->rotateImage('#000', 180);
break;
case Image::ORIENTATION_LEFTTOP:
$image->flopImage();
$image->rotateImage('#000', -90);
break;
case Image::ORIENTATION_RIGHTTOP:
$image->rotateImage('#000', 90);
break;
case Image::ORIENTATION_RIGHTBOTTOM:
$image->flopImage();
$image->rotateImage('#000', 90);
break;
case Image::ORIENTATION_LEFTBOTTOM:
$image->rotateImage('#000', -90);
break;
default: // Invalid orientation
break;
}
$image->setImageOrientation(Image::ORIENTATION_TOPLEFT);
return $image;
}
/**
* Applies the blur settings
*/
protected function blur(Image $image, array $options): Image
{
if ($options['blur'] !== false) {
$image->blurImage(0.0, $options['blur']);
}
return $image;
}
/**
* Keep animated gifs
*/
protected function coalesce(Image $image): Image
{
if ($image->getImageMimeType() === 'image/gif') {
return $image->coalesceImages();
}
return $image;
}
/**
* Returns additional default parameters for imagemagick
*/
protected function defaults(): array
{
return parent::defaults() + [
'interlace' => false,
'profiles' => ['icc', 'icm'],
'threads' => 1,
];
}
/**
* Applies the correct settings for grayscale images
*/
protected function grayscale(Image $image, array $options): Image
{
if ($options['grayscale'] === true) {
$image->setImageColorspace(Image::COLORSPACE_GRAY);
}
return $image;
}
/**
* Applies the correct settings for interlaced JPEGs if
* activated via options
*/
protected function interlace(Image $image, array $options): Image
{
if ($options['interlace'] === true) {
$image->setInterlaceScheme(Image::INTERLACE_LINE);
}
return $image;
}
/**
* Creates and runs the full imagemagick command
* to process the image
*
* @throws \Exception
*/
public function process(string $file, array $options = []): array
{
$options = $this->preprocess($file, $options);
$image = new Image($file);
$image = $this->threads($image, $options);
$image = $this->interlace($image, $options);
$image = $this->coalesce($image);
$image = $this->grayscale($image, $options);
$image = $this->autoOrient($image);
$image = $this->resize($image, $options);
$image = $this->quality($image, $options);
$image = $this->blur($image, $options);
$image = $this->sharpen($image, $options);
$image = $this->strip($image, $options);
if ($this->save($image, $file, $options) === false) {
// @codeCoverageIgnoreStart
throw new Exception(message: 'The imagemagick result could not be generated');
// @codeCoverageIgnoreEnd
}
return $options;
}
/**
* Applies the correct JPEG compression quality settings
*/
protected function quality(Image $image, array $options): Image
{
$image->setImageCompressionQuality($options['quality']);
return $image;
}
/**
* Creates the correct options to crop or resize the image
* and translates the crop positions for imagemagick
*/
protected function resize(Image $image, array $options): Image
{
// simple resize
if ($options['crop'] === false) {
$image->thumbnailImage(
$options['width'],
$options['height'],
true
);
return $image;
}
// crop based on focus point
if (Focus::isFocalPoint($options['crop']) === true) {
if ($focus = Focus::coords(
$options['crop'],
$options['sourceWidth'],
$options['sourceHeight'],
$options['width'],
$options['height']
)) {
$image->cropImage(
$focus['width'],
$focus['height'],
$focus['x1'],
$focus['y1']
);
$image->thumbnailImage(
$options['width'],
$options['height'],
true
);
return $image;
}
}
// translate the gravity option into something imagemagick understands
$gravity = match ($options['crop'] ?? null) {
'top left' => Image::GRAVITY_NORTHWEST,
'top' => Image::GRAVITY_NORTH,
'top right' => Image::GRAVITY_NORTHEAST,
'left' => Image::GRAVITY_WEST,
'right' => Image::GRAVITY_EAST,
'bottom left' => Image::GRAVITY_SOUTHWEST,
'bottom' => Image::GRAVITY_SOUTH,
'bottom right' => Image::GRAVITY_SOUTHEAST,
default => Image::GRAVITY_CENTER
};
$landscape = $options['width'] >= $options['height'];
$image->thumbnailImage(
$landscape ? $options['width'] : $image->getImageWidth(),
$landscape ? $image->getImageHeight() : $options['height'],
true
);
$image->setGravity($gravity);
$image->cropImage($options['width'], $options['height'], 0, 0);
return $image;
}
/**
* Creates the option for the output file
*/
protected function save(Image $image, string $file, array $options): bool
{
if ($options['format'] !== null) {
$file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format'];
}
return $image->writeImages($file, true);
}
/**
* Applies sharpening if activated in the options.
*/
protected function sharpen(Image $image, array $options): Image
{
if (is_int($options['sharpen']) === false) {
return $image;
}
$amount = max(1, min(100, $options['sharpen'])) / 100;
$image->sharpenImage(0.0, $amount);
return $image;
}
/**
* Removes all metadata but ICC profiles from the image
*/
protected function strip(Image $image, array $options): Image
{
// strip all profiles but the ICC profile
$profiles = $image->getImageProfiles('*', false);
foreach ($profiles as $profile) {
if (in_array($profile, $options['profiles'] ?? [], true) === false) {
$image->removeImageProfile($profile);
}
}
// strip all properties
$properties = $image->getImageProperties('*', false);
foreach ($properties as $property) {
$image->deleteImageProperty($property);
}
return $image;
}
/**
* Sets thread limit
*/
protected function threads(Image $image, array $options): Image
{
$image->setResourceLimit(
Image::RESOURCETYPE_THREAD,
$options['threads']
);
return $image;
}
}

View file

@ -2,6 +2,7 @@
namespace Kirby\Image;
use Kirby\Toolkit\A;
use Kirby\Toolkit\V;
/**
@ -25,7 +26,7 @@ class Exif
protected string|null $exposure = null;
protected string|null $focalLength = null;
protected bool|null $isColor = null;
protected string|null $iso = null;
protected array|string|null $iso = null;
protected Location|null $location = null;
protected string|null $timestamp = null;
protected int $orientation;
@ -96,6 +97,10 @@ class Exif
*/
public function iso(): string|null
{
if (is_array($this->iso) === true) {
return A::first($this->iso);
}
return $this->iso;
}

View file

@ -0,0 +1,73 @@
<?php
namespace Kirby\Panel\Collector;
use Kirby\Cms\Files;
use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Cms\Users;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class FilesCollector extends ModelsCollector
{
public function __construct(
protected bool $flip = false,
protected int|null $limit = null,
protected int $page = 1,
protected Site|Page|User|null $parent = null,
protected string|null $query = null,
protected string|null $search = null,
protected string|null $sortBy = null,
protected string|null $template = null,
) {
}
protected function collect(): Files
{
return $this->parent()->files();
}
protected function collectByQuery(): Files
{
return $this->parent()->query($this->query, Files::class) ?? new Files([]);
}
protected function filter(Files|Pages|Users $models): Files
{
return $models->filter(function ($file) {
// remove all protected and hidden files
if ($file->isListable() === false) {
return false;
}
// filter by template
if ($this->template !== null && $file->template() !== $this->template) {
return false;
}
return true;
});
}
public function isSorting(): bool
{
return true;
}
protected function sort(Files|Pages|Users $models): Files
{
if ($this->sortBy === null || $this->isSearching() === true) {
return $models->sorted();
}
return parent::sort($models);
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace Kirby\Panel\Collector;
use Kirby\Cms\App;
use Kirby\Cms\Files;
use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Cms\Pagination;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Cms\Users;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
abstract class ModelsCollector
{
protected Files|Pages|Users $models;
protected Files|Pages|Users $paginated;
public function __construct(
protected int|null $limit = null,
protected int $page = 1,
protected Site|Page|User|null $parent = null,
protected string|null $query = null,
protected string|null $search = null,
protected string|null $sortBy = null,
protected bool $flip = false,
) {
}
abstract protected function collect(): Files|Pages|Users;
abstract protected function collectByQuery(): Files|Pages|Users;
abstract protected function filter(Files|Pages|Users $models): Files|Pages|Users;
protected function flip(Files|Pages|Users $models): Files|Pages|Users
{
return $models->flip();
}
public function isFlipping(): bool
{
if ($this->isSearching() === true) {
return false;
}
return $this->flip === true;
}
public function isQuerying(): bool
{
return $this->query !== null;
}
public function isSearching(): bool
{
return $this->search !== null && trim($this->search) !== '';
}
public function isSorting(): bool
{
if ($this->isSearching() === true) {
return false;
}
return $this->sortBy !== null;
}
public function models(bool $paginated = false): Files|Pages|Users
{
if ($paginated === true) {
return $this->paginated ??= $this->models()->paginate([
'limit' => $this->limit ?? 1000,
'page' => $this->page,
'method' => 'none' // the page is manually provided
]);
}
if (isset($this->models) === true) {
return $this->models;
}
if ($this->isQuerying() === true) {
$models = $this->collectByQuery();
} else {
$models = $this->collect();
}
$models = $this->filter($models);
if ($this->isSearching() === true) {
$models = $this->search($models);
}
if ($this->isSorting() === true) {
$models = $this->sort($models);
}
if ($this->isFlipping() === true) {
$models = $this->flip($models);
}
return $this->models ??= $models;
}
public function pagination(): Pagination
{
return $this->models(paginated: true)->pagination();
}
protected function parent(): Site|Page|User
{
return $this->parent ?? App::instance()->site();
}
protected function search(Files|Pages|Users $models): Files|Pages|Users
{
return $models->search($this->search);
}
protected function sort(Files|Pages|Users $models): Files|Pages|Users
{
return $models->sort(...$models::sortArgs($this->sortBy));
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace Kirby\Panel\Collector;
use Kirby\Cms\Files;
use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Cms\Users;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PagesCollector extends ModelsCollector
{
public function __construct(
protected int|null $limit = null,
protected int $page = 1,
protected Site|Page|User|null $parent = null,
protected string|null $query = null,
protected string|null $status = null,
protected array $templates = [],
protected array $templatesIgnore = [],
protected string|null $search = null,
protected string|null $sortBy = null,
protected bool $flip = false,
) {
}
protected function collect(): Pages
{
return match ($this->status) {
'draft' => $this->parent()->drafts(),
'listed' => $this->parent()->children()->listed(),
'published' => $this->parent()->children(),
'unlisted' => $this->parent()->children()->unlisted(),
default => $this->parent()->childrenAndDrafts()
};
}
protected function collectByQuery(): Pages
{
return $this->parent()->query($this->query, Pages::class) ?? new Pages([]);
}
protected function filter(Files|Pages|Users $models): Pages
{
// filters pages that are protected and not in the templates list
// internal `filter()` method used instead of foreach loop that previously included `unset()`
// because `unset()` is updating the original data, `filter()` is just filtering
// also it has been tested that there is no performance difference
// even in 0.1 seconds on 100k virtual pages
return $models->filter(function (Page $model): bool {
// remove all protected and hidden pages
if ($model->isListable() === false) {
return false;
}
$intendedTemplate = $model->intendedTemplate()->name();
// filter by all set templates
if (
$this->templates &&
in_array($intendedTemplate, $this->templates, true) === false
) {
return false;
}
// exclude by all ignored templates
if (
$this->templatesIgnore &&
in_array($intendedTemplate, $this->templatesIgnore, true) === true
) {
return false;
}
return true;
});
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Kirby\Panel\Collector;
use Kirby\Cms\App;
use Kirby\Cms\Files;
use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Cms\Users;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class UsersCollector extends ModelsCollector
{
public function __construct(
protected bool $flip = false,
protected int|null $limit = null,
protected int $page = 1,
protected Site|Page|User|null $parent = null,
protected string|null $query = null,
protected string|null $role = null,
protected string|null $search = null,
protected string|null $sortBy = null,
) {
}
protected function collect(): Users
{
return App::instance()->users();
}
protected function collectByQuery(): Users
{
return $this->parent()->query($this->query, Users::class) ?? new Users([]);
}
protected function filter(Files|Pages|Users $models): Users
{
$user = App::instance()->user();
if ($user === null) {
return new Users([]);
}
if ($user->role()->permissions()->for('access', 'users') === false) {
return new Users([]);
}
if ($this->role !== null) {
$models = $models->role($this->role);
}
return $models;
}
}

View file

@ -3,7 +3,9 @@
namespace Kirby\Panel\Controller;
use Kirby\Cms\App;
use Kirby\Toolkit\Escape;
use Kirby\Panel\Ui\Item\FileItem;
use Kirby\Panel\Ui\Item\PageItem;
use Kirby\Panel\Ui\Item\UserItem;
/**
* The Search controller takes care of the logic
@ -40,13 +42,7 @@ class Search
}
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(),
]),
'results' => $files->values(fn ($file) => (new FileItem(file: $file, info: '{{ file.id }}'))->props()),
'pagination' => $files->pagination()?->toArray()
];
}
@ -67,13 +63,7 @@ class Search
}
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(),
]),
'results' => $pages->values(fn ($page) => (new PageItem(page: $page, info: '{{ page.id }}'))->props()),
'pagination' => $pages->pagination()?->toArray()
];
}
@ -91,13 +81,7 @@ class Search
}
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(),
]),
'results' => $users->values(fn ($user) => (new UserItem(user: $user))->props()),
'pagination' => $users->pagination()?->toArray()
];
}

View file

@ -50,6 +50,20 @@ class Dialog extends Json
$pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/');
$type = str_replace('$', '', static::$key);
// create load/submit events from controller class
if ($controller = $options['controller'] ?? null) {
if (is_string($controller) === true) {
if (method_exists($controller, 'for') === true) {
$controller = $controller::for(...);
} else {
$controller = fn (...$args) => new $controller(...$args);
}
}
$options['load'] ??= fn (...$args) => $controller(...$args)->load();
$options['submit'] ??= fn (...$args) => $controller(...$args)->submit();
}
// load event
$routes[] = [
'pattern' => $pattern,

View file

@ -7,6 +7,7 @@ use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Panel\Ui\FilePreview;
use Kirby\Panel\Ui\Item\FileItem;
use Kirby\Toolkit\I18n;
use Throwable;
@ -359,8 +360,9 @@ class File extends Model
*/
public function pickerData(array $params = []): array
{
$name = $this->model->filename();
$id = $this->model->id();
$name = $this->model->filename();
$id = $this->model->id();
$absolute = false;
if (empty($params['model']) === false) {
$parent = $this->model->parent();
@ -374,15 +376,20 @@ class File extends Model
};
}
$params['text'] ??= '{{ file.filename }}';
$item = new FileItem(
file: $this->model,
dragTextIsAbsolute: $absolute,
image: $params['image'] ?? null,
info: $params['info'] ?? null,
layout: $params['layout'] ?? null,
text: $params['text'] ?? null,
);
return [
...parent::pickerData($params),
'dragText' => $this->dragText('auto', absolute: $absolute ?? false),
'filename' => $name,
'id' => $id,
...$item->props(),
'id' => $id,
'sortable' => true,
'type' => $this->model->type(),
'url' => $this->model->url()
];
}

View file

@ -9,6 +9,7 @@ use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
use Kirby\Form\Fields;
use Kirby\Http\Uri;
use Kirby\Panel\Ui\Item\ModelItem;
use Kirby\Toolkit\A;
/**
@ -338,17 +339,18 @@ abstract class Model
*/
public function pickerData(array $params = []): array
{
$item = new ModelItem(
model: $this->model,
image: $params['image'] ?? null,
info: $params['info'] ?? null,
layout: $params['layout'] ?? null,
text: $params['text'] ?? null,
);
return [
'id' => $this->model->id(),
'image' => $this->image(
$params['image'] ?? [],
$params['layout'] ?? 'list'
),
'info' => $this->model->toSafeString($params['info'] ?? false),
'link' => $this->url(true),
...$item->props(),
'sortable' => true,
'text' => $this->model->toSafeString($params['text'] ?? false),
'uuid' => $this->model->uuid()?->toString()
'url' => $this->url(true)
];
}

View file

@ -6,6 +6,7 @@ use Kirby\Cms\File as CmsFile;
use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Panel\Ui\Item\PageItem;
use Kirby\Toolkit\I18n;
/**
@ -254,13 +255,18 @@ class Page extends Model
*/
public function pickerData(array $params = []): array
{
$params['text'] ??= '{{ page.title }}';
$item = new PageItem(
page: $this->model,
image: $params['image'] ?? null,
info: $params['info'] ?? null,
layout: $params['layout'] ?? null,
text: $params['text'] ?? null,
);
return [
...parent::pickerData($params),
'dragText' => $this->dragText(),
...$item->props(),
'hasChildren' => $this->model->hasChildren(),
'url' => $this->model->url()
'sortable' => true
];
}

View file

@ -9,10 +9,13 @@ use Kirby\Cms\PageBlueprint;
use Kirby\Cms\PageRules;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Content\MemoryStorage;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* Manages the Panel dialog to create new pages
@ -34,6 +37,7 @@ class PageCreateDialog
protected string|null $slug;
protected string|null $template;
protected string|null $title;
protected string|null $uuid;
protected Page|Site|User|File $view;
protected string|null $viewId;
@ -69,6 +73,7 @@ class PageCreateDialog
// optional
string|null $slug = null,
string|null $title = null,
string|null $uuid = null,
) {
$this->parentId = $parentId ?? 'site';
$this->parent = Find::parent($this->parentId);
@ -76,6 +81,7 @@ class PageCreateDialog
$this->slug = $slug;
$this->template = $template;
$this->title = $title;
$this->uuid = $uuid;
$this->viewId = $viewId;
$this->view = Find::parent($this->viewId ?? $this->parentId);
}
@ -139,6 +145,13 @@ class PageCreateDialog
]);
}
// pass uuid field to the dialog if uuids are enabled
// to use the same uuid and prevent generating a new one
// when the page is created
if (Uuids::enabled() === true) {
$fields['uuid'] = Field::hidden();
}
return [
...$fields,
'parent' => Field::hidden(),
@ -154,7 +167,7 @@ class PageCreateDialog
public function customFields(): array
{
$custom = [];
$ignore = ['title', 'slug', 'parent', 'template'];
$ignore = ['title', 'slug', 'parent', 'template', 'uuid'];
$blueprint = $this->blueprint();
$fields = $blueprint->fields();
@ -255,12 +268,33 @@ class PageCreateDialog
*/
public function model(): Page
{
return $this->model ??= Page::factory([
if (isset($this->model) === true) {
return $this->model;
}
$props = [
'slug' => '__new__',
'template' => $this->template,
'model' => $this->template,
'parent' => $this->parent instanceof Page ? $this->parent : null
]);
];
// make sure that a UUID gets generated
// and added to content right away
if (Uuids::enabled() === true) {
$props['content'] = [
'uuid' => $this->uuid = Uuid::generate()
];
}
$this->model = Page::factory($props);
// change the storage to memory immediately
// since this is a temporary model
// so that the model does not write to disk
$this->model->changeStorage(MemoryStorage::class);
return $this->model;
}
/**
@ -294,10 +328,15 @@ class PageCreateDialog
{
$input['title'] ??= $this->title ?? '';
$input['slug'] ??= $this->slug ?? '';
$input['uuid'] ??= $this->uuid ?? null;
$input = $this->resolveFieldTemplates($input);
$content = ['title' => trim($input['title'])];
if ($uuid = $input['uuid'] ?? null) {
$content['uuid'] = $uuid;
}
foreach ($this->customFields() as $name => $field) {
$content[$name] = $input[$name] ?? null;
}
@ -377,6 +416,7 @@ class PageCreateDialog
'slug' => $this->slug ?? '',
'template' => $this->template,
'title' => $this->title ?? '',
'uuid' => $this->uuid,
'view' => $this->viewId,
];

View file

@ -84,7 +84,10 @@ abstract class Component
return [
'component' => $this->component,
'key' => $this->key(),
'props' => array_filter($this->props())
'props' => array_filter(
$this->props(),
fn ($prop) => $prop !== null
)
];
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Kirby\Panel\Ui\Item;
use Kirby\Cms\File;
use Kirby\Cms\ModelWithContent;
use Kirby\Panel\Model;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class FileItem extends ModelItem
{
/**
* @var \Kirby\Cms\File
*/
protected ModelWithContent $model;
/**
* @var \Kirby\Panel\File
*/
protected Model $panel;
public function __construct(
File $file,
protected bool $dragTextIsAbsolute = false,
string|array|false|null $image = [],
string|null $info = null,
string|null $layout = null,
string|null $text = null,
) {
parent::__construct(
model: $file,
image: $image,
info: $info,
layout: $layout,
text: $text ?? '{{ file.filename }}',
);
}
protected function dragText(): string
{
return $this->panel->dragText(absolute: $this->dragTextIsAbsolute);
}
protected function permissions(): array
{
$permissions = $this->model->permissions();
return [
'delete' => $permissions->can('delete'),
'sort' => $permissions->can('sort'),
];
}
public function props(): array
{
return [
...parent::props(),
'dragText' => $this->dragText(),
'extension' => $this->model->extension(),
'filename' => $this->model->filename(),
'mime' => $this->model->mime(),
'parent' => $this->model->parent()->panel()->path(),
'template' => $this->model->template(),
'url' => $this->model->url(),
];
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Kirby\Panel\Ui\Item;
use Kirby\Cms\ModelWithContent;
use Kirby\Panel\Model as Panel;
use Kirby\Panel\Ui\Component;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class ModelItem extends Component
{
protected string $layout;
protected Panel $panel;
protected string $text;
public function __construct(
protected ModelWithContent $model,
protected string|array|false|null $image = [],
protected string|null $info = null,
string|null $layout = null,
string|null $text = null,
) {
parent::__construct(component: 'k-item');
$this->layout = $layout ?? 'list';
$this->panel = $this->model->panel();
$this->text = $text ?? '{{ model.title }}';
}
protected function info(): string|null
{
return $this->model->toSafeString($this->info ?? false);
}
protected function image(): array|null
{
return $this->panel->image($this->image, $this->layout);
}
protected function link(): string
{
return $this->panel->url(true);
}
protected function permissions(): array
{
return $this->model->permissions()->toArray();
}
public function props(): array
{
return [
'id' => $this->model->id(),
'image' => $this->image(),
'info' => $this->info(),
'link' => $this->link(),
'permissions' => $this->permissions(),
'text' => $this->text(),
'uuid' => $this->model->uuid()?->toString(),
];
}
protected function text(): string
{
return $this->model->toSafeString($this->text);
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Kirby\Panel\Ui\Item;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Panel\Model;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class PageItem extends ModelItem
{
/**
* @var \Kirby\Cms\Page
*/
protected ModelWithContent $model;
/**
* @var \Kirby\Panel\Page
*/
protected Model $panel;
public function __construct(
Page $page,
string|array|false|null $image = [],
string|null $info = null,
string|null $layout = null,
string|null $text = null,
) {
parent::__construct(
model: $page,
image: $image,
info: $info,
layout: $layout,
text: $text ?? '{{ page.title }}',
);
}
protected function dragText(): string
{
return $this->panel->dragText();
}
protected function permissions(): array
{
$permissions = $this->model->permissions();
return [
'changeSlug' => $permissions->can('changeSlug'),
'changeStatus' => $permissions->can('changeStatus'),
'changeTitle' => $permissions->can('changeTitle'),
'delete' => $permissions->can('delete'),
'sort' => $permissions->can('sort'),
];
}
public function props(): array
{
return [
...parent::props(),
'dragText' => $this->dragText(),
'parent' => $this->model->parentId(),
'status' => $this->model->status(),
'template' => $this->model->intendedTemplate()->name(),
'url' => $this->model->url(),
];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Kirby\Panel\Ui\Item;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\User;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class UserItem extends ModelItem
{
/**
* @var \Kirby\Cms\User
*/
protected ModelWithContent $model;
public function __construct(
User $user,
string|array|false|null $image = [],
string|null $info = '{{ user.role.title }}',
string|null $layout = null,
string|null $text = null,
) {
parent::__construct(
model: $user,
image: $image,
info: $info,
layout: $layout,
text: $text ?? '{{ user.username }}',
);
}
}

View file

@ -0,0 +1,140 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\InvalidArgumentException;
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.1.0
*/
class Stat extends Component
{
public function __construct(
public array|string $label,
public array|string $value,
public string $component = 'k-stat',
public array|string|null $dialog = null,
public array|string|null $drawer = null,
public string|null $icon = null,
public array|string|null $info = null,
public array|string|null $link = null,
public ModelWithContent|null $model = null,
public string|null $theme = null,
) {
}
public function dialog(): string|null
{
return $this->stringTemplate(
$this->i18n($this->dialog)
);
}
public function drawer(): string|null
{
return $this->stringTemplate(
$this->i18n($this->drawer)
);
}
/**
* @psalm-suppress TooFewArguments
*/
public static function from(
array|string $input,
ModelWithContent|null $model = null,
): static {
if ($model !== null) {
if (is_string($input) === true) {
$input = $model->query($input);
if (is_array($input) === false) {
throw new InvalidArgumentException(
message: 'Invalid data from stat query. The query must return an array.'
);
}
}
$input['model'] = $model;
}
return new static(...$input);
}
public function icon(): string|null
{
return $this->stringTemplate($this->icon);
}
public function info(): string|null
{
return $this->stringTemplate(
$this->i18n($this->info)
);
}
public function label(): string
{
return $this->stringTemplate(
$this->i18n($this->label)
);
}
public function link(): string|null
{
return $this->stringTemplate(
$this->i18n($this->link)
);
}
public function props(): array
{
return [
'dialog' => $this->dialog(),
'drawer' => $this->drawer(),
'icon' => $this->icon(),
'info' => $this->info(),
'label' => $this->label(),
'link' => $this->link(),
'theme' => $this->theme(),
'value' => $this->value(),
];
}
protected function stringTemplate(string|null $string = null): string|null
{
if ($this->model === null) {
return $string;
}
if ($string !== null) {
return $this->model->toString($string);
}
return null;
}
public function theme(): string|null
{
return $this->stringTemplate($this->theme);
}
protected function i18n(string|array|null $param = null): string|null
{
return empty($param) === false ? I18n::translate($param, $param) : null;
}
public function value(): string
{
return $this->stringTemplate(
$this->i18n($this->value)
);
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\InvalidArgumentException;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class Stats extends Component
{
public function __construct(
public string $component = 'k-stats',
public ModelWithContent|null $model = null,
public array $reports = [],
public string $size = 'large',
) {
}
public static function from(
ModelWithContent $model,
array|string $reports,
string $size = 'large'
): static {
if (is_string($reports) === true) {
$reports = $model->query($reports);
if (is_array($reports) === false) {
throw new InvalidArgumentException(
message: 'Invalid data from stats query. The query must return an array.'
);
}
}
return new static(
model: $model,
reports: $reports,
size: $size
);
}
public function props(): array
{
return [
'reports' => $this->reports(),
'size' => $this->size(),
];
}
public function reports(): array
{
$reports = [];
foreach ($this->reports as $stat) {
// if not already a Stat object, convert it
if ($stat instanceof Stat === false) {
try {
$stat = Stat::from(
input: $stat,
model: $this->model
);
} catch (InvalidArgumentException) {
continue;
}
}
$reports[] = $stat->props();
}
return $reports;
}
public function size(): string
{
return $this->size;
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Kirby\Panel\Ui;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.1.0
*/
class Upload
{
public function __construct(
protected string $api,
protected string|null $accept = null,
protected array $attributes = [],
protected int|null $max = null,
protected bool $multiple = true,
protected array|bool|null $preview = null,
protected int|null $sort = null,
protected string|null $template = null,
) {
}
protected function attributes(): array
{
return [
...$this->attributes,
'sort' => $this->sort,
'template' => $this->template()
];
}
protected function max(): int|null
{
return $this->multiple() === false ? 1 : $this->max;
}
protected function multiple(): bool
{
return $this->multiple === true && ($this->max === null || $this->max > 1);
}
public function props(): array
{
return [
'accept' => $this->accept,
'api' => $this->api,
'attributes' => $this->attributes(),
'max' => $this->max(),
'multiple' => $this->multiple(),
'preview' => $this->preview,
];
}
protected function template(): string|null
{
return $this->template === 'default' ? null : $this->template;
}
}

View file

@ -8,6 +8,7 @@ use Kirby\Cms\Translation;
use Kirby\Cms\Url;
use Kirby\Filesystem\Asset;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Panel\Ui\Item\UserItem;
use Kirby\Toolkit\I18n;
/**
@ -200,11 +201,18 @@ class User extends Model
*/
public function pickerData(array $params = []): array
{
$params['text'] ??= '{{ user.username }}';
$item = new UserItem(
user: $this->model,
image: $params['image'] ?? null,
info: $params['info'] ?? null,
layout: $params['layout'] ?? null,
text: $params['text'] ?? null,
);
return [
...parent::pickerData($params),
...$item->props(),
'email' => $this->model->email(),
'sortable' => true,
'username' => $this->model->username(),
];
}

View file

@ -266,6 +266,7 @@ class View
],
'debug' => $kirby->option('debug', false),
'kirbytext' => $kirby->option('panel.kirbytext', true),
'theme' => $kirby->option('panel.theme', 'system'),
'translation' => $kirby->option('panel.language', 'en'),
'upload' => Upload::chunkSize(),
],

View file

@ -0,0 +1,37 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a list of (method) arguments in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class ArgumentListNode extends Node
{
public function __construct(
public array $arguments = []
) {
}
public function resolve(Visitor $visitor): array|string
{
// Resolve each argument
$arguments = array_map(
fn ($argument) => $argument->resolve($visitor),
$this->arguments
);
// Keep as array or convert to string
// depending on the visitor type
return $visitor->arguments($arguments);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents an arithmetic operation between two values in the AST
*
* @package Kirby Query
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class ArithmeticNode extends Node
{
public function __construct(
public Node $left,
public string $operator,
public Node $right
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->arithmetic(
left: $this->left->resolve($visitor),
operator: $this->operator,
right: $this->right->resolve($visitor)
);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a (array) list of elements in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class ArrayListNode extends Node
{
public function __construct(
public array $elements,
) {
}
public function resolve(Visitor $visitor): array|string
{
// Resolve each array element
$elements = array_map(
fn ($element) => $element->resolve($visitor),
$this->elements
);
// Keep as array or convert to string
// depending on the visitor type
return $visitor->arrayList($elements);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a closure in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class ClosureNode extends Node
{
/**
* @param string[] $arguments The arguments names
*/
public function __construct(
public array $arguments,
public Node $body,
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->closure($this);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a coalesce operation in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class CoalesceNode extends Node
{
public function __construct(
public Node $left,
public Node $right,
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->coalescence(
left: $this->left->resolve($visitor),
right: $this->right->resolve($visitor)
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a comparison operation between two values in the AST
*
* @package Kirby Query
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class ComparisonNode extends Node
{
public function __construct(
public Node $left,
public string $operator,
public Node $right
) {
}
public function resolve(Visitor $visitor): bool|string
{
return $visitor->comparison(
left: $this->left->resolve($visitor),
operator: $this->operator,
right: $this->right->resolve($visitor)
);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a global function call in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class GlobalFunctionNode extends Node
{
public function __construct(
public string $name,
public ArgumentListNode $arguments,
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->function(
name: $this->name,
arguments: $this->arguments->resolve($visitor)
);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents literal values (e.g. string, int, bool) in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class LiteralNode extends Node
{
public function __construct(
public mixed $value,
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->literal($this->value);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a logical operation between two values in the AST
*
* @package Kirby Query
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class LogicalNode extends Node
{
public function __construct(
public Node $left,
public string $operator,
public Node $right
) {
}
public function resolve(Visitor $visitor): bool|string
{
return $visitor->logical(
left: $this->left->resolve($visitor),
operator: $this->operator,
right: $this->right->resolve($visitor)
);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents the access (e.g. method call) on a node in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class MemberAccessNode extends Node
{
public function __construct(
public Node $object,
public Node $member,
public ArgumentListNode|null $arguments = null,
public bool $nullSafe = false,
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->memberAccess(
object: $this->object->resolve($visitor),
member: $this->member->resolve($visitor),
arguments: $this->arguments?->resolve($visitor),
nullSafe: $this->nullSafe
);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Basic node representation in the query AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*
* @codeCoverageIgnore
*/
abstract class Node
{
abstract public function resolve(Visitor $visitor);
}

View file

@ -0,0 +1,36 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a ternary condition in the AST,
* with a value for when the condition is true
* and another value for when the condition is false
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class TernaryNode extends Node
{
public function __construct(
public Node $condition,
public Node $false,
public Node|null $true = null
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->ternary(
condition: $this->condition->resolve($visitor),
true: $this->true?->resolve($visitor),
false: $this->false->resolve($visitor)
);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Query\AST;
use Kirby\Query\Visitors\Visitor;
/**
* Represents a variable (e.g. an object) in the AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class VariableNode extends Node
{
public function __construct(
public string $name,
) {
}
public function resolve(Visitor $visitor): mixed
{
return $visitor->variable($this->name);
}
}

View file

@ -14,6 +14,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @todo Deprecate in v6
*/
class Argument
{

View file

@ -15,6 +15,8 @@ use Kirby\Toolkit\Collection;
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @todo Deprecate in v6
*
* @extends \Kirby\Toolkit\Collection<\Kirby\Query\Argument>
*/
class Arguments extends Collection

View file

@ -14,6 +14,8 @@ use Kirby\Toolkit\A;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @todo Deprecate in v6
*/
class Expression
{

View file

@ -0,0 +1,476 @@
<?php
namespace Kirby\Query\Parser;
use Exception;
use Iterator;
use Kirby\Query\AST\ArgumentListNode;
use Kirby\Query\AST\ArithmeticNode;
use Kirby\Query\AST\ArrayListNode;
use Kirby\Query\AST\ClosureNode;
use Kirby\Query\AST\CoalesceNode;
use Kirby\Query\AST\ComparisonNode;
use Kirby\Query\AST\GlobalFunctionNode;
use Kirby\Query\AST\LiteralNode;
use Kirby\Query\AST\LogicalNode;
use Kirby\Query\AST\MemberAccessNode;
use Kirby\Query\AST\Node;
use Kirby\Query\AST\TernaryNode;
use Kirby\Query\AST\VariableNode;
/**
* Parses query string by first splitting it into tokens
* and then matching and consuming tokens to create
* an abstract syntax tree (AST) of matching nodes
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class Parser
{
protected Token $current;
protected Token|null $previous = null;
/**
* @var Iterator<Token>
*/
protected Iterator $tokens;
public function __construct(string|Iterator $query)
{
if (is_string($query) === true) {
$tokenizer = new Tokenizer($query);
$query = $tokenizer->tokens();
}
$this->tokens = $query;
$this->current = $this->tokens->current();
}
/**
* Move to the next token
*/
protected function advance(): Token|null
{
if ($this->isAtEnd() === false) {
$this->previous = $this->current;
$this->tokens->next();
$this->current = $this->tokens->current();
}
return $this->previous;
}
/**
* Parses an array
*/
private function array(): ArrayListNode|null
{
if ($this->consume(TokenType::T_OPEN_BRACKET)) {
return new ArrayListNode(
elements: $this->consumeList(TokenType::T_CLOSE_BRACKET)
);
}
return null;
}
/**
* Parses a list of arguments
*/
private function argumentList(): ArgumentListNode
{
return new ArgumentListNode(
arguments: $this->consumeList(TokenType::T_CLOSE_PAREN)
);
}
/**
* Checks for and parses several atomic expressions
*/
private function atomic(): Node
{
$token = $this->scalar();
$token ??= $this->array();
$token ??= $this->identifier();
$token ??= $this->grouping();
if ($token === null) {
throw new Exception('Expect expression'); // @codeCoverageIgnore
}
return $token;
}
/**
* Checks for and parses a coalesce expression
*/
private function coalesce(): Node
{
$node = $this->logical();
while ($this->consume(TokenType::T_COALESCE)) {
$node = new CoalesceNode(
left: $node,
right: $this->logical()
);
}
return $node;
}
/**
* Collect the next token of a type
*
* @throws \Exception when next token is not of specified type
*/
protected function consume(
TokenType $type,
string|false $error = false
): Token|false {
if ($this->is($type) === true) {
return $this->advance();
}
if (is_string($error) === true) {
throw new Exception($error);
}
return false;
}
/**
* Move to next token if of any specific type
*/
protected function consumeAny(array $types): Token|false
{
foreach ($types as $type) {
if ($this->is($type) === true) {
return $this->advance();
}
}
return false;
}
/**
* Collect all list element until closing token
*/
private function consumeList(TokenType $until): array
{
$elements = [];
while (
$this->isAtEnd() === false &&
$this->is($until) === false
) {
$elements[] = $this->expression();
if ($this->consume(TokenType::T_COMMA) === false) {
break;
}
}
// consume the closing token
$this->consume($until, 'Expect closing bracket after list');
return $elements;
}
/**
* Returns the current token
*/
public function current(): Token
{
return $this->current;
}
/**
* Convert a full query expression into a node
*/
private function expression(): Node
{
// Top-level expression should be ternary
return $this->ternary();
}
/**
* Parses comparison expressions with proper precedence
*/
private function comparison(): Node
{
$left = $this->arithmetic();
while ($token = $this->consumeAny([
TokenType::T_EQUAL,
TokenType::T_IDENTICAL,
TokenType::T_NOT_EQUAL,
TokenType::T_NOT_IDENTICAL,
TokenType::T_LESS_THAN,
TokenType::T_LESS_EQUAL,
TokenType::T_GREATER_THAN,
TokenType::T_GREATER_EQUAL
])) {
$left = new ComparisonNode(
left: $left,
operator: $token->lexeme,
right: $this->arithmetic()
);
}
return $left;
}
/**
* Parses a grouping (e.g. closure)
*/
private function grouping(): ClosureNode|Node|null
{
if ($this->consume(TokenType::T_OPEN_PAREN)) {
$list = $this->consumeList(TokenType::T_CLOSE_PAREN);
if ($this->consume(TokenType::T_ARROW)) {
$expression = $this->expression();
/**
* Assert that all elements are VariableNodes
* @var VariableNode[] $list
*/
foreach ($list as $element) {
if ($element instanceof VariableNode === false) {
throw new Exception('Expecting only variables in closure argument list');
}
}
$arguments = array_map(fn ($element) => $element->name, $list);
return new ClosureNode(
arguments: $arguments,
body: $expression
);
}
if (count($list) > 1) {
throw new Exception('Expecting "=>" after closure argument list');
}
// this is just a grouping
return $list[0];
}
return null;
}
/**
* Parses an identifier (global functions or variables)
*/
private function identifier(): GlobalFunctionNode|VariableNode|null
{
if ($token = $this->consume(TokenType::T_IDENTIFIER)) {
if ($this->consume(TokenType::T_OPEN_PAREN)) {
return new GlobalFunctionNode(
name: $token->lexeme,
arguments: $this->argumentList()
);
}
return new VariableNode(name: $token->lexeme);
}
return null;
}
/**
* Whether the current token is of a specific type
*/
protected function is(TokenType $type): bool
{
if ($this->isAtEnd() === true) {
return false;
}
return $this->current->is($type);
}
/**
* Whether the parser has reached the end of the query
*/
protected function isAtEnd(): bool
{
return $this->current->is(TokenType::T_EOF);
}
/**
* Checks for and parses a member access expression
*/
private function memberAccess(): Node
{
$object = $this->atomic();
while ($token = $this->consumeAny([
TokenType::T_DOT,
TokenType::T_NULLSAFE,
TokenType::T_OPEN_BRACKET
])) {
if ($token->is(TokenType::T_OPEN_BRACKET) === true) {
// For subscript notation, parse the inside as expression…
$member = $this->expression();
// …and ensure consuming the closing bracket
$this->consume(
TokenType::T_CLOSE_BRACKET,
'Expect subscript closing bracket'
);
} elseif ($member = $this->consume(TokenType::T_IDENTIFIER)) {
$member = new LiteralNode($member->lexeme);
} elseif ($member = $this->consume(TokenType::T_INTEGER)) {
$member = new LiteralNode($member->literal);
} else {
throw new Exception('Expect property name after "."');
}
$object = new MemberAccessNode(
object: $object,
member: $member,
arguments: match ($this->consume(TokenType::T_OPEN_PAREN)) {
false => null,
default => $this->argumentList(),
},
nullSafe: $token->is(TokenType::T_NULLSAFE)
);
}
return $object;
}
/**
* Parses arithmetic expressions with proper precedence
*/
private function arithmetic(): Node
{
$left = $this->term();
while ($token = $this->consumeAny([
TokenType::T_PLUS,
TokenType::T_MINUS
])) {
$left = new ArithmeticNode(
left: $left,
operator: $token->lexeme,
right: $this->term()
);
}
return $left;
}
/**
* Parses multiplication, division, and modulo expressions
*/
private function term(): Node
{
$left = $this->memberAccess();
while ($token = $this->consumeAny([
TokenType::T_MULTIPLY,
TokenType::T_DIVIDE,
TokenType::T_MODULO
])) {
$left = new ArithmeticNode(
left: $left,
operator: $token->lexeme,
right: $this->memberAccess()
);
}
return $left;
}
/**
* Parses logical expressions with proper precedence
*/
private function logical(): Node
{
$left = $this->comparison();
while ($token = $this->consumeAny([
TokenType::T_AND,
TokenType::T_OR
])) {
$left = new LogicalNode(
left: $left,
operator: $token->lexeme,
right: $this->comparison()
);
}
return $left;
}
/**
* Parses the tokenized query into AST node tree
*/
public function parse(): Node
{
// Start parsing chain
$expression = $this->expression();
// Ensure that we consumed all tokens
if ($this->isAtEnd() === false) {
$this->consume(TokenType::T_EOF, 'Expect end of expression'); // @codeCoverageIgnore
}
return $expression;
}
private function scalar(): LiteralNode|null
{
if ($token = $this->consumeAny([
TokenType::T_TRUE,
TokenType::T_FALSE,
TokenType::T_NULL,
TokenType::T_STRING,
TokenType::T_INTEGER,
TokenType::T_FLOAT,
])) {
return new LiteralNode(value: $token->literal);
}
return null;
}
/**
* Checks for and parses a ternary expression
* (full `a ? b : c` or elvis shorthand `a ?: c`)
*/
private function ternary(): Node
{
$condition = $this->coalesce();
if ($token = $this->consumeAny([
TokenType::T_QUESTION_MARK,
TokenType::T_TERNARY_DEFAULT
])) {
if ($token->is(TokenType::T_TERNARY_DEFAULT) === false) {
$true = $this->expression();
$this->consume(
type: TokenType::T_COLON,
error: 'Expect ":" after true branch'
);
}
return new TernaryNode(
condition: $condition,
true: $true ?? null,
false: $this->expression()
);
}
return $condition;
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Kirby\Query\Parser;
/**
* Represents a single token of a particular type
* within a query
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class Token
{
public function __construct(
public TokenType $type,
public string $lexeme,
public mixed $literal = null,
) {
}
public function is(TokenType $type): bool
{
return $this->type === $type;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace Kirby\Query\Parser;
/**
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
enum TokenType
{
case T_DOT;
case T_COLON;
case T_QUESTION_MARK;
case T_OPEN_PAREN;
case T_CLOSE_PAREN;
case T_OPEN_BRACKET;
case T_CLOSE_BRACKET;
case T_TERNARY_DEFAULT; // ?:
case T_NULLSAFE; // ?.
case T_COALESCE; // ??
case T_COMMA;
case T_ARROW;
case T_WHITESPACE;
case T_EOF;
// Comparison operators
case T_EQUAL; // ==
case T_IDENTICAL; // ===
case T_NOT_EQUAL; // !=
case T_NOT_IDENTICAL; // !==
case T_LESS_THAN; // <
case T_LESS_EQUAL; // <=
case T_GREATER_THAN; // >
case T_GREATER_EQUAL; // >=
// Math operators
case T_PLUS; // +
case T_MINUS; // -
case T_MULTIPLY; // *
case T_DIVIDE; // /
case T_MODULO; // %
// Logical operators
case T_AND; // AND or &&
case T_OR; // OR or ||
// Literals
case T_STRING;
case T_INTEGER;
case T_FLOAT;
case T_TRUE;
case T_FALSE;
case T_NULL;
case T_IDENTIFIER;
}

View file

@ -0,0 +1,256 @@
<?php
namespace Kirby\Query\Parser;
use Exception;
use Generator;
/**
* Parses a query string into its individual tokens
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class Tokenizer
{
private int $length = 0;
/**
* The more complex regexes are written here in nowdoc format
* so we don't need to double or triple escape backslashes
* (that becomes ridiculous rather fast).
*
* Identifiers can contain letters, numbers and underscores.
* They can't start with a number.
* For more complex identifier strings, subscript member access
* should be used. With `this` to access the global context.
*/
private const IDENTIFIER_REGEX = <<<'REGEX'
(?:[\p{L}\p{N}_])*
REGEX;
private const SINGLEQUOTE_STRING_REGEX = <<<'REGEX'
'([^'\\]*(?:\\.[^'\\]*)*)'
REGEX;
private const DOUBLEQUOTE_STRING_REGEX = <<<'REGEX'
"([^"\\]*(?:\\.[^"\\]*)*)"
REGEX;
public function __construct(
private readonly string $query,
) {
$this->length = mb_strlen($query);
}
/**
* Matches a regex pattern at the current position in the query string.
* The matched lexeme will be stored in the $lexeme variable.
*
* @param int $offset Current position in the query string
* @param string $regex Regex pattern without delimiters/flags
*/
public static function match(
string $query,
int $offset,
string $regex,
bool $caseInsensitive = false
): string|null {
// Add delimiters and flags to the regex
$regex = '/\G' . $regex . '/u';
if ($caseInsensitive === true) {
$regex .= 'i';
}
if (preg_match($regex, $query, $matches, 0, $offset) !== 1) {
return null;
}
return $matches[0];
}
/**
* Scans the source string for a next token
* starting from the given position
*
* @param int $current The current position in the source string
*
* @throws \Exception If an unexpected character is encountered
*/
public static function token(string $query, int $current): Token
{
$char = $query[$current];
// Multi character tokens (check these first):
// Whitespace
if ($lex = static::match($query, $current, '\s+')) {
return new Token(TokenType::T_WHITESPACE, $lex);
}
// true
if ($lex = static::match($query, $current, 'true', true)) {
return new Token(TokenType::T_TRUE, $lex, true);
}
// false
if ($lex = static::match($query, $current, 'false', true)) {
return new Token(TokenType::T_FALSE, $lex, false);
}
// null
if ($lex = static::match($query, $current, 'null', true)) {
return new Token(TokenType::T_NULL, $lex, null);
}
// "string"
if ($lex = static::match($query, $current, static::DOUBLEQUOTE_STRING_REGEX)) {
return new Token(
TokenType::T_STRING,
$lex,
stripcslashes(substr($lex, 1, -1))
);
}
// 'string'
if ($lex = static::match($query, $current, static::SINGLEQUOTE_STRING_REGEX)) {
return new Token(
TokenType::T_STRING,
$lex,
stripcslashes(substr($lex, 1, -1))
);
}
// float (check before single character tokens)
$lex = static::match($query, $current, '-?\d+\.\d+\b');
if ($lex !== null) {
return new Token(TokenType::T_FLOAT, $lex, (float)$lex);
}
// int (check before single character tokens)
$lex = static::match($query, $current, '-?\d+\b');
if ($lex !== null) {
return new Token(TokenType::T_INTEGER, $lex, (int)$lex);
}
// Two character tokens:
// ??
if ($lex = static::match($query, $current, '\?\?')) {
return new Token(TokenType::T_COALESCE, $lex);
}
// ?.
if ($lex = static::match($query, $current, '\?\s*\.')) {
return new Token(TokenType::T_NULLSAFE, $lex);
}
// ?:
if ($lex = static::match($query, $current, '\?\s*:')) {
return new Token(TokenType::T_TERNARY_DEFAULT, $lex);
}
// =>
if ($lex = static::match($query, $current, '=>')) {
return new Token(TokenType::T_ARROW, $lex);
}
// Logical operators (check before comparison operators)
if ($lex = static::match($query, $current, '&&|AND')) {
return new Token(TokenType::T_AND, $lex);
}
if ($lex = static::match($query, $current, '\|\||OR')) {
return new Token(TokenType::T_OR, $lex);
}
// Comparison operators (three characters first, then two, then one)
// === (must come before ==)
if ($lex = static::match($query, $current, '===')) {
return new Token(TokenType::T_IDENTICAL, $lex);
}
// !== (must come before !=)
if ($lex = static::match($query, $current, '!==')) {
return new Token(TokenType::T_NOT_IDENTICAL, $lex);
}
// <= (must come before <)
if ($lex = static::match($query, $current, '<=')) {
return new Token(TokenType::T_LESS_EQUAL, $lex);
}
// >= (must come before >)
if ($lex = static::match($query, $current, '>=')) {
return new Token(TokenType::T_GREATER_EQUAL, $lex);
}
// ==
if ($lex = static::match($query, $current, '==')) {
return new Token(TokenType::T_EQUAL, $lex);
}
// !=
if ($lex = static::match($query, $current, '!=')) {
return new Token(TokenType::T_NOT_EQUAL, $lex);
}
// Single character tokens (check these last):
$token = match ($char) {
'.' => new Token(TokenType::T_DOT, '.'),
'(' => new Token(TokenType::T_OPEN_PAREN, '('),
')' => new Token(TokenType::T_CLOSE_PAREN, ')'),
'[' => new Token(TokenType::T_OPEN_BRACKET, '['),
']' => new Token(TokenType::T_CLOSE_BRACKET, ']'),
',' => new Token(TokenType::T_COMMA, ','),
':' => new Token(TokenType::T_COLON, ':'),
'+' => new Token(TokenType::T_PLUS, '+'),
'-' => new Token(TokenType::T_MINUS, '-'),
'*' => new Token(TokenType::T_MULTIPLY, '*'),
'/' => new Token(TokenType::T_DIVIDE, '/'),
'%' => new Token(TokenType::T_MODULO, '%'),
'?' => new Token(TokenType::T_QUESTION_MARK, '?'),
'<' => new Token(TokenType::T_LESS_THAN, '<'),
'>' => new Token(TokenType::T_GREATER_THAN, '>'),
default => null
};
if ($token !== null) {
return $token;
}
// Identifier
if ($lex = static::match($query, $current, static::IDENTIFIER_REGEX)) {
return new Token(TokenType::T_IDENTIFIER, $lex);
}
// Unknown token
throw new Exception('Invalid character in query: ' . $query[$current]);
}
/**
* Tokenizes the query string and returns a generator of tokens.
* @return Generator<Token>
*/
public function tokens(): Generator
{
$current = 0;
while ($current < $this->length) {
$token = static::token($this->query, $current);
// Don't yield whitespace tokens (ignore them)
if ($token->type !== TokenType::T_WHITESPACE) {
yield $token;
}
$current += mb_strlen($token->lexeme);
}
yield new Token(TokenType::T_EOF, '', null);
}
}

View file

@ -9,35 +9,29 @@ use Kirby\Cms\File;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Image\QrCode;
use Kirby\Query\Runners\Runner;
use Kirby\Toolkit\I18n;
/**
* The Query class can be used to query arrays and objects,
* including their methods with a very simple string-based syntax.
*
* Namespace structure - what handles what:
* - Query Main interface, direct entries
* - Expression Simple comparisons (`a ? b :c`)
* - Segments Chain of method calls (`site.find('notes').url`)
* - Segment Single method call (`find('notes')`)
* - Arguments Method call parameters (`'template', '!=', 'note'`)
* - Argument Single parameter, resolving into actual types
* The Query class can be used to run expressions on arrays and objects,
* including their methods with a very simple string-based syntax
*
* @package Kirby Query
* @author Bastian Allgeier <bastian@getkirby.com>,
* Nico Hoffmann <nico@getkirby.com>
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Query
{
/**
* Default data entries
*/
public static array $cache = [];
public static array $entries = [];
public Runner|string $runner;
/**
* Creates a new Query object
*/
@ -47,6 +41,17 @@ class Query
if ($query !== null) {
$this->query = trim($query);
}
$this->runner = App::instance()->option('query.runner', 'legacy');
if ($this->runner !== 'legacy') {
if (is_subclass_of($this->runner, Runner::class) === false) {
throw new InvalidArgumentException("Query runner $this->runner must extend " . Runner::class);
}
$this->runner = $this->runner::for($this);
}
}
/**
@ -71,6 +76,7 @@ class Query
* can be found, otherwise returns null
*
* @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query
* @throws \Kirby\Exception\InvalidArgumentException If an invalid query runner is set in the config option
*/
public function resolve(array|object $data = []): mixed
{
@ -78,6 +84,24 @@ class Query
return $data;
}
// TODO: switch to 'interpreted' as default in v6
// TODO: remove in v7
// @codeCoverageIgnoreStart
if ($this->runner === 'legacy') {
return $this->resolveLegacy($data);
}
// @codeCoverageIgnoreEnd
return $this->runner->run($this->query, (array)$data);
}
/**
* @deprecated 5.1.0
* @codeCoverageIgnore
*/
private function resolveLegacy(array|object $data = []): mixed
{
// merge data with default entries
if (is_array($data) === true) {
$data = [...static::$entries, ...$data];

View file

@ -0,0 +1,69 @@
<?php
namespace Kirby\Query\Runners;
use Closure;
use Kirby\Query\Parser\Parser;
use Kirby\Query\Query;
use Kirby\Query\Visitors\DefaultVisitor;
/**
* Runner that caches the AST in memory
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class DefaultRunner extends Runner
{
/**
* Creates a runner for the Query
*/
public static function for(Query $query): static
{
return new static(
global: $query::$entries,
interceptor: $query->intercept(...),
cache: $query::$cache
);
}
protected function resolver(string $query): Closure
{
// Load closure from cache
if (isset($this->cache[$query]) === true) {
return $this->cache[$query];
}
// Parse query as AST
$parser = new Parser($query);
$ast = $parser->parse();
// Cache closure to resolve same query
return $this->cache[$query] = fn (array $context) => $ast->resolve(
new DefaultVisitor($this->global, $context, $this->interceptor)
);
}
/**
* Executes a query within a given data context
*
* @param array $context Optional variables to be passed to the query
*
* @throws \Exception when query is invalid or executor not callable
*/
public function run(string $query, array $context = []): mixed
{
// Try resolving query directly from data context or global functions
$entry = Scope::get($query, $context, $this->global, false);
if ($entry !== false) {
return $entry;
}
return $this->resolver($query)($context);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Kirby\Query\Runners;
use ArrayAccess;
use Closure;
use Kirby\Query\Query;
/**
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
abstract class Runner
{
/**
* @param array $global Allowed global function closures
*/
public function __construct(
public array $global = [],
protected Closure|null $interceptor = null,
protected ArrayAccess|array &$cache = [],
) {
}
/**
* Creates a runner instance for the Query
*/
abstract public static function for(Query $query): static;
/**
* Executes a query within a given data context
*
* @param array $context Optional variables to be passed to the query
*
* @throws \Exception when query is invalid or executor not callable
*/
abstract public function run(string $query, array $context = []): mixed;
}

View file

@ -0,0 +1,94 @@
<?php
namespace Kirby\Query\Runners;
use Closure;
use Exception;
/**
* Helper class to execute logic during runtime
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class Scope
{
/**
* Access the key on the object/array during runtime
*/
public static function access(
array|object|null $object,
string|int $key,
bool $nullSafe = false,
...$arguments
): mixed {
if ($object === null && $nullSafe === true) {
return null;
}
if (is_array($object) === true) {
if ($item = $object[$key] ?? $object[(string)$key] ?? null) {
if ($arguments) {
return $item(...$arguments);
}
if ($item instanceof Closure) {
return $item();
}
}
return $item;
}
if (is_object($object) === true) {
$key = (string)$key;
if (
method_exists($object, $key) === true ||
method_exists($object, '__call') === true
) {
return $object->$key(...$arguments);
}
return $object->$key ?? null;
}
throw new Exception("Cannot access \"$key\" on " . gettype($object));
}
/**
* Resolves a mapping from global context or functions during runtime
*/
public static function get(
string $name,
array $context = [],
array $global = [],
false|null $fallback = null
): mixed {
// What looks like a variable might actually be a global function
// but if there is a variable with the same name,
// the variable takes precedence
if (isset($context[$name]) === true) {
if ($context[$name] instanceof Closure) {
return $context[$name]();
}
return $context[$name];
}
if (isset($global[$name]) === true) {
return $global[$name]();
}
// Alias to access the global context
if ($name === 'this') {
return $context;
}
return $fallback;
}
}

View file

@ -16,6 +16,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @todo Deprecate in v6
*/
class Segment
{

View file

@ -15,6 +15,8 @@ use Kirby\Toolkit\Collection;
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @todo Deprecate in v6
*
* @extends \Kirby\Toolkit\Collection<\Kirby\Query\Segment>
*/
class Segments extends Collection

View file

@ -0,0 +1,188 @@
<?php
namespace Kirby\Query\Visitors;
use Closure;
use Exception;
use Kirby\Query\AST\ClosureNode;
use Kirby\Query\Runners\Scope;
/**
* Processes a query AST
*
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*/
class DefaultVisitor extends Visitor
{
/**
* Processes list of arguments
*/
public function arguments(array $arguments): array
{
return $arguments;
}
/**
* Processes arithmetic operation
*/
public function arithmetic(
int|float $left,
string $operator,
int|float $right
): mixed {
return match ($operator) {
'+' => $left + $right,
'-' => $left - $right,
'*' => $left * $right,
'/' => $left / $right,
'%' => $left % $right,
default => throw new Exception("Unknown arithmetic operator: $operator")
};
}
/**
* Processes array
*/
public function arrayList(array $elements): array
{
return $elements;
}
/**
* Processes node into actual closure
*/
public function closure(ClosureNode $node): Closure
{
$self = $this;
return function (...$params) use ($self, $node) {
// [key1, key2] + [value1, value2] =>
// [key1 => value1, key2 => value2]
$arguments = array_combine(
$node->arguments,
$params
);
// Create new nested visitor with combined
// data context for resolving the closure body
$visitor = new static(
global: $self->global,
context: [...$self->context, ...$arguments],
interceptor: $self->interceptor
);
return $node->body->resolve($visitor);
};
}
/**
* Processes coalescence operator
*/
public function coalescence(mixed $left, mixed $right): mixed
{
return $left ?? $right;
}
/**
* Processes comparison operation
*/
public function comparison(
mixed $left,
string $operator,
mixed $right
): bool {
return match ($operator) {
'==' => $left == $right,
'===' => $left === $right,
'!=' => $left != $right,
'!==' => $left !== $right,
'<' => $left < $right,
'<=' => $left <= $right,
'>' => $left > $right,
'>=' => $left >= $right,
default => throw new Exception("Unknown comparison operator: $operator")
};
}
/**
* Processes global function
*/
public function function(string $name, array $arguments = []): mixed
{
$function = $this->global[$name] ?? null;
if ($function === null) {
throw new Exception("Invalid global function in query: $name");
}
return $function(...$arguments);
}
/**
* Processes literals
*/
public function literal(mixed $value): mixed
{
return $value;
}
/**
* Processes logical operation
*/
public function logical(
mixed $left,
string $operator,
mixed $right
): bool {
return match ($operator) {
'&&', 'AND' => $left && $right,
'||', 'OR' => $left || $right,
default => throw new Exception("Unknown logical operator: $operator")
};
}
/**
* Processes member access
*/
public function memberAccess(
mixed $object,
string|int $member,
array|null $arguments = null,
bool $nullSafe = false
): mixed {
if ($this->interceptor !== null) {
$object = ($this->interceptor)($object);
}
return Scope::access($object, $member, $nullSafe, ...$arguments ?? []);
}
/**
* Processes ternary operator
*/
public function ternary(
mixed $condition,
mixed $true,
mixed $false
): mixed {
if ($true === null) {
return $condition ?: $false;
}
return $condition ? $true : $false;
}
/**
* Get variable from context or global function
*/
public function variable(string $name): mixed
{
return Scope::get($name, $this->context, $this->global);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Kirby\Query\Visitors;
use Closure;
/**
* @package Kirby Query
* @author Roman Steiner <roman@toastlab.ch>,
* Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @license https://opensource.org/licenses/MIT
* @since 5.1.0
* @unstable
*
* Every visitor class must implement the following methods.
* As PHP won't allow increasing the typing specificity, we
* aren't actually adding them here in the abstract class, so that
* the actual visitor classes can work with much more specific type hints.
*
* @method mixed arguments(array $arguments)
* @method mixed arithmetic(mixed $left, string $operator, mixed $right)
* @method mixed arrayList(array $elements)
* @method mixed closure($ClosureNode $node))
* @method mixed coalescence($left, $right)
* @method mixed comparison(mixed $left, string $operator, mixed $right)
* @method mixed function($name, $arguments)
* @method mixed literal($value)
* @method mixed logical(mixed $left, string $operator, mixed $right)
* @method mixed memberAccess($object, string|int $member, $arguments, bool $nullSafe = false)
* @method mixed ternary($condition, $true, $false)
* @method mixed variable(string $name)
*/
abstract class Visitor
{
/**
* @param array<string,Closure> $global valid global function closures
* @param array<string,mixed> $context data bindings for the query
*/
public function __construct(
public array $global = [],
public array $context = [],
protected Closure|null $interceptor = null
) {
}
}

View file

@ -26,6 +26,7 @@ class AutoSession
* - `durationNormal`: Duration of normal sessions in seconds; defaults to 2 hours
* - `durationLong`: Duration of "remember me" sessions in seconds; defaults to 2 weeks
* - `timeout`: Activity timeout in seconds (integer or false for none); *only* used for normal sessions; defaults to `1800` (half an hour)
* - `cookieDomain`: Domain to set the cookie to (this disables the cookie path restriction); defaults to none (default browser behavior)
* - `cookieName`: Name to use for the session cookie; defaults to `kirby_session`
* - `gcInterval`: How often should the garbage collector be run?; integer or `false` for never; defaults to `100`
*/
@ -38,6 +39,7 @@ class AutoSession
'durationNormal' => 7200,
'durationLong' => 1209600,
'timeout' => 1800,
'cookieDomain' => null,
'cookieName' => 'kirby_session',
'gcInterval' => 100,
...$options
@ -45,8 +47,9 @@ class AutoSession
// create an internal instance of the low-level Sessions class
$this->sessions = new Sessions($store, [
'cookieName' => $this->options['cookieName'],
'gcInterval' => $this->options['gcInterval']
'cookieDomain' => $this->options['cookieDomain'],
'cookieName' => $this->options['cookieName'],
'gcInterval' => $this->options['gcInterval']
]);
}

Some files were not shown because too many files have changed in this diff Show more