Upgrade to 4.2.0

This commit is contained in:
Bastian Allgeier
2024-04-10 11:09:52 +02:00
parent 77d9337371
commit 7f4eb7509d
88 changed files with 1187 additions and 490 deletions

View File

@@ -71,7 +71,7 @@ class Api extends BaseApi
$field = Form::for($model)->field($name);
$fieldApi = $this->clone([
'data' => array_merge($this->data(), ['field' => $field]),
'data' => [...$this->data(), 'field' => $field],
'routes' => $field->api(),
]);
@@ -185,6 +185,30 @@ class Api extends BaseApi
return $pages->query($this->requestBody());
}
/**
* @throws \Kirby\Exception\NotFoundException if the section type cannot be found or the section cannot be loaded
*/
public function sectionApi(
ModelWithContent $model,
string $name,
string|null $path = null
): mixed {
if (!$section = $model->blueprint()?->section($name)) {
throw new NotFoundException('The section "' . $name . '" could not be found');
}
$sectionApi = $this->clone([
'data' => [...$this->data(), 'section' => $section],
'routes' => $section->api(),
]);
return $sectionApi->call(
$path,
$this->requestMethod(),
$this->requestData()
);
}
/**
* Returns the current Session instance
*

View File

@@ -659,6 +659,11 @@ trait AppPlugins
*/
protected function extensionsFromSystem(): void
{
// Always start with fresh fields and sections
// from the core and add plugins on top of that
FormField::$types = [];
Section::$types = [];
// mixins
FormField::$mixins = $this->core->fieldMixins();
Section::$mixins = $this->core->sectionMixins();
@@ -674,8 +679,8 @@ trait AppPlugins
$this->extendCacheTypes($this->core->cacheTypes());
$this->extendComponents($this->core->components());
$this->extendBlueprints($this->core->blueprints());
$this->extendFields($this->core->fields());
$this->extendFieldMethods($this->core->fieldMethods());
$this->extendFields($this->core->fields());
$this->extendSections($this->core->sections());
$this->extendSnippets($this->core->snippets());
$this->extendTags($this->core->kirbyTags());

View File

@@ -34,6 +34,8 @@ class Blueprint
protected $sections = [];
protected $tabs = [];
protected array|null $fileTemplates = null;
/**
* Magic getter/caller for any blueprint prop
*/
@@ -96,6 +98,115 @@ class Blueprint
return $this->props ?? [];
}
/**
* Gathers what file templates are allowed in
* this model based on the blueprint
*/
public function acceptedFileTemplates(string $inSection = null): array
{
// get cached results for the current file model
// (except when collecting for a specific section)
if ($inSection === null && $this->fileTemplates !== null) {
return $this->fileTemplates; // @codeCoverageIgnore
}
$templates = [];
// collect all allowed file templates from blueprint…
foreach ($this->sections() as $section) {
// if collecting for a specific section, skip all others
if ($inSection !== null && $section->name() !== $inSection) {
continue;
}
$templates = match ($section->type()) {
'files' => [...$templates, $section->template() ?? 'default'],
'fields' => [
...$templates,
...$this->acceptedFileTemplatesFromFields($section->fields())
],
default => $templates
};
}
// no caching for when collecting for specific section
if ($inSection !== null) {
return $templates; // @codeCoverageIgnore
}
return $this->fileTemplates = $templates;
}
/**
* Gathers the allowed file templates from model's fields
*/
protected function acceptedFileTemplatesFromFields(array $fields): array
{
$templates = [];
foreach ($fields as $field) {
// fields with uploads settings
if (isset($field['uploads']) === true && is_array($field['uploads']) === true) {
$templates = [
...$templates,
...$this->acceptedFileTemplatesFromFieldUploads($field['uploads'])
];
continue;
}
// structure and object fields
if (isset($field['fields']) === true && is_array($field['fields']) === true) {
$templates = [
...$templates,
...$this->acceptedFileTemplatesFromFields($field['fields']),
];
continue;
}
// layout and blocks fields
if (isset($field['fieldsets']) === true && is_array($field['fieldsets']) === true) {
$templates = [
...$templates,
...$this->acceptedFileTemplatesFromFieldsets($field['fieldsets'])
];
continue;
}
}
return $templates;
}
/**
* Gathers the allowed file templates from fieldsets
*/
protected function acceptedFileTemplatesFromFieldsets(array $fieldsets): array
{
$templates = [];
foreach ($fieldsets as $fieldset) {
foreach (($fieldset['tabs'] ?? []) as $tab) {
$templates = array_merge($templates, $this->acceptedFileTemplatesFromFields($tab['fields'] ?? []));
}
}
return $templates;
}
/**
* Extracts templates from field uploads settings
*/
protected function acceptedFileTemplatesFromFieldUploads(array $uploads): array
{
// only if the `uploads` parent is this model
if ($target = $uploads['parent'] ?? null) {
if ($this->model->id() !== $target) {
return [];
}
}
return [($uploads['template'] ?? 'default')];
}
/**
* Converts all column definitions, that
* are not wrapped in a tab, into a generic tab
@@ -186,24 +297,20 @@ class Blueprint
];
}
$extends = $props['extends'] ?? null;
if ($extends === null) {
return $props;
}
foreach (A::wrap($extends) as $extend) {
try {
$mixin = static::find($extend);
$mixin = static::extend($mixin);
$props = A::merge($mixin, $props, A::MERGE_REPLACE);
} catch (Exception) {
// keep the props unextended if the snippet wasn't found
if ($extends = $props['extends'] ?? null) {
foreach (A::wrap($extends) as $extend) {
try {
$mixin = static::find($extend);
$mixin = static::extend($mixin);
$props = A::merge($mixin, $props, A::MERGE_REPLACE);
} catch (Exception) {
// keep the props unextended if the snippet wasn't found
}
}
}
// remove the extends flag
unset($props['extends']);
// remove the extends flag
unset($props['extends']);
}
return $props;
}
@@ -280,6 +387,7 @@ class Blueprint
if (is_string($file) === true && F::exists($file) === true) {
return static::$loaded[$name] = Data::read($file);
}
if (is_array($file) === true) {
return static::$loaded[$name] = $file;
}
@@ -354,7 +462,10 @@ class Blueprint
continue;
}
$columnProps = $this->convertFieldsToSections($tabName . '-col-' . $columnKey, $columnProps);
$columnProps = $this->convertFieldsToSections(
$tabName . '-col-' . $columnKey,
$columnProps
);
// inject getting started info, if the sections are empty
if (empty($columnProps['sections']) === true) {
@@ -367,10 +478,14 @@ class Blueprint
];
}
$columns[$columnKey] = array_merge($columnProps, [
$columns[$columnKey] = [
...$columnProps,
'width' => $columnProps['width'] ?? '1/1',
'sections' => $this->normalizeSections($tabName, $columnProps['sections'] ?? [])
]);
'sections' => $this->normalizeSections(
$tabName,
$columnProps['sections'] ?? []
)
];
}
return $columns;
@@ -390,10 +505,9 @@ class Blueprint
/**
* Normalize field props for a single field
*
* @param array|string $props
* @throws \Kirby\Exception\InvalidArgumentException If the filed name is missing or the field type is invalid
*/
public static function fieldProps($props): array
public static function fieldProps(array|string $props): array
{
$props = static::extend($props);
@@ -432,12 +546,13 @@ class Blueprint
}
// add some useful defaults
return array_merge($props, [
return [
...$props,
'label' => $props['label'] ?? ucfirst($name),
'name' => $name,
'type' => $type,
'width' => $props['width'] ?? '1/1',
]);
];
}
/**
@@ -496,11 +611,16 @@ class Blueprint
// resolve field groups
if ($fieldProps['type'] === 'group') {
if (empty($fieldProps['fields']) === false && is_array($fieldProps['fields']) === true) {
if (
empty($fieldProps['fields']) === false &&
is_array($fieldProps['fields']) === true
) {
$index = array_search($fieldName, array_keys($fields));
$before = array_slice($fields, 0, $index);
$after = array_slice($fields, $index + 1);
$fields = array_merge($before, $fieldProps['fields'] ?? [], $after);
$fields = [
...array_slice($fields, 0, $index),
...$fieldProps['fields'] ?? [],
...array_slice($fields, $index + 1)
];
} else {
unset($fields[$fieldName]);
}
@@ -515,11 +635,9 @@ class Blueprint
/**
* Normalizes blueprint options. This must be used in the
* constructor of an extended class, if you want to make use of it.
*
* @param array|true|false|null|string $options
*/
protected function normalizeOptions(
$options,
array|string|bool|null $options,
array $defaults,
array $aliases = []
): array {
@@ -545,7 +663,7 @@ class Blueprint
}
}
return array_merge($defaults, $options);
return [...$defaults, ...$options];
}
/**
@@ -570,10 +688,11 @@ class Blueprint
// inject all section extensions
$sectionProps = $this->extend($sectionProps);
$sections[$sectionName] = $sectionProps = array_merge($sectionProps, [
$sections[$sectionName] = $sectionProps = [
...$sectionProps,
'name' => $sectionName,
'type' => $type = $sectionProps['type'] ?? $sectionName
]);
];
if (empty($type) === true || is_string($type) === false) {
$sections[$sectionName] = [
@@ -623,7 +742,7 @@ class Blueprint
}
// store all normalized sections
$this->sections = array_merge($this->sections, $sections);
$this->sections = [...$this->sections, ...$sections];
return $sections;
}
@@ -653,13 +772,14 @@ class Blueprint
$tabProps = $this->convertFieldsToSections($tabName, $tabProps);
$tabProps = $this->convertSectionsToColumns($tabName, $tabProps);
$tabs[$tabName] = array_merge($tabProps, [
$tabs[$tabName] = [
...$tabProps,
'columns' => $this->normalizeColumns($tabName, $tabProps['columns'] ?? []),
'icon' => $tabProps['icon'] ?? null,
'label' => $this->i18n($tabProps['label'] ?? ucfirst($tabName)),
'link' => $this->model->panel()->url(true) . '/?tab=' . $tabName,
'name' => $tabName,
]);
];
}
return $this->tabs = $tabs;

View File

@@ -157,72 +157,17 @@ class File extends ModelWithContent
*/
public function blueprints(string $inSection = null): array
{
// get cached results for the current file model
// (except when collecting for a specific section)
if ($inSection === null && $this->blueprints !== null) {
return $this->blueprints; // @codeCoverageIgnore
}
// always include the current template as option
$template = $this->template() ?? 'default';
$templates = [$template];
$parent = $this->parent();
// what file templates/blueprints should be considered is
// defined bythe parent's blueprint: which templates it allows
// in files sections as well as files fields
$blueprint = $parent->blueprint();
$fromFields = function ($fields) use (&$fromFields, $parent) {
$templates = [];
foreach ($fields as $field) {
// files or textare field
if (
$field['type'] === 'files' ||
$field['type'] === 'textarea'
) {
$uploads = $field['uploads'] ?? null;
// only if the `uploads` parent is the actual parent
if ($target = $uploads['parent'] ?? null) {
if ($parent->id() !== $target) {
continue;
}
}
$templates[] = $uploads['template'] ?? 'default';
continue;
}
// structure field
if ($field['type'] === 'structure') {
$fields = $fromFields($field['fields']);
$templates = array_merge($templates, $fields);
continue;
}
}
return $templates;
};
// collect all allowed templates…
foreach ($blueprint->sections() as $section) {
// if collecting for a specific section, skip all others
if ($inSection !== null && $section->name() !== $inSection) {
continue;
}
// …from files sections
if ($section->type() === 'files') {
$templates[] = $section->template() ?? 'default';
continue;
}
// …from fields
if ($section->type() === 'fields') {
$fields = $fromFields($section->fields());
$templates = array_merge($templates, $fields);
}
}
$templates = [
$this->template() ?? 'default',
...$this->parent()->blueprint()->acceptedFileTemplates($inSection)
];
// make sure every template is only included once
$templates = array_unique(array_filter($templates));

View File

@@ -100,6 +100,11 @@ trait FileActions
*/
public function changeSort(int $sort): static
{
// skip if the sort number stays the same
if ($this->sort()->value() === $sort) {
return $this;
}
return $this->commit(
'changeSort',
['file' => $this, 'position' => $sort],

View File

@@ -57,6 +57,9 @@ class FileBlueprint extends Blueprint
/**
* Returns the list of all accepted MIME types for
* file upload or `*` if all MIME types are allowed
*
* @deprecated 4.2.0 Use `acceptAttribute` instead
* @todo 5.0.0 Remove method
*/
public function acceptMime(): string
{
@@ -116,6 +119,74 @@ class FileBlueprint extends Blueprint
return '*';
}
/**
* Returns the list of all accepted file extensions
* for file upload or `*` if all extensions are allowed
*
* If a MIME type is specified in the blueprint, the `extension` and `type` options are ignored for the browser.
* Extensions and types, however, are still used to validate an uploaded file on the server.
* This behavior might change in the future to better represent which file extensions are actually allowed.
*
* If no MIME type is specified, the intersection between manually defined extensions and the Kirby "file types" is returned.
* If the intersection is empty, an empty string is returned.
* This behavior might change in the future to instead return the union of `mime`, `extension` and `type`.
*
* @since 4.2.0
*/
public function acceptAttribute(): string
{
// don't disclose the specific default types
if ($this->defaultTypes === true) {
return '*';
}
$accept = $this->accept();
// get extensions from "mime" option
if (is_array($accept['mime']) === true) {
// determine the extensions for each MIME type
$extensions = array_map(
fn ($pattern) => Mime::toExtensions($pattern, true),
$accept['mime']
);
$fromMime = array_unique(array_merge(...array_values($extensions)));
// return early to ignore the other options
return implode(',', array_map(fn ($ext) => ".$ext", $fromMime));
}
$restrictions = [];
// get extensions from "type" option
if (is_array($accept['type']) === true) {
$extensions = array_map(
fn ($type) => F::typeToExtensions($type) ?? [],
$accept['type']
);
$fromType = array_merge(...array_values($extensions));
$restrictions[] = $fromType;
}
// get extensions from "extension" option
if (is_array($accept['extension']) === true) {
$restrictions[] = $accept['extension'];
}
// intersect all restrictions
$list = match (count($restrictions)) {
0 => [],
1 => $restrictions[0],
default => array_intersect(...$restrictions)
};
$list = array_unique($list);
// format the list to include a leading dot on each extension
return implode(',', array_map(fn ($ext) => ".$ext", $list));
}
protected function normalizeAccept(mixed $accept = null): array
{
$accept = match (true) {

View File

@@ -110,6 +110,9 @@ class Helpers
) {
$override = null;
/**
* @psalm-suppress UndefinedVariable
*/
$handler = set_error_handler(function () use (&$override, &$handler, $condition, $fallback) {
// check if suppress condition is met
$suppress = $condition(...func_get_args());

View File

@@ -2,6 +2,7 @@
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Component;
@@ -46,6 +47,21 @@ class Section extends Component
parent::__construct($type, $attrs);
}
/**
* Returns field api call
*/
public function api(): mixed
{
if (
isset($this->options['api']) === true &&
$this->options['api'] instanceof Closure
) {
return $this->options['api']->call($this);
}
return null;
}
public function errors(): array
{
if (array_key_exists('errors', $this->methods) === true) {

View File

@@ -158,7 +158,9 @@ class UpdateStatus
// collect all matching custom messages
$filters = [
'kirby' => $this->app->version(),
'php' => phpversion()
// some PHP version strings contain extra info that makes them
// invalid so we need to strip it off
'php' => preg_replace('/^([^~+-]+).*$/', '$1', phpversion())
];
if ($type === 'plugin') {

View File

@@ -305,7 +305,23 @@ class Database
// try to prepare and execute the sql
try {
$this->statement = $this->connection->prepare($query);
$this->statement->execute($bindings);
// bind parameters to statement
foreach ($bindings as $parameter => $value) {
// positional parameters start at 1
if (is_int($parameter)) {
$parameter++;
}
$type = match (gettype($value)) {
'integer' => PDO::PARAM_INT,
'boolean' => PDO::PARAM_BOOL,
'NULL' => PDO::PARAM_NULL,
default => PDO::PARAM_STR
};
$this->statement->bindValue($parameter, $value, $type);
}
$this->statement->execute();
$this->affected = $this->statement->rowCount();
$this->lastId = Str::startsWith($query, 'insert ', true) ? $this->connection->lastInsertId() : null;

View File

@@ -869,7 +869,7 @@ class Query
$this->bindings($args[1]);
// ->where('username like ?', 'myuser')
} elseif (is_string($args[0]) === true && is_string($args[1]) === true) {
} elseif (is_string($args[0]) === true && is_scalar($args[1]) === true) {
// prepared where clause
$result = $args[0];
@@ -887,9 +887,10 @@ class Query
$key = $sql->columnName($this->table, $args[0]);
// ->where('username', 'in', ['myuser', 'myotheruser']);
// ->where('quantity', 'between', [10, 50]);
$predicate = trim(strtoupper($args[1]));
if (is_array($args[2]) === true) {
if (in_array($predicate, ['IN', 'NOT IN']) === false) {
if (in_array($predicate, ['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN']) === false) {
throw new InvalidArgumentException('Invalid predicate ' . $predicate);
}
@@ -903,15 +904,20 @@ class Query
$values[] = $valueBinding;
}
// add that to the where clause in parenthesis
$result = $key . ' ' . $predicate . ' (' . implode(', ', $values) . ')';
// add that to the where clause in parenthesis or seperated by AND
$values = match ($predicate) {
'IN',
'NOT IN' => '(' . implode(', ', $values) . ')',
'BETWEEN',
'NOT BETWEEN' => $values[0] . ' AND ' . $values[1]
};
$result = $key . ' ' . $predicate . ' ' . $values;
// ->where('username', 'like', 'myuser');
} else {
$predicates = [
'=', '>=', '>', '<=', '<', '<>', '!=', '<=>',
'IS', 'IS NOT',
'BETWEEN', 'NOT BETWEEN',
'LIKE', 'NOT LIKE',
'SOUNDS LIKE',
'REGEXP', 'NOT REGEXP'

View File

@@ -132,11 +132,13 @@ abstract class Sql
{
return [
'id' => '{{ name }} INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY',
'varchar' => '{{ name }} varchar(255) {{ null }} {{ default }} {{ unique }}',
'varchar' => '{{ name }} varchar({{ size }}) {{ null }} {{ default }} {{ unique }}',
'text' => '{{ name }} TEXT {{ unique }}',
'int' => '{{ name }} INT(11) UNSIGNED {{ null }} {{ default }} {{ unique }}',
'int' => '{{ name }} INT(11) {{ unsigned }} {{ null }} {{ default }} {{ unique }}',
'timestamp' => '{{ name }} TIMESTAMP {{ null }} {{ default }} {{ unique }}',
'bool' => '{{ name }} TINYINT(1) {{ null }} {{ default }} {{ unique }}'
'bool' => '{{ name }} TINYINT(1) {{ null }} {{ default }} {{ unique }}',
'float' => '{{ name }} DOUBLE {{ null }} {{ default }} {{ unique }}',
'decimal' => '{{ name }} DECIMAL({{ precision }}, {{ decimalPlaces }}) {{ null }} {{ default }} {{ unique }}'
];
}
@@ -157,6 +159,10 @@ abstract class Sql
* @param string $name Column name
* @param array $column Column definition array; valid keys:
* - `type` (required): Column template to use
* - `unsigned`: Whether an int column is signed or unsigned (boolean)
* - `size`: The size of varchar (int)
* - `precision`: The precision of a decimal type
* - `decimalPlaces`: The number of decimal places for a decimal type
* - `null`: Whether the column may be NULL (boolean)
* - `key`: Index this column is part of; special values `'primary'` for PRIMARY KEY and `true` for automatic naming
* - `unique`: Whether the index (or if not set the column itself) has a UNIQUE constraint
@@ -191,6 +197,13 @@ abstract class Sql
}
}
// unsigned (defaults to true for backwards compatibility)
if (isset($column['unsigned']) === true && $column['unsigned'] === false) {
$unsigned = '';
} else {
$unsigned = 'UNSIGNED';
}
// unique
$uniqueKey = false;
$uniqueColumn = null;
@@ -208,11 +221,15 @@ abstract class Sql
$columnDefault = $this->columnDefault($name, $column);
$query = trim(Str::template($template, [
'name' => $this->quoteIdentifier($name),
'null' => $null,
'default' => $columnDefault['query'],
'unique' => $uniqueColumn
], ['fallback' => '']));
'name' => $this->quoteIdentifier($name),
'unsigned' => $unsigned,
'size' => $column['size'] ?? 255,
'precision' => $column['precision'] ?? 14,
'decimalPlaces' => $column['decimalPlaces'] ?? 4,
'null' => $null,
'default' => $columnDefault['query'],
'unique' => $uniqueColumn
], ['fallback' => '']));
return [
'query' => $query,

View File

@@ -42,7 +42,9 @@ class Sqlite extends Sql
'text' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}',
'int' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}',
'timestamp' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}',
'bool' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}'
'bool' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}',
'float' => '{{ name }} REAL {{ null }} {{ default }} {{ unique }}',
'decimal' => '{{ name }} REAL {{ null }} {{ default }} {{ unique }}'
];
}

View File

@@ -220,143 +220,145 @@ class Dir
array|null $contentIgnore = null,
bool $multilang = false
): array {
$dir = realpath($dir);
$inventory = [
'children' => [],
'files' => [],
'template' => 'default',
];
$dir = realpath($dir);
if ($dir === false) {
return $inventory;
}
$items = static::read($dir, $contentIgnore);
// a temporary store for all content files
$content = [];
// sort all items naturally to avoid sorting issues later
// read and sort all items naturally to avoid sorting issues later
$items = static::read($dir, $contentIgnore);
natsort($items);
// loop through all directory items and collect all relevant information
foreach ($items as $item) {
// ignore all items with a leading dot
// ignore all items with a leading dot or underscore
if (in_array(substr($item, 0, 1), ['.', '_']) === true) {
continue;
}
$root = $dir . '/' . $item;
// collect all directories as children
if (is_dir($root) === true) {
// extract the slug and num of the directory
if (preg_match('/^([0-9]+)' . static::$numSeparator . '(.*)$/', $item, $match)) {
$num = (int)$match[1];
$slug = $match[2];
} else {
$num = null;
$slug = $item;
}
$inventory['children'][] = [
'dirname' => $item,
'model' => null,
'num' => $num,
'root' => $root,
'slug' => $slug,
];
} else {
$extension = pathinfo($item, PATHINFO_EXTENSION);
switch ($extension) {
case 'htm':
case 'html':
case 'php':
// don't track those files
break;
case $contentExtension:
$content[] = pathinfo($item, PATHINFO_FILENAME);
break;
default:
$inventory['files'][$item] = [
'filename' => $item,
'extension' => $extension,
'root' => $root,
];
}
}
}
// remove the language codes from all content filenames
if ($multilang === true) {
foreach ($content as $key => $filename) {
$content[$key] = pathinfo($filename, PATHINFO_FILENAME);
$inventory['children'][] = static::inventoryChild(
$item,
$root,
$contentExtension,
$multilang
);
continue;
}
$content = array_unique($content);
$extension = pathinfo($item, PATHINFO_EXTENSION);
// don't track files with these extensions
if (in_array($extension, ['htm', 'html', 'php']) === true) {
continue;
}
// collect all content files separately,
// not as inventory entries
if ($extension === $contentExtension) {
$filename = pathinfo($item, PATHINFO_FILENAME);
// remove the language codes from all content filenames
if ($multilang === true) {
$filename = pathinfo($filename, PATHINFO_FILENAME);
}
$content[] = $filename;
continue;
}
// collect all other files
$inventory['files'][$item] = [
'filename' => $item,
'extension' => $extension,
'root' => $root,
];
}
$inventory = static::inventoryContent($inventory, $content);
$inventory = static::inventoryModels($inventory, $contentExtension, $multilang);
$content = array_unique($content);
$inventory['template'] = static::inventoryTemplate(
$content,
$inventory['files']
);
return $inventory;
}
/**
* Take all content files,
* remove those who are meta files and
* detect the main content file
* Collect information for a child for the inventory
*/
protected static function inventoryContent(array $inventory, array $content): array
{
// filter meta files from the content file
if (empty($content) === true) {
$inventory['template'] = 'default';
return $inventory;
protected static function inventoryChild(
string $item,
string $root,
string $contentExtension = 'txt',
bool $multilang = false
): array {
// extract the slug and num of the directory
if ($separator = strpos($item, static::$numSeparator)) {
$num = (int)substr($item, 0, $separator);
$slug = substr($item, $separator + 1);
}
foreach ($content as $contentName) {
// could be a meta file. i.e. cover.jpg
if (isset($inventory['files'][$contentName]) === true) {
// determine the model
if (empty(Page::$models) === false) {
if ($multilang === true) {
$code = App::instance()->defaultLanguage()->code();
$contentExtension = $code . '.' . $contentExtension;
}
// look if a content file can be found
// for any of the available models
foreach (Page::$models as $modelName => $modelClass) {
if (is_file($root . '/' . $modelName . '.' . $contentExtension) === true) {
$model = $modelName;
break;
}
}
}
return [
'dirname' => $item,
'model' => $model ?? null,
'num' => $num ?? null,
'root' => $root,
'slug' => $slug ?? $item,
];
}
/**
* Determines the main template for the inventory
* from all collected content files, ignore file meta files
*/
protected static function inventoryTemplate(
array $content,
array $files,
): string {
foreach ($content as $name) {
// is a meta file corresponding to an actual file, i.e. cover.jpg
if (isset($files[$name]) === true) {
continue;
}
// it's most likely the template
$inventory['template'] = $contentName;
// (will overwrite and use the last match for historic reasons)
$template = $name;
}
return $inventory;
}
/**
* Go through all inventory children
* and inject a model for each
*/
protected static function inventoryModels(
array $inventory,
string $contentExtension,
bool $multilang = false
): array {
// inject models
if (
empty($inventory['children']) === false &&
empty(Page::$models) === false
) {
if ($multilang === true) {
$contentExtension = App::instance()->defaultLanguage()->code() . '.' . $contentExtension;
}
foreach ($inventory['children'] as $key => $child) {
foreach (Page::$models as $modelName => $modelClass) {
if (file_exists($child['root'] . '/' . $modelName . '.' . $contentExtension) === true) {
$inventory['children'][$key]['model'] = $modelName;
break;
}
}
}
}
return $inventory;
return $template ?? 'default';
}
/**

View File

@@ -2,6 +2,7 @@
namespace Kirby\Filesystem;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use SimpleXMLElement;
@@ -268,18 +269,32 @@ class Mime
/**
* Returns all available extensions for a given MIME type
*/
public static function toExtensions(string $mime = null): array
public static function toExtensions(string $mime = null, bool $matchWildcards = false): array
{
$extensions = [];
$testMime = fn (string $v) => static::matches($v, $mime);
foreach (static::$types as $key => $value) {
if (is_array($value) === true && in_array($mime, $value) === true) {
$extensions[] = $key;
continue;
}
if ($value === $mime) {
$extensions[] = $key;
if (is_array($value) === true) {
if ($matchWildcards === true) {
if (A::some($value, $testMime)) {
$extensions[] = $key;
}
} else {
if (in_array($mime, $value) === true) {
$extensions[] = $key;
}
}
} else {
if ($matchWildcards === true) {
if ($testMime($value) === true) {
$extensions[] = $key;
}
} else {
if ($value === $mime) {
$extensions[] = $key;
}
}
}
}

View File

@@ -62,8 +62,8 @@ class ImageMagick extends Darkroom
{
$command = escapeshellarg($options['bin']);
// limit to single-threading to keep CPU usage sane
$command .= ' -limit thread 1';
// default is limiting to single-threading to keep CPU usage sane
$command .= ' -limit thread ' . escapeshellarg($options['threads']);
// append input file
return $command . ' ' . escapeshellarg($file);
@@ -77,6 +77,7 @@ class ImageMagick extends Darkroom
return parent::defaults() + [
'bin' => 'convert',
'interlace' => false,
'threads' => 1,
];
}

View File

@@ -121,35 +121,72 @@ class Assets
* Returns array of favicon icons
* based on config option
*
* @todo Deprecate `url` option in v5, use `href` option instead
* @todo Deprecate `rel` usage as array key in v5, use `rel` option instead
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public function favicons(): array
{
$icons = $this->kirby->option('panel.favicon', [
'apple-touch-icon' => [
'type' => 'image/png',
'url' => $this->url . '/apple-touch-icon.png',
[
'rel' => 'apple-touch-icon',
'type' => 'image/png',
'href' => $this->url . '/apple-touch-icon.png'
],
'alternate icon' => [
'type' => 'image/png',
'url' => $this->url . '/favicon.png',
[
'rel' => 'alternate icon',
'type' => 'image/png',
'href' => $this->url . '/favicon.png'
],
'shortcut icon' => [
'type' => 'image/svg+xml',
'url' => $this->url . '/favicon.svg',
[
'rel' => 'shortcut icon',
'type' => 'image/svg+xml',
'href' => $this->url . '/favicon.svg'
],
[
'rel' => 'apple-touch-icon',
'type' => 'image/png',
'href' => $this->url . '/apple-touch-icon-dark.png',
'media' => '(prefers-color-scheme: dark)'
],
[
'rel' => 'alternate icon',
'type' => 'image/png',
'href' => $this->url . '/favicon-dark.png',
'media' => '(prefers-color-scheme: dark)'
]
]);
if (is_array($icons) === true) {
return $icons;
// normalize options
foreach ($icons as $rel => &$icon) {
// TODO: remove this backward compatibility check in v6
if (isset($icon['url']) === true) {
$icon['href'] = $icon['url'];
unset($icon['url']);
}
// TODO: remove this backward compatibility check in v6
if (is_string($rel) === true && isset($icon['rel']) === false) {
$icon['rel'] = $rel;
}
$icon['href'] = Url::to($icon['href']);
$icon['nonce'] = $this->nonce;
}
return array_values($icons);
}
// make sure to convert favicon string to array
if (is_string($icons) === true) {
return [
'shortcut icon' => [
'type' => F::mime($icons),
'url' => $icons,
[
'rel' => 'shortcut icon',
'type' => F::mime($icons),
'href' => Url::to($icons),
'nonce' => $this->nonce
]
];
}

View File

@@ -2,12 +2,10 @@
namespace Kirby\Panel\Lab;
use Kirby\Cms\App;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Toolkit\Str;
/**
* One or multiple lab examples with one or multiple tabs
@@ -81,17 +79,6 @@ class Example
return $this->parent->root() . '/' . $this->path() . '/' . $filename;
}
public function github(): string
{
$path = Str::after($this->root(), App::instance()->root('kirby'));
if ($tab = $this->tab()) {
$path .= '/' . $tab;
}
return 'https://github.com/getkirby/kirby/tree/main' . $path;
}
public function id(): string
{
return $this->id;
@@ -204,22 +191,48 @@ class Example
$file ??= '';
// extract parts
$parts['template'] = $this->vueTemplate($file);
$parts['examples'] = $this->vueExamples($parts['template']);
$parts['script'] = $this->vueScript($file);
$parts['template'] = $this->vueTemplate($file);
$parts['examples'] = $this->vueExamples($parts['template'], $parts['script']);
$parts['style'] = $this->vueStyle($file);
return $parts;
}
public function vueExamples(string|null $template): array
public function vueExamples(string|null $template, string|null $script): array
{
$template ??= '';
$examples = [];
$scripts = [];
if (preg_match_all('!<k-lab-example[\s|\n].*?label="(.*?)".*?>(.*?)<\/k-lab-example>!s', $template, $matches)) {
if (preg_match_all('!\/\*\* \@script: (.*?)\*\/(.*?)\/\*\* \@script-end \*\/!s', $script, $matches)) {
foreach ($matches[1] as $key => $name) {
$code = $matches[2][$key];
$code = preg_replace('!const (.*?) \=!', 'default', $code);
$scripts[trim($name)] = $code;
}
}
if (preg_match_all('!<k-lab-example[\s|\n].*?label="(.*?)"(.*?)>(.*?)<\/k-lab-example>!s', $template, $matches)) {
foreach ($matches[1] as $key => $name) {
$tail = $matches[2][$key];
$code = $matches[3][$key];
$scriptId = trim(preg_replace_callback('!script="(.*?)"!', function ($match) {
return trim($match[1]);
}, $tail));
$scriptBlock = $scripts[$scriptId] ?? null;
if (empty($scriptBlock) === false) {
$js = PHP_EOL . PHP_EOL;
$js .= '<script>';
$js .= $scriptBlock;
$js .= '</script>';
} else {
$js = '';
}
// only use the code between the @code and @code-end comments
if (preg_match('$<!-- @code -->(.*?)<!-- @code-end -->$s', $code, $match)) {
@@ -231,11 +244,21 @@ class Example
$indents = array_map(fn ($i) => strlen($i), $indents[1]);
$indents = min($indents);
if (empty($js) === false) {
$indents--;
}
// strip minimum indent from each line
$code = preg_replace('/^\t{' . $indents . '}/m', '', $code);
}
$examples[$name] = trim($code);
$code = trim($code);
if (empty($js) === false) {
$code = '<template>' . PHP_EOL . "\t" . $code . PHP_EOL . '</template>';
}
$examples[$name] = $code . $js;
}
}

View File

@@ -128,7 +128,7 @@ class Menu
'link' => $area['link'] ?? null,
'dialog' => $area['dialog'] ?? null,
'drawer' => $area['drawer'] ?? null,
'text' => $area['label'],
'text' => I18n::translate($area['label'], $area['label'])
], $menu);
// unset the link (which is always added by default to an area)

View File

@@ -2,11 +2,13 @@
namespace Kirby\Panel;
use Kirby\Cms\File;
use Kirby\Cms\Find;
use Kirby\Cms\Page;
use Kirby\Cms\PageBlueprint;
use Kirby\Cms\PageRules;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Form;
use Kirby\Toolkit\A;
@@ -32,7 +34,7 @@ class PageCreateDialog
protected string|null $slug;
protected string|null $template;
protected string|null $title;
protected Page|Site $view;
protected Page|Site|User|File $view;
protected string|null $viewId;
public static array $fieldTypes = [
@@ -369,9 +371,9 @@ class PageCreateDialog
$value = [
'parent' => $this->parentId,
'section' => $this->sectionId,
'slug' => '',
'slug' => $this->slug ?? '',
'template' => $this->template,
'title' => '',
'title' => $this->title ?? '',
'view' => $this->viewId,
];

View File

@@ -219,7 +219,7 @@ class KirbyTag
*/
public function parent(): ModelWithContent|null
{
return $this->data['parent'];
return $this->data['parent'] ?? null;
}
public function render(): string

View File

@@ -255,7 +255,7 @@ class A
* // result: ['cat' => 'miao', 'dog' => 'wuff'];
* </code>
*
* @param array $array The source array
* @param mixed $array The source array
* @param string|int|array|null $key The key to look for
* @param mixed $default Optional default value, which
* should be returned if no element
@@ -588,7 +588,7 @@ class A
*/
public static function prepend(array $array, array $prepend): array
{
return $prepend + $array;
return static::merge($prepend, $array, A::MERGE_APPEND);
}
/**

View File

@@ -107,16 +107,16 @@ class Date extends DateTime
static::validateUnit($unit);
$formats = [
'year' => 'Y-01-01P',
'month' => 'Y-m-01P',
'day' => 'Y-m-dP',
'hour' => 'Y-m-d H:00:00P',
'minute' => 'Y-m-d H:i:00P',
'second' => 'Y-m-d H:i:sP'
'year' => 'Y-01-01',
'month' => 'Y-m-01',
'day' => 'Y-m-d',
'hour' => 'Y-m-d H:00:00',
'minute' => 'Y-m-d H:i:00',
'second' => 'Y-m-d H:i:s'
];
$flooredDate = date($formats[$unit], $this->timestamp());
$this->set($flooredDate);
$flooredDate = $this->format($formats[$unit]);
$this->set($flooredDate, $this->timezone());
return $this;
}

View File

@@ -710,7 +710,7 @@ class Dom
return $options;
}
$options = array_merge([
return [
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
@@ -724,11 +724,9 @@ class Dom
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
], $options);
$options['_normalized'] = true;
return $options;
...$options,
'_normalized' => true
];
}
/**

View File

@@ -283,6 +283,14 @@ V::$validators = [
V::max($value, $max) === true;
},
/**
* Checks with the callback sent by the user
* It's ideal for one-time custom validations
*/
'callback' => function ($value, callable $callback): bool {
return $callback($value);
},
/**
* Checks if the given string contains the given value
*/