Upgrade to rc5

This commit is contained in:
Bastian Allgeier
2020-12-10 11:24:42 +01:00
parent 3fec0d7c93
commit c378376bc9
257 changed files with 13009 additions and 1846 deletions

View File

@@ -52,7 +52,7 @@ class Collection
* @param array $schema
* @throws \Exception
*/
public function __construct(Api $api, $data = null, array $schema)
public function __construct(Api $api, $data, array $schema)
{
$this->api = $api;
$this->data = $data;

View File

@@ -50,11 +50,11 @@ class Model
* Model constructor
*
* @param \Kirby\Api\Api $api
* @param null $data
* @param mixed $data
* @param array $schema
* @throws \Exception
*/
public function __construct(Api $api, $data = null, array $schema)
public function __construct(Api $api, $data, array $schema)
{
$this->api = $api;
$this->data = $data;

View File

@@ -56,28 +56,7 @@ class Api extends BaseApi
*/
public function fieldApi($model, string $name, string $path = null)
{
$form = Form::for($model);
$fieldNames = Str::split($name, '+');
$index = 0;
$count = count($fieldNames);
$field = null;
foreach ($fieldNames as $fieldName) {
$index++;
if ($field = $form->fields()->get($fieldName)) {
if ($count !== $index) {
$form = $field->form();
}
} else {
throw new NotFoundException('The field "' . $fieldName . '" could not be found');
}
}
// it can get this error only if $name is an empty string as $name = ''
if ($field === null) {
throw new NotFoundException('No field could be loaded');
}
$field = Form::for($model)->field($name);
$fieldApi = $this->clone([
'routes' => $field->api(),

View File

@@ -79,8 +79,9 @@ class App
* Creates a new App instance
*
* @param array $props
* @param bool $setInstance If false, the instance won't be set globally
*/
public function __construct(array $props = [])
public function __construct(array $props = [], bool $setInstance = true)
{
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
@@ -110,7 +111,9 @@ class App
]);
// set the singleton
Model::$kirby = static::$instance = $this;
if (static::$instance === null || $setInstance === true) {
Model::$kirby = static::$instance = $this;
}
// setup the I18n class with the translation loader
$this->i18n();
@@ -333,6 +336,24 @@ class App
return $router->call($path ?? $this->path(), $method ?? $this->request()->method());
}
/**
* Creates an instance with the same
* initial properties
*
* @param array $props
* @param bool $setInstance If false, the instance won't be set globally
* @return self
*/
public function clone(array $props = [], bool $setInstance = true)
{
$props = array_replace_recursive($this->propertyData, $props);
$clone = new static($props, $setInstance);
$clone->data = $this->data;
return $clone;
}
/**
* Returns a specific user-defined collection
* by name. All relevant dependencies are

View File

@@ -48,6 +48,7 @@ trait AppPlugins
// other plugin types
'api' => [],
'authChallenges' => [],
'blueprints' => [],
'cacheTypes' => [],
'collections' => [],
@@ -125,6 +126,17 @@ trait AppPlugins
}
}
/**
* Registers additional authentication challenges
*
* @param array $challenges
* @return array
*/
protected function extendAuthChallenges(array $challenges): array
{
return $this->extensions['authChallenges'] = Auth::$challenges = array_merge(Auth::$challenges, $challenges);
}
/**
* Registers additional blueprints
*
@@ -618,6 +630,7 @@ trait AppPlugins
// load static extensions only once
if (static::$systemExtensions === null) {
// Form Field Mixins
FormField::$mixins['datetime'] = include $root . '/config/fields/mixins/datetime.php';
FormField::$mixins['filepicker'] = include $root . '/config/fields/mixins/filepicker.php';
FormField::$mixins['min'] = include $root . '/config/fields/mixins/min.php';
FormField::$mixins['options'] = include $root . '/config/fields/mixins/options.php';
@@ -675,10 +688,17 @@ trait AppPlugins
'blueprints' => include $root . '/config/blueprints.php',
'fields' => include $root . '/config/fields.php',
'fieldMethods' => include $root . '/config/methods.php',
'tags' => include $root . '/config/tags.php'
'snippets' => include $root . '/config/snippets.php',
'tags' => include $root . '/config/tags.php',
'templates' => include $root . '/config/templates.php'
];
}
// default auth challenges
$this->extendAuthChallenges([
'email' => 'Kirby\Cms\Auth\EmailChallenge'
]);
// default cache types
$this->extendCacheTypes([
'apcu' => 'Kirby\Cache\ApcuCache',
@@ -691,7 +711,9 @@ trait AppPlugins
$this->extendBlueprints(static::$systemExtensions['blueprints']);
$this->extendFields(static::$systemExtensions['fields']);
$this->extendFieldMethods((static::$systemExtensions['fieldMethods'])($this));
$this->extendSnippets(static::$systemExtensions['snippets']);
$this->extendTags(static::$systemExtensions['tags']);
$this->extendTemplates(static::$systemExtensions['templates']);
}
/**

View File

@@ -2,10 +2,9 @@
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Toolkit\F;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
/**
@@ -36,11 +35,11 @@ trait AppTranslations
}
// inject translations from the current language
if ($this->multilang() === true && $language = $this->languages()->find($locale)) {
if (
$this->multilang() === true &&
$language = $this->languages()->find($locale)
) {
$data = array_merge($data, $language->translations());
// Add language slug rules to Str class
Str::$language = $language->rules();
}
@@ -65,25 +64,12 @@ trait AppTranslations
I18n::$translations = [];
// checks custom language definition for slugs
if ($slugsOption = $this->option('slugs')) {
// checks setting in two different ways
// add slug rules based on config option
if ($slugs = $this->option('slugs')) {
// two ways that the option can be defined:
// "slugs" => "de" or "slugs" => ["language" => "de"]
$slugsLanguage = is_string($slugsOption) === true ? $slugsOption : ($slugsOption['language'] ?? null);
// load custom slugs language if it's defined
if ($slugsLanguage !== null) {
$file = $this->root('i18n:rules') . '/' . $slugsLanguage . '.json';
if (F::exists($file) === true) {
try {
$data = Data::read($file);
} catch (Exception $e) {
$data = [];
}
Str::$language = $data;
}
if ($slugs = $slugs['language'] ?? $slugs ?? null) {
Str::$language = Language::loadRules($slugs);
}
}
}
@@ -99,7 +85,7 @@ trait AppTranslations
public function setCurrentLanguage(string $languageCode = null)
{
if ($this->multilang() === false) {
$this->setLocale($this->option('locale', 'en_US.utf-8'));
Locale::set($this->option('locale', 'en_US.utf-8'));
return $this->language = null;
}
@@ -110,9 +96,12 @@ trait AppTranslations
}
if ($this->language) {
$this->setLocale($this->language->locale());
Locale::set($this->language->locale());
}
// add language slug rules to Str class
Str::$language = $this->language->rules();
return $this->language;
}
@@ -131,18 +120,13 @@ trait AppTranslations
/**
* Set locale settings
*
* @internal
* @deprecated 3.5.0 Use \Kirby\Toolkit\Locale::set() instead
*
* @param string|array $locale
*/
public function setLocale($locale): void
{
if (is_array($locale) === true) {
foreach ($locale as $key => $value) {
setlocale($key, $value);
}
} else {
setlocale(LC_ALL, $locale);
}
Locale::set($locale);
}
/**

View File

@@ -35,9 +35,12 @@ trait AppUsers
}
/**
* Become any existing user
* Become any existing user or disable the current user
*
* @param string|null $who User ID or email address
* @param string|null $who User ID or email address,
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @param Closure|null $callback Optional action function that will be run with
* the permissions of the impersonated user; the
* impersonation will be reset afterwards

View File

@@ -4,10 +4,12 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Http\Idn;
use Kirby\Http\Request\Auth\BasicAuth;
use Kirby\Toolkit\A;
use Kirby\Toolkit\F;
use Throwable;
@@ -22,6 +24,14 @@ use Throwable;
*/
class Auth
{
/**
* Available auth challenge classes
* from the core and plugins
*
* @var array
*/
public static $challenges = [];
protected $impersonate;
protected $kirby;
protected $user = false;
@@ -36,6 +46,105 @@ class Auth
$this->kirby = $kirby;
}
/**
* Creates an authentication challenge
* (one-time auth code)
*
* @param string $email
* @param bool $long If `true`, a long session will be created
* @param string $mode Either 'login' or 'password-reset'
* @return string|null Name of the challenge that was created;
* `null` if the user does not exist or no
* challenge was available for the user
*
* @throws \Kirby\Exception\LogicException If there is no suitable authentication challenge (only in debug mode)
* @throws \Kirby\Exception\NotFoundException If the user does not exist (only in debug mode)
* @throws \Kirby\Exception\PermissionException If the rate limit is exceeded
*/
public function createChallenge(string $email, bool $long = false, string $mode = 'login'): ?string
{
// ensure that email addresses with IDN domains are in Unicode format
$email = Idn::decodeEmail($email);
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
if ($this->kirby->option('debug') === true) {
$message = 'Rate limit exceeded';
} else {
// avoid leaking security-relevant information
$message = ['key' => 'access.login'];
}
throw new PermissionException($message);
}
// rate-limit the number of challenges for DoS/DDoS protection
$this->track($email, false);
$session = $this->kirby->session([
'createMode' => 'cookie',
'long' => $long === true
]);
$challenge = null;
if ($user = $this->kirby->users()->find($email)) {
$timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60);
$challenges = $this->kirby->option('auth.challenges', ['email']);
foreach (A::wrap($challenges) as $name) {
$class = static::$challenges[$name] ?? null;
if (
$class &&
class_exists($class) === true &&
is_subclass_of($class, 'Kirby\Cms\Auth\Challenge') === true &&
$class::isAvailable($user, $mode) === true
) {
$challenge = $name;
$code = $class::create($user, compact('mode', 'timeout'));
$session->set('kirby.challenge.type', $challenge);
if ($code !== null) {
$session->set('kirby.challenge.code', password_hash($code, PASSWORD_DEFAULT));
$session->set('kirby.challenge.timeout', time() + $timeout);
}
break;
}
}
// if no suitable challenge was found, `$challenge === null` at this point;
// only leak this in debug mode, otherwise `null` is returned below
if ($challenge === null && $this->kirby->option('debug') === true) {
throw new LogicException('Could not find a suitable authentication challenge');
}
} else {
$this->kirby->trigger('user.login:failed', compact('email'));
// only leak the non-existing user in debug mode
if ($this->kirby->option('debug') === true) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
}
// always set the email, even if the challenge won't be
// created to avoid leaking whether the user exists
$session->set('kirby.challenge.email', $email);
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 300000));
return $challenge;
}
/**
* Returns the csrf token if it exists and if it is valid
*
@@ -73,6 +182,19 @@ class Auth
throw new PermissionException('Basic authentication is not activated');
}
// if logging in with password is disabled, basic auth cannot be possible either
$loginMethods = $this->kirby->system()->loginMethods();
if (isset($loginMethods['password']) !== true) {
throw new PermissionException('Login with password is not enabled');
}
// if any login method requires 2FA, basic auth without 2FA would be a weakness
foreach ($loginMethods as $method) {
if (isset($method['2fa']) === true && $method['2fa'] === true) {
throw new PermissionException('Basic authentication cannot be used with 2FA');
}
}
$request = $this->kirby->request();
$auth = $auth ?? $request->auth();
@@ -118,7 +240,7 @@ class Auth
$session = $this->kirby->session(['detect' => true]);
}
$id = $session->data()->get('user.id');
$id = $session->data()->get('kirby.userId');
if (is_string($id) !== true) {
return null;
@@ -135,9 +257,12 @@ class Auth
}
/**
* Become any existing user
* Become any existing user or disable the current user
*
* @param string|null $who User ID or email address
* @param string|null $who User ID or email address,
* `null` to use the actual user again,
* `'kirby'` for a virtual admin user or
* `'nobody'` to disable the actual user
* @return \Kirby\Cms\User|null
* @throws \Kirby\Exception\NotFoundException if the given user cannot be found
*/
@@ -152,6 +277,12 @@ class Auth
'id' => 'kirby',
'role' => 'admin',
]);
case 'nobody':
return $this->impersonate = new User([
'email' => 'nobody@getkirby.com',
'id' => 'nobody',
'role' => 'nobody',
]);
default:
if ($user = $this->kirby->users()->find($who)) {
return $this->impersonate = $user;
@@ -231,6 +362,25 @@ class Auth
return $user;
}
/**
* Login a user by email, password and auth challenge
*
* @param string $email
* @param string $password
* @param bool $long
* @return string|null Name of the challenge that was created;
* `null` if no challenge was available for the user
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occured with debug mode off
* @throws \Kirby\Exception\NotFoundException If the email was invalid
* @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
*/
public function login2fa(string $email, string $password, bool $long = false)
{
$this->validatePassword($email, $password);
return $this->createChallenge($email, $long, '2fa');
}
/**
* Sets a user object as the current user in the cache
* @internal
@@ -271,7 +421,7 @@ class Auth
$message = 'Rate limit exceeded';
} else {
// avoid leaking security-relevant information
$message = 'Invalid email or password';
$message = ['key' => 'access.login'];
}
throw new PermissionException($message);
@@ -304,7 +454,7 @@ class Auth
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
throw new PermissionException('Invalid email or password');
throw new PermissionException(['key' => 'access.login']);
}
}
}
@@ -377,6 +527,13 @@ class Auth
if ($user = $this->user()) {
$user->logout();
}
// clear the pending challenge
$session = $this->kirby->session();
$session->remove('kirby.challenge.code');
$session->remove('kirby.challenge.email');
$session->remove('kirby.challenge.timeout');
$session->remove('kirby.challenge.type');
}
/**
@@ -394,12 +551,15 @@ class Auth
/**
* Tracks a login
*
* @param string $email
* @param string|null $email
* @param bool $triggerHook If `false`, no user.login:failed hook is triggered
* @return bool
*/
public function track(string $email): bool
public function track(?string $email, bool $triggerHook = true): bool
{
$this->kirby->trigger('user.login:failed', compact('email'));
if ($triggerHook === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
}
$ip = $this->ipHash();
$log = $this->log();
@@ -417,7 +577,7 @@ class Auth
];
}
if ($this->kirby->users()->find($email)) {
if ($email !== null && $this->kirby->users()->find($email)) {
if (isset($log['by-email'][$email]) === true) {
$log['by-email'][$email] = [
'time' => $time,
@@ -462,7 +622,7 @@ class Auth
* @param \Kirby\Session\Session|array|null $session
* @param bool $allowImpersonation If set to false, only the actually
* logged in user will be returned
* @return \Kirby\Cms\User
* @return \Kirby\Cms\User|null
*
* @throws \Throwable If an authentication error occured
*/
@@ -499,4 +659,89 @@ class Auth
throw $e;
}
}
/**
* Verifies an authentication code that was
* requested with the `createChallenge()` method;
* if successful, the user is automatically logged in
*
* @param string $code User-provided auth code to verify
* @return \Kirby\Cms\User User object of the logged-in user
*
* @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code
* is incorrect or if any other error occured with debug mode off
* @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist
* @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active
* @throws \Kirby\Exception\LogicException If the authentication challenge is invalid
*/
public function verifyChallenge(string $code)
{
try {
$session = $this->kirby->session();
// first check if we have an active challenge at all
$email = $session->get('kirby.challenge.email');
$challenge = $session->get('kirby.challenge.type');
if (is_string($email) !== true || is_string($challenge) !== true) {
throw new InvalidArgumentException('No authentication challenge is active');
}
$user = $this->kirby->users()->find($email);
if ($user === null) {
throw new NotFoundException([
'key' => 'user.notFound',
'data' => [
'name' => $email
]
]);
}
// rate-limiting
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
throw new PermissionException('Rate limit exceeded');
}
// time-limiting
$timeout = $session->get('kirby.challenge.timeout');
if ($timeout !== null && time() > $timeout) {
throw new PermissionException('Authentication challenge timeout');
}
if (
isset(static::$challenges[$challenge]) === true &&
class_exists(static::$challenges[$challenge]) === true &&
is_subclass_of(static::$challenges[$challenge], 'Kirby\Cms\Auth\Challenge') === true
) {
$class = static::$challenges[$challenge];
if ($class::verify($user, $code) === true) {
$this->logout();
$user->loginPasswordless();
return $user;
} else {
throw new PermissionException(['key' => 'access.code']);
}
}
throw new LogicException('Invalid authentication challenge: ' . $challenge);
} catch (Throwable $e) {
if ($e->getMessage() !== 'Rate limit exceeded') {
$this->track($email);
}
// sleep for a random amount of milliseconds
// to make automated attacks harder and to
// avoid leaking whether the user exists
usleep(random_int(1000, 2000000));
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
if ($this->kirby->option('debug') === true) {
throw $e;
} else {
throw new PermissionException(['key' => 'access.code']);
}
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
/**
* Template class for authentication challenges
* that create and verify one-time auth codes
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
abstract class Challenge
{
/**
* Checks whether the challenge is available
* for the passed user and purpose
*
* @param \Kirby\Cms\User $user User the code will be generated for
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
abstract public static function isAvailable(User $user, string $mode): bool;
/**
* Generates a random one-time auth code and returns that code
* for later verification
*
* @param \Kirby\Cms\User $user User to generate the code for
* @param array $options Details of the challenge request:
* - 'mode': Purpose of the code ('login', 'reset' or '2fa')
* - 'timeout': Number of seconds the code will be valid for
* @return string|null The generated and sent code or `null` in case
* there was no code to generate by this algorithm
*/
abstract public static function create(User $user, array $options): ?string;
/**
* Verifies the provided code against the created one;
* default implementation that checks the code that was
* returned from the `create()` method
*
* @param \Kirby\Cms\User $user User to check the code for
* @param string $code Code to verify
* @return bool
*/
public static function verify(User $user, string $code): bool
{
$hash = $user->kirby()->session()->get('kirby.challenge.code');
if (is_string($hash) !== true) {
return false;
}
// normalize the formatting in the user-provided code
$code = str_replace(' ', '', $code);
return password_verify($code, $hash);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Kirby\Cms\Auth;
use Kirby\Cms\User;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Creates and verifies one-time auth codes
* that are sent via email
*
* @package Kirby Cms
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class EmailChallenge extends Challenge
{
/**
* Checks whether the challenge is available
* for the passed user and purpose
*
* @param \Kirby\Cms\User $user User the code will be generated for
* @param string $mode Purpose of the code ('login', 'reset' or '2fa')
* @return bool
*/
public static function isAvailable(User $user, string $mode): bool
{
return true;
}
/**
* Generates a random one-time auth code and returns that code
* for later verification
*
* @param \Kirby\Cms\User $user User to generate the code for
* @param array $options Details of the challenge request:
* - 'mode': Purpose of the code ('login', 'reset' or '2fa')
* - 'timeout': Number of seconds the code will be valid for
* @return string The generated and sent code
*/
public static function create(User $user, array $options): string
{
$code = Str::random(6, 'num');
// insert a space in the middle for easier readability
$formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3);
// use the login templates for 2FA
$mode = $options['mode'];
if ($mode === '2fa') {
$mode = 'login';
}
$kirby = $user->kirby();
$kirby->email([
'from' => $kirby->option('auth.challenge.email.from', 'noreply@' . $kirby->system()->indexUrl()),
'fromName' => $kirby->option('auth.challenge.email.fromName', $kirby->site()->title()),
'to' => $user,
'subject' => $kirby->option(
'auth.challenge.email.subject',
I18n::translate('login.email.' . $mode . '.subject')
),
'template' => 'auth/' . $mode,
'data' => [
'user' => $user,
'code' => $formatted,
'timeout' => round($options['timeout'] / 60)
]
]);
return $code;
}
}

217
kirby/src/Cms/Block.php Executable file
View File

@@ -0,0 +1,217 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Throwable;
/**
* Represents a single block
* which can be inspected further or
* converted to HTML
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Block extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\Blocks';
/**
* @var \Kirby\Cms\Content
*/
protected $content;
/**
* @var bool
*/
protected $isHidden;
/**
* @var string
*/
protected $type;
/**
* Proxy for content fields
*
* @param string $method
* @param array $args
* @return \Kirby\Cms\Field
*/
public function __call(string $method, array $args = [])
{
return $this->content()->get($method);
}
/**
* Creates a new block object
*
* @param array $params
* @param \Kirby\Cms\Blocks $siblings
*/
public function __construct(array $params)
{
parent::__construct($params);
// import old builder format
$params = BlockConverter::builderBlock($params);
$params = BlockConverter::editorBlock($params);
if (isset($params['type']) === false) {
throw new InvalidArgumentException('The block type is missing');
}
$this->content = $params['content'] ?? [];
$this->isHidden = $params['isHidden'] ?? false;
$this->type = $params['type'] ?? null;
// create the content object
$this->content = new Content($this->content, $this->parent);
}
/**
* Converts the object to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Deprecated method to return the block type
*
* @return string
*/
public function _key(): string
{
return $this->type();
}
/**
* Deprecated method to return the block id
*
* @return string
*/
public function _uid(): string
{
return $this->id();
}
/**
* Returns the content object
*
* @return \Kirby\Cms\Content
*/
public function content()
{
return $this->content;
}
/**
* Controller for the block snippet
*
* @return array
*/
public function controller(): array
{
return [
'block' => $this,
'content' => $this->content(),
// deprecated block data
'data' => $this,
'id' => $this->id(),
'prev' => $this->prev(),
'next' => $this->next()
];
}
/**
* Checks if the block is empty
*
* @return bool
*/
public function isEmpty(): bool
{
return empty($this->content()->toArray());
}
/**
* Checks if the block is hidden
* from being rendered in the frontend
*
* @return bool
*/
public function isHidden(): bool
{
return $this->isHidden;
}
/**
* Checks if the block is not empty
*
* @return bool
*/
public function isNotEmpty(): bool
{
return $this->isEmpty() === false;
}
/**
* Returns the block type
*
* @return string
*/
public function type(): string
{
return $this->type;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'content' => $this->content()->toArray(),
'id' => $this->id(),
'isHidden' => $this->isHidden(),
'type' => $this->type(),
];
}
/**
* Converts the block to html first
* and then places that inside a field
* object. This can be used further
* with all available field methods
*
* @return \Kirby\Cms\Field;
*/
public function toField()
{
return new Field($this->parent(), $this->id(), $this->toHtml());
}
/**
* Converts the block to HTML
*
* @return string
*/
public function toHtml(): string
{
try {
return (string)snippet('blocks/' . $this->type(), $this->controller(), true);
} catch (Throwable $e) {
return '<p>Block error: "' . $e->getMessage() . '" in block type: "' . $this->type() . '"</p>';
}
}
}

258
kirby/src/Cms/BlockConverter.php Executable file
View File

@@ -0,0 +1,258 @@
<?php
namespace Kirby\Cms;
class BlockConverter
{
public static function builderBlock(array $params): array
{
if (isset($params['_key']) === false) {
return $params;
}
$params['type'] = $params['_key'];
$params['content'] = $params;
unset($params['_uid']);
return $params;
}
public static function editorBlock(array $params): array
{
if (static::isEditorBlock($params) === false) {
return $params;
}
$method = 'editor' . $params['type'];
if (method_exists(static::class, $method) === true) {
$params = static::$method($params);
} else {
$params = static::editorCustom($params);
}
return $params;
}
public static function editorBlocks(array $blocks = []): array
{
if (empty($blocks) === true) {
return $blocks;
}
if (static::isEditorBlock($blocks[0]) === false) {
return $blocks;
}
$list = [];
$listStart = null;
foreach ($blocks as $index => $block) {
if (in_array($block['type'], ['ul', 'ol']) === true) {
$prev = $blocks[$index-1] ?? null;
$next = $blocks[$index+1] ?? null;
// new list starts here
if (!$prev || $prev['type'] !== $block['type']) {
$listStart = $index;
}
// add the block to the list
$list[] = $block;
// list ends here
if (!$next || $next['type'] !== $block['type']) {
$blocks[$listStart] = [
'content' => [
'text' =>
'<' . $block['type'] . '>' .
implode(array_map(function ($item) {
return '<li>' . $item['content'] . '</li>';
}, $list)) .
'</' . $block['type'] . '>',
],
'type' => 'list'
];
for ($x = $listStart+1; $x <= $listStart + count($list); $x++) {
$blocks[$x] = false;
}
$listStart = null;
$list = [];
}
} else {
$blocks[$index] = static::editorBlock($block);
}
}
return array_filter($blocks);
}
public static function editorBlockquote(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'quote'
];
}
public static function editorCode(array $params): array
{
return [
'content' => [
'language' => $params['attrs']['language'] ?? null,
'code' => $params['content']
],
'type' => 'code'
];
}
public static function editorCustom(array $params): array
{
return [
'content' => array_merge(
$params['attrs'] ?? [],
[
'body' => $params['content'] ?? null
]
),
'type' => $params['type'] ?? 'unknown'
];
}
public static function editorH1(array $params): array
{
return static::editorHeading($params, 'h1');
}
public static function editorH2(array $params): array
{
return static::editorHeading($params, 'h2');
}
public static function editorH3(array $params): array
{
return static::editorHeading($params, 'h3');
}
public static function editorH4(array $params): array
{
return static::editorHeading($params, 'h4');
}
public static function editorH5(array $params): array
{
return static::editorHeading($params, 'h5');
}
public static function editorH6(array $params): array
{
return static::editorHeading($params, 'h6');
}
public static function editorHeading(array $params, string $level): array
{
return [
'content' => [
'level' => $level,
'text' => $params['content']
],
'type' => 'heading'
];
}
public static function editorImage(array $params): array
{
// internal image
if (isset($params['attrs']['id']) === true) {
return [
'content' => [
'alt' => $params['attrs']['alt'] ?? null,
'caption' => $params['attrs']['caption'] ?? null,
'image' => $params['attrs']['id'] ?? $params['attrs']['src'] ?? null,
'location' => 'kirby',
'ratio' => $params['attrs']['ratio'] ?? null,
],
'type' => 'image'
];
}
return [
'content' => [
'alt' => $params['attrs']['alt'] ?? null,
'caption' => $params['attrs']['caption'] ?? null,
'src' => $params['attrs']['src'] ?? null,
'location' => 'web',
'ratio' => $params['attrs']['ratio'] ?? null,
],
'type' => 'image'
];
}
public static function editorKirbytext(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'markdown'
];
}
public static function editorOl(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'list'
];
}
public static function editorParagraph(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'text'
];
}
public static function editorUl(array $params): array
{
return [
'content' => [
'text' => $params['content']
],
'type' => 'list'
];
}
public static function editorVideo(array $params): array
{
return [
'content' => [
'caption' => $params['attrs']['caption'] ?? null,
'url' => $params['attrs']['src'] ?? null
],
'type' => 'video'
];
}
public static function isEditorBlock(array $params): bool
{
if (isset($params['attrs']) === true) {
return true;
}
if (is_string($params['content'] ?? null) === true) {
return true;
}
return false;
}
}

149
kirby/src/Cms/Blocks.php Executable file
View File

@@ -0,0 +1,149 @@
<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Json;
use Kirby\Data\Yaml;
use Kirby\Parsley\Parsley;
use Kirby\Parsley\Schema\Blocks as BlockSchema;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Throwable;
/**
* A collection of blocks
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Blocks extends Items
{
const ITEM_CLASS = '\Kirby\Cms\Block';
/**
* Return HTML when the collection is
* converted to a string
*
* @return string
*/
public function __toString(): string
{
return $this->toHtml();
}
/**
* Converts the blocks to HTML and then
* uses the Str::excerpt method to create
* a non-formatted, shortened excerpt from it
*
* @param mixed ...$args
* @return string
*/
public function excerpt(...$args)
{
return Str::excerpt($this->toHtml(), ...$args);
}
/**
* Wrapper around the factory to
* catch blocks from layouts
*
* @param array $items
* @param array $params
* @return \Kirby\Cms\Blocks
*/
public static function factory(array $blocks = null, array $params = [])
{
$blocks = static::extractFromLayouts($blocks);
$blocks = BlockConverter::editorBlocks($blocks);
return parent::factory($blocks, $params);
}
/**
* Pull out blocks from layouts
*
* @param array $input
* @return array
*/
protected static function extractFromLayouts(array $input): array
{
if (empty($input) === true) {
return [];
}
// no layouts
if (array_key_exists('columns', $input[0]) === false) {
return $input;
}
$blocks = [];
foreach ($input as $layout) {
foreach (($layout['columns'] ?? []) as $column) {
foreach (($column['blocks'] ?? []) as $block) {
$blocks[] = $block;
}
}
}
return $blocks;
}
/**
* Parse and sanitize various block formats
*
* @param array|string $input
* @return array
*/
public static function parse($input): array
{
if (empty($input) === false && is_array($input) === false) {
try {
$input = Json::decode((string)$input);
} catch (Throwable $e) {
try {
// try to import the old YAML format
$yaml = Yaml::decode((string)$input);
$first = A::first($yaml);
// check for valid yaml
if (empty($yaml) === true || (isset($first['_key']) === false && isset($first['type']) === false)) {
throw new Exception('Invalid YAML');
} else {
$input = $yaml;
}
} catch (Throwable $e) {
$parser = new Parsley((string)$input, new BlockSchema());
$input = $parser->blocks();
}
}
}
if (empty($input) === true) {
return [];
}
return $input;
}
/**
* Convert all blocks to HTML
*
* @return string
*/
public function toHtml(): string
{
$html = [];
foreach ($this->data as $block) {
$html[] = $block->toHtml();
}
return implode($html);
}
}

View File

@@ -201,6 +201,7 @@ class Blueprint
try {
$mixin = static::find($extends);
$mixin = static::extend($mixin);
$props = A::merge($mixin, $props, A::MERGE_REPLACE);
} catch (Exception $e) {
// keep the props unextended if the snippet wasn't found

View File

@@ -3,7 +3,6 @@
namespace Kirby\Cms;
use Closure;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Collection as BaseCollection;
use Kirby\Toolkit\Str;
@@ -89,7 +88,7 @@ class Collection extends BaseCollection
{
if (is_a($object, static::class) === true) {
$this->data = array_merge($this->data, $object->data);
} elseif (method_exists($object, 'id') === true) {
} elseif (is_object($object) === true && method_exists($object, 'id') === true) {
$this->__set($object->id(), $object);
} else {
$this->append($object);
@@ -121,45 +120,45 @@ class Collection extends BaseCollection
}
/**
* Groups the items by a given field. Returns a collection
* Groups the items by a given field or callback. Returns a collection
* with an item for each group and a collection for each group.
*
* @param string $field
* @param string|Closure $field
* @param bool $i Ignore upper/lowercase for group names
* @return \Kirby\Cms\Collection
* @throws \Kirby\Exception\Exception
*/
public function groupBy($field, bool $i = true)
public function group($field, bool $i = true)
{
if (is_string($field) === false) {
throw new Exception('Cannot group by non-string values. Did you mean to call group()?');
if (is_string($field) === true) {
$groups = new Collection([], $this->parent());
foreach ($this->data as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by
if (!$value) {
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
}
// ignore upper/lowercase for group names
if ($i) {
$value = Str::lower($value);
}
if (isset($groups->data[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups->data[$value] = new static([$key => $item]);
} else {
// add the item to an existing group
$groups->data[$value]->set($key, $item);
}
}
return $groups;
}
$groups = new Collection([], $this->parent());
foreach ($this->data as $key => $item) {
$value = $this->getAttribute($item, $field);
// make sure that there's always a proper value to group by
if (!$value) {
throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
}
// ignore upper/lowercase for group names
if ($i) {
$value = Str::lower($value);
}
if (isset($groups->data[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups->data[$value] = new static([$key => $item]);
} else {
// add the item to an existing group
$groups->data[$value]->set($key, $item);
}
}
return $groups;
return parent::group($field, $i);
}
/**
@@ -204,6 +203,7 @@ class Collection extends BaseCollection
public function not(...$keys)
{
$collection = $this->clone();
foreach ($keys as $key) {
if (is_array($key) === true) {
return $this->not(...$key);
@@ -212,8 +212,10 @@ class Collection extends BaseCollection
} elseif (is_object($key) === true) {
$key = $key->id();
}
unset($collection->$key);
unset($collection->{$key});
}
return $collection;
}
@@ -264,7 +266,7 @@ class Collection extends BaseCollection
}
/**
* Runs a combination of filterBy, sortBy, not
* Runs a combination of filter, sort, not,
* offset, limit, search and paginate on the collection.
* Any part of the query is optional.
*

View File

@@ -157,7 +157,7 @@ class Event
* @param \Closure $hook
* @return mixed
*/
public function call($bind = null, Closure $hook)
public function call(?object $bind, Closure $hook)
{
// collect the list of possible hook arguments
$data = $this->arguments();

View File

@@ -96,7 +96,7 @@ class Field
* @param string $key
* @param mixed $value
*/
public function __construct($parent = null, string $key, $value)
public function __construct(?object $parent, string $key, $value)
{
$this->key = $key;
$this->value = $value;

206
kirby/src/Cms/Fieldset.php Executable file
View File

@@ -0,0 +1,206 @@
<?php
namespace Kirby\Cms;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Represents a single Fieldset
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Fieldset extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\Fieldsets';
protected $disabled;
protected $fields = [];
protected $icon;
protected $label;
protected $model;
protected $name;
protected $preview;
protected $tabs;
protected $translate;
protected $type;
protected $unset;
protected $wysiwyg;
/**
* Creates a new Fieldset object
*
* @param array $params
*/
public function __construct(array $params = [])
{
if (empty($params['type']) === true) {
throw new InvalidArgumentException('The fieldset type is missing');
}
$this->type = $params['id'] = $params['type'];
parent::__construct($params);
$this->disabled = $params['disabled'] ?? false;
$this->icon = $params['icon'] ?? null;
$this->model = $this->parent;
$this->kirby = $this->parent->kirby();
$this->name = $this->createName($params['name'] ?? Str::ucfirst($this->type));
$this->label = $this->createLabel($params['label'] ?? null);
$this->preview = $params['preview'] ?? null;
$this->tabs = $this->createTabs($params);
$this->translate = $params['translate'] ?? true;
$this->unset = $params['unset'] ?? false;
$this->wysiwyg = $params['wysiwyg'] ?? false;
if (
$this->translate === false &&
$this->kirby->multilang() === true &&
$this->kirby->language()->isDefault() === false
) {
// disable and unset the fieldset if it's not translatable
$this->unset = true;
$this->disabled = true;
}
}
protected function createFields(array $fields = []): array
{
$fields = Blueprint::fieldsProps($fields);
$fields = $this->form($fields)->fields()->toArray();
// collect all fields
$this->fields = array_merge($this->fields, $fields);
return $fields;
}
protected function createName($name): string
{
return I18n::translate($name, $name);
}
protected function createLabel($label = null): ?string
{
return I18n::translate($label, $label);
}
protected function createTabs(array $params = []): array
{
$tabs = $params['tabs'] ?? [];
// return a single tab if there are only fields
if (empty($tabs) === true) {
return [
'content' => [
'fields' => $this->createFields($params['fields'] ?? []),
]
];
}
// normalize tabs props
foreach ($tabs as $name => $tab) {
$tab = Blueprint::extend($tab);
$tab['fields'] = $this->createFields($tab['fields'] ?? []);
$tab['label'] = $this->createLabel($tab['label'] ?? Str::ucfirst($name));
$tab['name'] = $name;
$tabs[$name] = $tab;
}
return $tabs;
}
public function disabled(): bool
{
return $this->disabled;
}
public function fields(): array
{
return $this->fields;
}
/**
* Creates a form for the given fields
*
* @param array $fields
* @param array $input
* @return \Kirby\Cms\Form
*/
public function form(array $fields, array $input = [])
{
return new Form([
'fields' => $fields,
'model' => $this->model,
'strict' => true,
'values' => $input,
]);
}
public function icon(): ?string
{
return $this->icon;
}
public function label(): ?string
{
return $this->label;
}
public function model()
{
return $this->model;
}
public function name(): string
{
return $this->name;
}
public function tabs(): array
{
return $this->tabs;
}
public function translate(): bool
{
return $this->translate;
}
public function type(): string
{
return $this->type;
}
/**
* @return array
*/
public function toArray(): array
{
return [
'disabled' => $this->disabled,
'icon' => $this->icon,
'label' => $this->label,
'name' => $this->name,
'preview' => $this->preview,
'tabs' => $this->tabs,
'translate' => $this->translate,
'type' => $this->type,
'unset' => $this->unset,
'wysiwyg' => $this->wysiwyg,
];
}
public function unset(): bool
{
return $this->unset;
}
}

99
kirby/src/Cms/Fieldsets.php Executable file
View File

@@ -0,0 +1,99 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* A collection of fieldsets
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Fieldsets extends Items
{
const ITEM_CLASS = '\Kirby\Cms\Fieldset';
protected static function createFieldsets($params)
{
$fieldsets = [];
$groups = [];
foreach ($params as $type => $fieldset) {
if (is_int($type) === true && is_string($fieldset)) {
$type = $fieldset;
$fieldset = 'blocks/' . $type;
}
if ($fieldset === false) {
continue;
}
if ($fieldset === true) {
$fieldset = 'blocks/' . $type;
}
$fieldset = Blueprint::extend($fieldset);
// make sure the type is always set
$fieldset['type'] = $fieldset['type'] ?? $type;
// extract groups
if ($fieldset['type'] === 'group') {
$result = static::createFieldsets($fieldset['fieldsets'] ?? []);
$fieldsets = array_merge($fieldsets, $result['fieldsets']);
$label = $fieldset['label'] ?? Str::ucfirst($type);
$groups[$type] = [
'label' => I18n::translate($label, $label),
'name' => $type,
'open' => $fieldset['open'] ?? true,
'sets' => array_column($result['fieldsets'], 'type'),
];
} else {
$fieldsets[$fieldset['type']] = $fieldset;
}
}
return [
'fieldsets' => $fieldsets,
'groups' => $groups
];
}
public static function factory(array $fieldsets = null, array $options = [])
{
$fieldsets = $fieldsets ?? option('blocks.fieldsets', [
'code' => 'blocks/code',
'gallery' => 'blocks/gallery',
'heading' => 'blocks/heading',
'image' => 'blocks/image',
'list' => 'blocks/list',
'markdown' => 'blocks/markdown',
'quote' => 'blocks/quote',
'text' => 'blocks/text',
'video' => 'blocks/video',
]);
$result = static::createFieldsets($fieldsets);
return parent::factory($result['fieldsets'], ['groups' => $result['groups']] + $options);
}
public function groups(): array
{
return $this->options['groups'] ?? [];
}
public function toArray(?Closure $map = null): array
{
return array_map($map ?? function ($fieldset) {
return $fieldset->toArray();
}, $this->data);
}
}

View File

@@ -385,19 +385,6 @@ class File extends ModelWithContent
return $this->parent()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->filename();
}
/**
* @deprecated 3.0.0 Use `File::content()` instead
*
* @return \Kirby\Cms\Content
* @codeCoverageIgnore
*/
public function meta()
{
deprecated('$file->meta() is deprecated, use $file->content() instead. $file->meta() will be removed in Kirby 3.5.0.');
return $this->content();
}
/**
* Get the file's last modification time.
*
@@ -758,7 +745,7 @@ class File extends ModelWithContent
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filterBy('template', $this->template());
return $this->siblings($self)->filter('template', $this->template());
}
/**

View File

@@ -264,21 +264,6 @@ trait FileActions
return $this;
}
/**
* @deprecated 3.0.0 Use `File::changeName()` instead
*
* @param string $name
* @param bool $sanitize
* @return self
* @codeCoverageIgnore
*/
public function rename(string $name, bool $sanitize = true)
{
deprecated('$file->rename() is deprecated, use $file->changeName() instead. $file->rename() will be removed in Kirby 3.5.0.');
return $this->changeName($name, $sanitize);
}
/**
* Replaces the file. The source must
* be an absolute path to a file or a Url.

View File

@@ -2,6 +2,9 @@
namespace Kirby\Cms;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Str;
/**
* Extension of the basic blueprint class
* to handle all blueprints for files.
@@ -14,6 +17,14 @@ namespace Kirby\Cms;
*/
class FileBlueprint extends Blueprint
{
/**
* `true` if the default accepted
* types are being used
*
* @var bool
*/
protected $defaultTypes = false;
public function __construct(array $props)
{
parent::__construct($props);
@@ -44,6 +55,64 @@ class FileBlueprint extends Blueprint
return $this->props['accept'];
}
/**
* Returns the list of all accepted MIME types for
* file upload or `*` if all MIME types are allowed
*
* @return string
*/
public function acceptMime(): string
{
// don't disclose the specific default types
if ($this->defaultTypes === true) {
return '*';
}
$accept = $this->accept();
$restrictions = [];
if (is_array($accept['mime']) === true) {
$restrictions[] = $accept['mime'];
} else {
// only fall back to the extension or type if
// no explicit MIME types were defined
// (allows to set custom MIME types for the frontend
// check but still restrict the extension and/or type)
if (is_array($accept['extension']) === true) {
// determine the main MIME type for each extension
$restrictions[] = array_map(['Kirby\Toolkit\Mime', 'fromExtension'], $accept['extension']);
}
if (is_array($accept['type']) === true) {
// determine the MIME types of each file type
$mimes = [];
foreach ($accept['type'] as $type) {
if ($extensions = F::typeToExtensions($type)) {
$mimes[] = array_map(['Kirby\Toolkit\Mime', 'fromExtension'], $extensions);
}
}
$restrictions[] = array_merge(...$mimes);
}
}
if ($restrictions !== []) {
if (count($restrictions) > 1) {
// only return the MIME types that are allowed by all restrictions
$mimes = array_intersect(...$restrictions);
} else {
$mimes = $restrictions[0];
}
// filter out empty MIME types and duplicates
return implode(', ', array_filter(array_unique($mimes)));
}
// no restrictions, accept everything
return '*';
}
/**
* @param mixed $accept
* @return array
@@ -54,16 +123,20 @@ class FileBlueprint extends Blueprint
$accept = [
'mime' => $accept
];
}
// accept anything
if (empty($accept) === true) {
return [];
} elseif ($accept === true) {
// explicitly no restrictions at all
$accept = [
'mime' => null
];
} elseif (empty($accept) === true) {
// no custom restrictions
$accept = [];
}
$accept = array_change_key_case($accept);
$defaults = [
'extension' => null,
'mime' => null,
'maxheight' => null,
'maxsize' => null,
@@ -71,9 +144,38 @@ class FileBlueprint extends Blueprint
'minheight' => null,
'minsize' => null,
'minwidth' => null,
'orientation' => null
'orientation' => null,
'type' => null
];
return array_merge($defaults, $accept);
// default type restriction if none are configured;
// this ensures that no unexpected files are uploaded
if (
array_key_exists('mime', $accept) === false &&
array_key_exists('extension', $accept) === false &&
array_key_exists('type', $accept) === false
) {
$defaults['type'] = ['image', 'document', 'archive', 'audio', 'video'];
$this->defaultTypes = true;
}
$accept = array_merge($defaults, $accept);
// normalize the MIME, extension and type from strings into arrays
if (is_string($accept['mime']) === true) {
$accept['mime'] = array_map(function ($mime) {
return $mime['value'];
}, Str::accepted($accept['mime']));
}
if (is_string($accept['extension']) === true) {
$accept['extension'] = array_map('trim', explode(',', $accept['extension']));
}
if (is_string($accept['type']) === true) {
$accept['type'] = array_map('trim', explode(',', $accept['type']));
}
return $accept;
}
}

View File

@@ -132,6 +132,14 @@ class Files extends Collection
return $this;
}
return $this->filterBy('template', is_array($template) ? 'in' : '==', $template);
if ($template === 'default') {
$template = ['default', ''];
}
return $this->filter(
'template',
is_array($template) ? 'in' : '==',
$template
);
}
}

View File

@@ -2,7 +2,9 @@
namespace Kirby\Cms;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Form as BaseForm;
use Kirby\Toolkit\Str;
/**
* Extension of `Kirby\Form\Form` that introduces
@@ -49,6 +51,42 @@ class Form extends BaseForm
parent::__construct($props);
}
/**
* Get the field object by name
* and handle nested fields correctly
*
* @param string $name
* @throws \Kirby\Exception\NotFoundException
* @return \Kirby\Form\Field
*/
public function field(string $name)
{
$form = $this;
$fieldNames = Str::split($name, '+');
$index = 0;
$count = count($fieldNames);
$field = null;
foreach ($fieldNames as $fieldName) {
$index++;
if ($field = $form->fields()->get($fieldName)) {
if ($count !== $index) {
$form = $field->form();
}
} else {
throw new NotFoundException('The field "' . $fieldName . '" could not be found');
}
}
// it can get this error only if $name is an empty string as $name = ''
if ($field === null) {
throw new NotFoundException('No field could be loaded');
}
return $field;
}
/**
* @param \Kirby\Cms\Model $model
* @param array $props

View File

@@ -174,18 +174,6 @@ trait HasChildren
return $this->drafts()->count() > 0;
}
/**
* @deprecated 3.0.0 Use `Page::hasUnlistedChildren()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasInvisibleChildren(): bool
{
deprecated('$page->hasInvisibleChildren() is deprecated, use $page->hasUnlistedChildren() instead. $page->hasInvisibleChildren() will be removed in Kirby 3.5.0.');
return $this->hasUnlistedChildren();
}
/**
* Checks if the page has any listed children
*
@@ -206,18 +194,6 @@ trait HasChildren
return $this->children()->unlisted()->count() > 0;
}
/**
* @deprecated 3.0.0 Use `Page::hasListedChildren()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasVisibleChildren(): bool
{
deprecated('$page->hasVisibleChildren() is deprecated, use $page->hasListedChildren() instead. $page->hasVisibleChildren() will be removed in Kirby 3.5.0.');
return $this->hasListedChildren();
}
/**
* Creates a flat child index
*

View File

@@ -27,7 +27,7 @@ trait HasFiles
*/
public function audio()
{
return $this->files()->filterBy('type', '==', 'audio');
return $this->files()->filter('type', '==', 'audio');
}
/**
@@ -37,7 +37,7 @@ trait HasFiles
*/
public function code()
{
return $this->files()->filterBy('type', '==', 'code');
return $this->files()->filter('type', '==', 'code');
}
/**
@@ -74,7 +74,7 @@ trait HasFiles
*/
public function documents()
{
return $this->files()->filterBy('type', '==', 'document');
return $this->files()->filter('type', '==', 'document');
}
/**
@@ -196,7 +196,7 @@ trait HasFiles
*/
public function images()
{
return $this->files()->filterBy('type', '==', 'image');
return $this->files()->filter('type', '==', 'image');
}
/**
@@ -221,6 +221,6 @@ trait HasFiles
*/
public function videos()
{
return $this->files()->filterBy('type', '==', 'video');
return $this->files()->filter('type', '==', 'video');
}
}

130
kirby/src/Cms/Item.php Executable file
View File

@@ -0,0 +1,130 @@
<?php
namespace Kirby\Cms;
/**
* The Item class is the foundation
* for every object in context with
* other objects. I.e.
*
* - a Block in a collection of Blocks
* - a Layout in a collection of Layouts
* - a Column in a collection of Columns
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Item
{
const ITEMS_CLASS = '\Kirby\Cms\Items';
use HasSiblings;
/**
* @var string
*/
protected $id;
/**
* @var array
*/
protected $params;
/**
* @var \Kirby\Cms\Items
*/
protected $siblings;
/**
* Creates a new item
*
* @param array $params
*/
public function __construct(array $params = [])
{
$siblingsClass = static::ITEMS_CLASS;
$this->id = $params['id'] ?? uuid();
$this->params = $params;
$this->parent = $params['parent'] ?? site();
$this->siblings = $params['siblings'] ?? new $siblingsClass();
}
/**
* Static Item factory
*
* @param array $params
* @return \Kirby\Cms\Item
*/
public static function factory(array $params)
{
return new static($params);
}
/**
* Returns the unique item id (UUID v4)
*
* @return string
*/
public function id(): string
{
return $this->id;
}
/**
* Compares the item to another one
*
* @param \Kirby\Cms\Item $item
* @return bool
*/
public function is(Item $item): bool
{
return $this->id() === $item->id();
}
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby()
{
return $this->parent()->kirby();
}
/**
* Returns the parent model
*
* @return \Kirby\Cms\Page | \Kirby\Cms\Site | \Kirby\Cms\File | \Kirby\Cms\User
*/
public function parent()
{
return $this->parent;
}
/**
* Returns the sibling collection
* This is required by the HasSiblings trait
*
* @return \Kirby\Editor\Blocks
*/
protected function siblingsCollection()
{
return $this->siblings;
}
/**
* Converts the item to an array
*
* @return array
*/
public function toArray(): array
{
return [
'id' => $this->id(),
];
}
}

96
kirby/src/Cms/Items.php Executable file
View File

@@ -0,0 +1,96 @@
<?php
namespace Kirby\Cms;
use Closure;
use Exception;
/**
* A collection of items
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Items extends Collection
{
const ITEM_CLASS = '\Kirby\Cms\Item';
/**
* @var array
*/
protected $options;
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $parent;
/**
* Constructor
*
* @param array $objects
* @param array $options
*/
public function __construct($objects = [], array $options = [])
{
$this->options = $options;
$this->parent = $options['parent'] ?? site();
parent::__construct($objects, $this->parent);
}
/**
* Creates a new item collection from a
* an array of item props
*
* @param array $items
* @param array $params
* @return \Kirby\Cms\Items
*/
public static function factory(array $items = null, array $params = [])
{
$options = array_merge([
'options' => [],
'parent' => site(),
], $params);
if (empty($items) === true || is_array($items) === false) {
return new static();
}
if (is_array($options) === false) {
throw new Exception('Invalid item options');
}
// create a new collection of blocks
$collection = new static([], $options);
foreach ($items as $params) {
if (is_array($params) === false) {
continue;
}
$params['options'] = $options['options'];
$params['parent'] = $options['parent'];
$params['siblings'] = $collection;
$class = static::ITEM_CLASS;
$item = $class::factory($params);
$collection->append($item->id(), $item);
}
return $collection;
}
/**
* Convert the items to an array
*
* @return array
*/
public function toArray(Closure $map = null): array
{
return array_values(parent::toArray($map));
}
}

View File

@@ -27,7 +27,11 @@ class KirbyTag extends \Kirby\Text\KirbyTag
{
$parent = $this->parent();
if (method_exists($parent, 'file') === true && $file = $parent->file($path)) {
if (
is_object($parent) === true &&
method_exists($parent, 'file') === true &&
$file = $parent->file($path)
) {
return $file;
}

View File

@@ -4,9 +4,9 @@ namespace Kirby\Cms;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\F;
use Kirby\Toolkit\Locale;
use Kirby\Toolkit\Str;
use Throwable;
@@ -328,6 +328,28 @@ class Language extends Model
return $this->code;
}
/**
* Loads the language rules for provided locale code
*
* @param string $code
*/
public static function loadRules(string $code)
{
$kirby = kirby();
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $kirby->root('i18n:rules') . '/' . $code . '.json';
if (F::exists($file) === false) {
$file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
}
try {
return Data::read($file);
} catch (\Exception $e) {
return [];
}
}
/**
* Returns the PHP locale setting array
*
@@ -343,45 +365,6 @@ class Language extends Model
}
}
/**
* Returns the locale array but with the locale
* constants replaced with their string representations
*
* @return array
*/
protected function localeExport(): array
{
// list of all possible constant names
$constantNames = [
'LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY',
'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES'
];
// build an associative array with the locales
// that are actually supported on this system
$constants = [];
foreach ($constantNames as $name) {
if (defined($name) === true) {
$constants[constant($name)] = $name;
}
}
// replace the keys in the locale data array with the locale names
$return = [];
foreach ($this->locale() as $key => $value) {
if (isset($constants[$key]) === true) {
// the key is a valid constant,
// replace it with its string representation
$return[$constants[$key]] = $value;
} else {
// not found, keep it as-is
$return[$key] = $value;
}
}
return $return;
}
/**
* Returns the human-readable name
* of the language
@@ -454,19 +437,7 @@ class Language extends Model
public function rules(): array
{
$code = $this->locale(LC_CTYPE);
$code = Str::contains($code, '.') ? Str::before($code, '.') : $code;
$file = $this->kirby()->root('i18n:rules') . '/' . $code . '.json';
if (F::exists($file) === false) {
$file = $this->kirby()->root('i18n:rules') . '/' . Str::before($code, '_') . '.json';
}
try {
$data = Data::read($file);
} catch (\Exception $e) {
$data = [];
}
$data = static::loadRules($code);
return array_merge($data, $this->slugs());
}
@@ -488,7 +459,7 @@ class Language extends Model
'code' => $this->code(),
'default' => $this->isDefault(),
'direction' => $this->direction(),
'locale' => $this->localeExport(),
'locale' => Locale::export($this->locale()),
'name' => $this->name(),
'translations' => $this->translations(),
'url' => $this->url,
@@ -539,24 +510,10 @@ class Language extends Model
*/
protected function setLocale($locale = null)
{
if (is_array($locale)) {
// replace string constant keys with the constant values
$convertedLocale = [];
foreach ($locale as $key => $value) {
if (is_string($key) === true && Str::startsWith($key, 'LC_') === true) {
$key = constant($key);
}
$convertedLocale[$key] = $value;
}
$this->locale = $convertedLocale;
} elseif (is_string($locale)) {
$this->locale = [LC_ALL => $locale];
} elseif ($locale === null) {
if ($locale === null) {
$this->locale = [LC_ALL => $this->code];
} else {
throw new InvalidArgumentException('Locale must be string or array');
$this->locale = Locale::normalize($locale);
}
return $this;

View File

@@ -116,7 +116,7 @@ class LanguageRoutes
'action' => function () use ($kirby) {
// find all languages with the same base url as the current installation
$languages = $kirby->languages()->filterBy('baseurl', $kirby->url());
$languages = $kirby->languages()->filter('baseurl', $kirby->url());
// if there's no language with a matching base url,
// redirect to the default language

View File

@@ -72,18 +72,6 @@ class Languages extends Collection
}
}
/**
* @deprecated 3.0.0 Use `Languages::default()` instead
* @return \Kirby\Cms\Language|null
* @codeCoverageIgnore
*/
public function findDefault()
{
deprecated('$languages->findDefault() is deprecated, use $languages->default() instead. $languages->findDefault() will be removed in Kirby 3.5.0.');
return $this->default();
}
/**
* Convert all defined languages to a collection
*

92
kirby/src/Cms/Layout.php Executable file
View File

@@ -0,0 +1,92 @@
<?php
namespace Kirby\Cms;
/**
* Represents a single Layout with
* multiple columns
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Layout extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\Layouts';
/**
* @var \Kirby\Cms\Content
*/
protected $attrs;
/**
* @var \Kirby\Cms\LayoutColumns
*/
protected $columns;
/**
* Proxy for attrs
*
* @param string $method
* @param array $args
* @return \Kirby\Cms\Field
*/
public function __call(string $method, array $args = [])
{
return $this->attrs()->get($method);
}
/**
* Creates a new Layout object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->columns = LayoutColumns::factory($params['columns'] ?? [], [
'parent' => $this->parent
]);
// create the attrs object
$this->attrs = new Content($params['attrs'] ?? [], $this->parent);
}
/**
* Returns the attrs object
*
* @return \Kirby\Cms\Content
*/
public function attrs()
{
return $this->attrs;
}
/**
* Returns the columns in this layout
*
* @return \Kirby\Cms\LayoutColumns
*/
public function columns()
{
return $this->columns;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'attrs' => $this->attrs()->toArray(),
'columns' => $this->columns()->toArray(),
'id' => $this->id(),
];
}
}

96
kirby/src/Cms/LayoutColumn.php Executable file
View File

@@ -0,0 +1,96 @@
<?php
namespace Kirby\Cms;
use Kirby\Toolkit\Str;
/**
* Represents a single layout column with
* multiple blocks
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class LayoutColumn extends Item
{
const ITEMS_CLASS = '\Kirby\Cms\LayoutColumns';
/**
* @var \Kirby\Cms\Blocks
*/
protected $blocks;
/**
* @var string
*/
protected $width;
/**
* Creates a new LayoutColumn object
*
* @param array $params
*/
public function __construct(array $params = [])
{
parent::__construct($params);
$this->blocks = Blocks::factory($params['blocks'] ?? [], [
'parent' => $this->parent
]);
$this->width = $params['width'] ?? '1/1';
}
/**
* Returns the blocks collection
*
* @return \Kirby\Cms\Blocks
*/
public function blocks()
{
return $this->blocks;
}
/**
* Returns the number of columns this column spans
*
* @param int $columns
* @return int
*/
public function span(int $columns = 12): int
{
$fraction = Str::split($this->width, '/');
$a = $fraction[0] ?? 1;
$b = $fraction[1] ?? 1;
return $columns * $a / $b;
}
/**
* The result is being sent to the editor
* via the API in the panel
*
* @return array
*/
public function toArray(): array
{
return [
'blocks' => $this->blocks()->toArray(),
'id' => $this->id(),
'width' => $this->width(),
];
}
/**
* Returns the width of the column
*
* @return string
*/
public function width(): string
{
return $this->width;
}
}

17
kirby/src/Cms/LayoutColumns.php Executable file
View File

@@ -0,0 +1,17 @@
<?php
namespace Kirby\Cms;
/**
* A collection of layout columns
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class LayoutColumns extends Items
{
const ITEM_CLASS = '\Kirby\Cms\LayoutColumn';
}

39
kirby/src/Cms/Layouts.php Executable file
View File

@@ -0,0 +1,39 @@
<?php
namespace Kirby\Cms;
/**
* A collection of layouts
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://getkirby.com/license
*/
class Layouts extends Items
{
const ITEM_CLASS = '\Kirby\Cms\Layout';
public static function factory(array $layouts = null, array $options = [])
{
$first = $layouts[0] ?? [];
// if there are no wrapping layouts for blocks yet …
if (array_key_exists('content', $first) === true || array_key_exists('type', $first) === true) {
$layouts = [
[
'id' => uuid(),
'columns' => [
[
'width' => '1/1',
'blocks' => $layouts
]
]
]
];
}
return parent::factory($layouts, $options);
}
}

View File

@@ -294,9 +294,7 @@ abstract class ModelWithContent extends Model
$errors = [];
foreach ($this->blueprint()->sections() as $section) {
if (method_exists($section, 'errors') === true || isset($section->errors)) {
$errors = array_merge($errors, $section->errors());
}
$errors = array_merge($errors, $section->errors());
}
return $errors;

View File

@@ -753,18 +753,6 @@ class Page extends ModelWithContent
return $this->isHomePage() === true || $this->isErrorPage() === true;
}
/**
* @deprecated 3.0.0 Use `Page::isUnlisted()` instead
* @return bool
* @codeCoverageIgnore
*/
public function isInvisible(): bool
{
deprecated('$page->isInvisible() is deprecated, use $page->isUnlisted() instead. $page->isInvisible() will be removed in Kirby 3.5.0.');
return $this->isUnlisted();
}
/**
* Checks if the page has a sorting number
*
@@ -845,18 +833,6 @@ class Page extends ModelWithContent
return $this->isListed() === false;
}
/**
* @deprecated 3.0.0 Use `Page::isListed()` instead
* @return bool
* @codeCoverageIgnore
*/
public function isVisible(): bool
{
deprecated('$page->isVisible() is deprecated, use $page->isListed() instead. $page->isVisible() will be removed in Kirby 3.5.0.');
return $this->isListed();
}
/**
* Checks if the page access is verified.
* This is only used for drafts so far.

View File

@@ -23,9 +23,11 @@ use Kirby\Toolkit\Str;
trait PageActions
{
/**
* Changes the sorting number
* Changes the sorting number.
* The sorting number must already be correct
* when the method is called
* when the method is called.
* This only affects this page,
* siblings will not be resorted.
*
* @param int|null $num
* @return self
@@ -168,7 +170,9 @@ trait PageActions
/**
* Change the status of the current page
* to either draft, listed or unlisted
* to either draft, listed or unlisted.
* If changing to `listed`, you can pass a position for the
* page in the siblings collection. Siblings will be resorted.
*
* @param string $status "draft", "listed" or "unlisted"
* @param int|null $position Optional sorting number
@@ -203,7 +207,7 @@ trait PageActions
}
/**
* @param int $position
* @param int|null $position
* @return self
*/
protected function changeStatusToListed(int $position = null)
@@ -247,6 +251,19 @@ trait PageActions
return $page;
}
/**
* Change the position of the page in its siblings
* collection. Siblings will be resorted. If the page
* status isn't yet `listed`, it will be changed to it.
*
* @param int|null $position
* @return self
*/
public function changeSort(int $position = null)
{
return $this->changeStatus('listed', $position);
}
/**
* Changes the page template
*
@@ -545,7 +562,7 @@ trait PageActions
return $num;
default:
// get instance with default language
$app = $this->kirby()->clone();
$app = $this->kirby()->clone([], false);
$app->setCurrentLanguage();
$template = Str::template($mode, [
@@ -735,7 +752,7 @@ trait PageActions
}
$parent = $this->parentModel();
$parent->children = $parent->children()->sortBy('num', 'asc');
$parent->children = $parent->children()->sort('num', 'asc');
return true;
}
@@ -761,18 +778,22 @@ trait PageActions
$sibling->changeNum($index);
}
$parent->children = $siblings->sortBy('num', 'asc');
$parent->children = $siblings->sort('num', 'asc');
}
return true;
}
/**
* @deprecated 3.5.0 Use `Page::changeSort()` instead
*
* @param null $position
* @return self
*/
public function sort($position = null)
{
deprecated('$page->sort() is deprecated, use $page->changeSort() instead. $page->sort() will be removed in Kirby 3.6.0.');
return $this->changeStatus('listed', $position);
}

View File

@@ -162,7 +162,7 @@ class PagePicker extends Picker
}
// filter protected pages
$items = $items->filterBy('isReadable', true);
$items = $items->filter('isReadable', true);
// search
$items = $this->search($items);

View File

@@ -135,103 +135,6 @@ trait PageSiblings
*/
public function templateSiblings(bool $self = true)
{
return $this->siblings($self)->filterBy('intendedTemplate', $this->intendedTemplate()->name());
}
/**
* @deprecated 3.0.0 Use `Page::hasNextUnlisted()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasNextInvisible(): bool
{
deprecated('$page->hasNextInvisible() is deprecated, use $page->hasNextUnlisted() instead. $page->hasNextInvisible() will be removed in Kirby 3.5.0.');
return $this->hasNextUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::hasNextListed()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasNextVisible(): bool
{
deprecated('$page->hasNextVisible() is deprecated, use $page->hasNextListed() instead. $page->hasNextVisible() will be removed in Kirby 3.5.0.');
return $this->hasNextListed();
}
/**
* @deprecated 3.0.0 Use `Page::hasPrevUnlisted()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasPrevInvisible(): bool
{
deprecated('$page->hasPrevInvisible() is deprecated, use $page->hasPrevUnlisted() instead. $page->hasPrevInvisible() will be removed in Kirby 3.5.0.');
return $this->hasPrevUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::hasPrevListed()` instead
* @return bool
* @codeCoverageIgnore
*/
public function hasPrevVisible(): bool
{
deprecated('$page->hasPrevVisible() is deprecated, use $page->hasPrevListed() instead. $page->hasPrevVisible() will be removed in Kirby 3.5.0.');
return $this->hasPrevListed();
}
/**
* @deprecated 3.0.0 Use `Page::nextUnlisted()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function nextInvisible()
{
deprecated('$page->nextInvisible() is deprecated, use $page->nextUnlisted() instead. $page->nextInvisible() will be removed in Kirby 3.5.0.');
return $this->nextUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::nextListed()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function nextVisible()
{
deprecated('$page->nextVisible() is deprecated, use $page->nextListed() instead. $page->nextVisible() will be removed in Kirby 3.5.0.');
return $this->nextListed();
}
/**
* @deprecated 3.0.0 Use `Page::prevUnlisted()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function prevInvisible()
{
deprecated('$page->prevInvisible() is deprecated, use $page->prevUnlisted() instead. $page->prevInvisible() will be removed in Kirby 3.5.0.');
return $this->prevUnlisted();
}
/**
* @deprecated 3.0.0 Use `Page::prevListed()` instead
* @return self|null
* @codeCoverageIgnore
*/
public function prevVisible()
{
deprecated('$page->prevVisible() is deprecated, use $page->prevListed() instead. $page->prevVisible() will be removed in Kirby 3.5.0.');
return $this->prevListed();
return $this->siblings($self)->filter('intendedTemplate', $this->intendedTemplate()->name());
}
}

View File

@@ -74,7 +74,7 @@ class Pages extends Collection
*/
public function audio()
{
return $this->files()->filterBy('type', 'audio');
return $this->files()->filter('type', 'audio');
}
/**
@@ -102,7 +102,7 @@ class Pages extends Collection
*/
public function code()
{
return $this->files()->filterBy('type', 'code');
return $this->files()->filter('type', 'code');
}
/**
@@ -112,7 +112,7 @@ class Pages extends Collection
*/
public function documents()
{
return $this->files()->filterBy('type', 'document');
return $this->files()->filter('type', 'document');
}
/**
@@ -319,7 +319,7 @@ class Pages extends Collection
*/
public function images()
{
return $this->files()->filterBy('type', 'image');
return $this->files()->filter('type', 'image');
}
/**
@@ -351,19 +351,6 @@ class Pages extends Collection
return $this->index;
}
/**
* @deprecated 3.0.0 Use `Pages::unlisted()` instead
*
* @return self
* @codeCoverageIgnore
*/
public function invisible()
{
deprecated('$pages->invisible() is deprecated, use $pages->unlisted() instead. $pages->invisible() will be removed in Kirby 3.5.0.');
return $this->unlisted();
}
/**
* Returns all listed pages in the collection
*
@@ -371,7 +358,7 @@ class Pages extends Collection
*/
public function listed()
{
return $this->filterBy('isListed', '==', true);
return $this->filter('isListed', '==', true);
}
/**
@@ -381,7 +368,7 @@ class Pages extends Collection
*/
public function unlisted()
{
return $this->filterBy('isUnlisted', '==', true);
return $this->filter('isUnlisted', '==', true);
}
/**
@@ -478,7 +465,7 @@ class Pages extends Collection
*/
public function published()
{
return $this->filterBy('isDraft', '==', false);
return $this->filter('isDraft', '==', false);
}
/**
@@ -509,19 +496,6 @@ class Pages extends Collection
*/
public function videos()
{
return $this->files()->filterBy('type', 'video');
}
/**
* @deprecated 3.0.0 Use `Pages::listed()` instead
*
* @return \Kirby\Cms\Pages
* @codeCoverageIgnore
*/
public function visible()
{
deprecated('$pages->visible() is deprecated, use $pages->listed() instead. $pages->visible() will be removed in Kirby 3.5.0.');
return $this->listed();
return $this->files()->filter('type', 'video');
}
}

View File

@@ -140,7 +140,7 @@ class Plugin extends Model
*/
protected function setName(string $name)
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) == false) {
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"');
}

View File

@@ -87,7 +87,7 @@ class Roles extends Collection
}
// return the collection sorted by name
return $collection->sortBy('name', 'asc');
return $collection->sort('name', 'asc');
}
/**
@@ -134,6 +134,6 @@ class Roles extends Collection
}
// return the collection sorted by name
return $roles->sortBy('name', 'asc');
return $roles->sort('name', 'asc');
}
}

View File

@@ -51,6 +51,15 @@ class Section extends Component
parent::__construct($type, $attrs);
}
public function errors(): array
{
if (array_key_exists('errors', $this->methods) === true) {
return $this->methods['errors']->call($this);
}
return $this->errors ?? [];
}
/**
* @return \Kirby\Cms\App
*/

View File

@@ -340,6 +340,68 @@ class System
}
}
/**
* Returns the configured UI modes for the login form
* with their respective options
*
* @return array
*
* @throws \Kirby\Exception\InvalidArgumentException If the configuration is invalid
* (only in debug mode)
*/
public function loginMethods(): array
{
$default = ['password' => []];
$methods = A::wrap($this->app->option('auth.methods', $default));
// normalize the syntax variants
$normalized = [];
$uses2fa = false;
foreach ($methods as $key => $value) {
if (is_int($key) === true) {
// ['password']
$normalized[$value] = [];
} elseif ($value === true) {
// ['password' => true]
$normalized[$key] = [];
} else {
// ['password' => [...]]
$normalized[$key] = $value;
if (isset($value['2fa']) === true && $value['2fa'] === true) {
$uses2fa = true;
}
}
}
// 2FA must not be circumvented by code-based modes
foreach (['code', 'password-reset'] as $method) {
if ($uses2fa === true && isset($normalized[$method]) === true) {
unset($normalized[$method]);
if ($this->app->option('debug') === true) {
$message = 'The "' . $method . '" login method cannot be enabled when 2FA is required';
throw new InvalidArgumentException($message);
}
}
}
// only one code-based mode can be active at once
if (
isset($normalized['code']) === true &&
isset($normalized['password-reset']) === true
) {
unset($normalized['code']);
if ($this->app->option('debug') === true) {
$message = 'The "code" and "password-reset" login methods cannot be enabled together';
throw new InvalidArgumentException($message);
}
}
return $normalized;
}
/**
* Check for an existing mbstring extension
*
@@ -367,7 +429,9 @@ class System
*/
public function php(): bool
{
return version_compare(phpversion(), '7.1.0', '>=');
return
version_compare(PHP_VERSION, '7.3.0', '>=') === true &&
version_compare(PHP_VERSION, '8.1.0', '<') === true;
}
/**

View File

@@ -368,7 +368,8 @@ class User extends ModelWithContent
*/
public function isLastAdmin(): bool
{
return $this->role()->isAdmin() === true && $this->kirby()->users()->filterBy('role', 'admin')->count() <= 1;
return $this->role()->isAdmin() === true &&
$this->kirby()->users()->filter('role', 'admin')->count() <= 1;
}
/**
@@ -381,6 +382,17 @@ class User extends ModelWithContent
return $this->kirby()->users()->count() === 1;
}
/**
* Checks if the current user is the virtual
* Nobody user
*
* @return bool
*/
public function isNobody(): bool
{
return $this->email() === 'nobody@getkirby.com';
}
/**
* Returns the user language
*
@@ -421,7 +433,7 @@ class User extends ModelWithContent
$kirby->trigger('user.login:before', ['user' => $this, 'session' => $session]);
$session->regenerateToken(); // privilege change
$session->data()->set('user.id', $this->id());
$session->data()->set('kirby.userId', $this->id());
$this->kirby()->auth()->setUser($this);
$kirby->trigger('user.login:after', ['user' => $this, 'session' => $session]);
@@ -441,7 +453,7 @@ class User extends ModelWithContent
$kirby->trigger('user.logout:before', ['user' => $this, 'session' => $session]);
// remove the user from the session for future requests
$session->data()->remove('user.id');
$session->data()->remove('kirby.userId');
// clear the cached user object from the app state of the current request
$this->kirby()->auth()->flush();
@@ -538,6 +550,18 @@ class User extends ModelWithContent
return $this->name = new Field($this, 'name', $this->credentials()['name'] ?? null);
}
/**
* Returns the user's name or,
* if empty, the email address
*
* @return \Kirby\Cms\Field
*/
public function nameOrEmail()
{
$name = $this->name();
return $name->isNotEmpty() ? $name : new Field($this, 'email', $this->email());
}
/**
* Create a dummy nobody
*
@@ -688,7 +712,7 @@ class User extends ModelWithContent
$roles = $kirby->roles();
// a collection with just the one role of the user
$myRole = $roles->filterBy('id', $this->role()->id());
$myRole = $roles->filter('id', $this->role()->id());
// if there's an authenticated user …
if ($user = $kirby->user()) {

View File

@@ -140,7 +140,7 @@ trait UserActions
* @return mixed
* @throws \Kirby\Exception\PermissionException
*/
protected function commit(string $action, array $arguments = [], Closure $callback)
protected function commit(string $action, array $arguments, Closure $callback)
{
if ($this->isKirby() === true) {
throw new PermissionException('The Kirby user cannot be changed');

View File

@@ -50,7 +50,7 @@ class UserPermissions extends ModelPermissions
}
// users who are not admins cannot create admins
if ($this->model->isAdmin() === false) {
if ($this->model->isAdmin() === true) {
return false;
}

View File

@@ -61,7 +61,7 @@ class UserPicker extends Picker
$users = $this->search($users);
// sort
$users = $users->sortBy('username', 'asc');
$users = $users->sort('username', 'asc');
// paginate
return $this->paginate($users);

View File

@@ -165,6 +165,12 @@ class UserRules
static::validEmail($user, $user->email(), true);
static::validLanguage($user, $user->language());
// the first user must have a password
if ($user->kirby()->users()->count() === 0 && empty($props['password'])) {
// trigger invalid password error
static::validPassword($user, ' ');
}
if (empty($props['password']) === false) {
static::validPassword($user, $props['password']);
}

View File

@@ -128,13 +128,13 @@ class Users extends Collection
}
/**
* Shortcut for `$users->filterBy('role', 'admin')`
* Shortcut for `$users->filter('role', 'admin')`
*
* @param string $role
* @return self
*/
public function role(string $role)
{
return $this->filterBy('role', $role);
return $this->filter('role', $role);
}
}

View File

@@ -79,7 +79,7 @@ class Data
* @param string $type
* @return array
*/
public static function decode($string = null, string $type): array
public static function decode($string, string $type): array
{
return static::handler($type)->decode($string);
}
@@ -91,7 +91,7 @@ class Data
* @param string $type
* @return string
*/
public static function encode($data = null, string $type): string
public static function encode($data, string $type): string
{
return static::handler($type)->encode($data);
}

View File

@@ -24,6 +24,9 @@ class Yaml extends Handler
*/
public static function encode($data): string
{
// TODO: The locale magic should no longer be
// necessary when support for PHP 7.x is dropped
// fetch the current locale setting for numbers
$locale = setlocale(LC_NUMERIC, 0);

View File

@@ -363,7 +363,7 @@ abstract class Sql
* @param array $input
* @return void
*/
public function extend(&$query, array &$bindings = [], $input)
public function extend(&$query, array &$bindings, $input)
{
if (empty($input['query']) === false) {
$query[] = $input['query'];

View File

@@ -21,6 +21,22 @@ class Email
{
use Properties;
/**
* If set to `true`, the debug mode is enabled
* for all emails
*
* @var bool
*/
public static $debug = false;
/**
* Store for sent emails when `Email::$debug`
* is set to `true`
*
* @var array
*/
public static $emails = [];
/**
* @var array|null
*/
@@ -96,9 +112,13 @@ class Email
{
$this->setProperties($props);
if ($debug === false) {
$this->send(); // @codeCoverageIgnore
// @codeCoverageIgnoreStart
if (static::$debug === false && $debug === false) {
$this->send();
} elseif (static::$debug === true) {
static::$emails[] = $this;
}
// @codeCoverageIgnoreEnd
}
/**

View File

@@ -248,6 +248,26 @@ class Field extends Component
];
}
/**
* Creates a new field instance
*
* @param string $type
* @param array $attrs
* @param Fields|null $formFields
* @return static
*/
public static function factory(string $type, array $attrs = [], ?Fields $formFields = null)
{
$field = static::$types[$type] ?? null;
if (is_string($field) && class_exists($field) === true) {
$attrs['siblings'] = $formFields;
return new $field($attrs);
}
return new static($type, $attrs, $formFields);
}
/**
* Parent collection with all fields of the current form
*
@@ -404,8 +424,6 @@ class Field extends Component
unset($array['model']);
$array['errors'] = $this->errors();
$array['invalid'] = $this->isInvalid();
$array['saveable'] = $this->save();
$array['signature'] = md5(json_encode($array));

View File

@@ -0,0 +1,265 @@
<?php
namespace Kirby\Form\Field;
use Kirby\Cms\Block;
use Kirby\Cms\Blocks as BlocksCollection;
use Kirby\Cms\Fieldsets;
use Kirby\Cms\Form;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\FieldClass;
use Kirby\Form\Mixin\EmptyState;
use Kirby\Form\Mixin\Max;
use Kirby\Form\Mixin\Min;
use Throwable;
class BlocksField extends FieldClass
{
use EmptyState;
use Max;
use Min;
protected $fieldsets;
protected $blocks;
protected $value = [];
public function __construct(array $params = [])
{
$this->setFieldsets($params['fieldsets'] ?? null, $params['model'] ?? site());
parent::__construct($params);
$this->setEmpty($params['empty'] ?? null);
$this->setGroup($params['group'] ?? 'blocks');
$this->setMax($params['max'] ?? null);
$this->setMin($params['min'] ?? null);
$this->setPretty($params['pretty'] ?? false);
}
public function blocksToValues($blocks, $to = 'values'): array
{
$result = [];
$fields = [];
foreach ($blocks as $block) {
try {
$type = $block['type'];
// get and cache fields at the same time
$fields[$type] = $fields[$type] ?? $this->fields($block['type']);
// overwrite the block content with form values
$block['content'] = $this->form($fields[$type], $block['content'])->$to();
$result[] = $block;
} catch (Throwable $e) {
$result[] = $block;
// skip invalid blocks
continue;
}
}
return $result;
}
public function fields(string $type)
{
return $this->fieldset($type)->fields();
}
public function fieldset(string $type)
{
if ($fieldset = $this->fieldsets->find($type)) {
return $fieldset;
}
throw new NotFoundException('The fieldset ' . $type . ' could not be found');
}
public function fieldsets()
{
return $this->fieldsets;
}
public function fieldsetGroups(): ?array
{
$fieldsetGroups = $this->fieldsets()->groups();
return empty($fieldsetGroups) === true ? null : $fieldsetGroups;
}
public function fill($value = null)
{
$value = BlocksCollection::parse($value);
$blocks = BlocksCollection::factory($value);
$this->value = $this->blocksToValues($blocks->toArray());
}
public function form(array $fields, array $input = [])
{
return new Form([
'fields' => $fields,
'model' => $this->model,
'strict' => true,
'values' => $input,
]);
}
public function isEmpty(): bool
{
return count($this->value()) === 0;
}
public function group(): string
{
return $this->group;
}
public function pretty(): bool
{
return $this->pretty;
}
public function props(): array
{
return [
'empty' => $this->empty(),
'fieldsets' => $this->fieldsets()->toArray(),
'fieldsetGroups' => $this->fieldsetGroups(),
'group' => $this->group(),
'max' => $this->max(),
'min' => $this->min(),
] + parent::props();
}
public function routes(): array
{
$field = $this;
return [
[
'pattern' => 'uuid',
'action' => function () {
return ['uuid' => uuid()];
}
],
[
'pattern' => 'fieldsets/(:any)',
'method' => 'GET',
'action' => function ($fieldsetType) use ($field) {
$fields = $field->fields($fieldsetType);
$defaults = $field->form($fields, [])->data(true);
$content = $field->form($fields, $defaults)->values();
return Block::factory([
'content' => $content,
'type' => $fieldsetType
])->toArray();
}
],
[
'pattern' => 'fieldsets/(:any)/fields/(:any)/(:all?)',
'method' => 'ALL',
'action' => function (string $fieldsetType, string $fieldName, string $path = null) use ($field) {
$fields = $field->fields($fieldsetType);
$field = $field->form($fields)->field($fieldName);
$fieldApi = $this->clone([
'routes' => $field->api(),
'data' => array_merge($this->data(), ['field' => $field])
]);
return $fieldApi->call($path, $this->requestMethod(), $this->requestData());
}
],
];
}
public function store($value)
{
$blocks = $this->blocksToValues((array)$value, 'content');
return $this->valueToJson($blocks, $this->pretty());
}
protected function setFieldsets($fieldsets, $model)
{
if (is_string($fieldsets) === true) {
$fieldsets = [];
}
$this->fieldsets = Fieldsets::factory($fieldsets, [
'parent' => $model
]);
}
protected function setGroup(string $group = null)
{
$this->group = $group;
}
protected function setPretty(bool $pretty = false)
{
$this->pretty = $pretty;
}
public function validations(): array
{
return [
'blocks' => function ($value) {
if ($this->min && count($value) < $this->min) {
throw new InvalidArgumentException([
'key' => 'blocks.min.' . ($this->min === 1 ? 'singular' : 'plural'),
'data' => [
'min' => $this->min
]
]);
}
if ($this->max && count($value) > $this->max) {
throw new InvalidArgumentException([
'key' => 'blocks.max.' . ($this->max === 1 ? 'singular' : 'plural'),
'data' => [
'max' => $this->max
]
]);
}
$fields = [];
$index = 0;
foreach ($value as $block) {
$index++;
$blockType = $block['type'];
try {
$blockFields = $fields[$blockType] ?? $this->fields($blockType) ?? [];
} catch (Throwable $e) {
// skip invalid blocks
continue;
}
// store the fields for the next round
$fields[$blockType] = $blockFields;
// overwrite the content with the serialized form
foreach ($this->form($blockFields, $block['content'])->fields() as $field) {
$errors = $field->errors();
// rough first validation
if (empty($errors) === false) {
throw new InvalidArgumentException([
'key' => 'blocks.validation',
'data' => [
'index' => $index,
]
]);
}
}
}
return true;
}
];
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace Kirby\Form\Field;
use Kirby\Cms\Fieldset;
use Kirby\Cms\Form;
use Kirby\Cms\Layout;
use Kirby\Cms\Layouts;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
use Throwable;
class LayoutField extends BlocksField
{
protected $layouts;
protected $settings;
public function __construct(array $params)
{
$this->setModel($params['model'] ?? site());
$this->setLayouts($params['layouts'] ?? ['1/1']);
$this->setSettings($params['settings'] ?? []);
parent::__construct($params);
}
public function fill($value = null)
{
$value = $this->valueFromJson($value);
$layouts = Layouts::factory($value, ['parent' => $this->model])->toArray();
foreach ($layouts as $layoutIndex => $layout) {
if ($this->settings !== null) {
$layouts[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->values();
}
foreach ($layout['columns'] as $columnIndex => $column) {
$layouts[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks']);
}
}
$this->value = $layouts;
}
public function attrsForm(array $input = [])
{
$settings = $this->settings();
return new Form([
'fields' => $settings ? $settings->fields() : [],
'model' => $this->model,
'strict' => true,
'values' => $input,
]);
}
public function layouts(): ?array
{
return $this->layouts;
}
public function props(): array
{
$settings = $this->settings();
return array_merge(parent::props(), [
'settings' => $settings !== null ? $settings->toArray() : null,
'layouts' => $this->layouts()
]);
}
public function routes(): array
{
$field = $this;
$routes = parent::routes();
$routes[] = [
'pattern' => 'layout',
'method' => 'POST',
'action' => function () use ($field) {
$defaults = $field->attrsForm([])->data(true);
$attrs = $field->attrsForm($defaults)->values();
$columns = get('columns') ?? ['1/1'];
return Layout::factory([
'attrs' => $attrs,
'columns' => array_map(function ($width) {
return [
'blocks' => [],
'id' => uuid(),
'width' => $width,
];
}, $columns)
])->toArray();
},
];
$routes[] = [
'pattern' => 'fields/(:any)/(:all?)',
'method' => 'ALL',
'action' => function (string $fieldName, string $path = null) use ($field) {
$form = $field->attrsForm();
$field = $form->field($fieldName);
$fieldApi = $this->clone([
'routes' => $field->api(),
'data' => array_merge($this->data(), ['field' => $field])
]);
return $fieldApi->call($path, $this->requestMethod(), $this->requestData());
}
];
return $routes;
}
protected function setLayouts(array $layouts = [])
{
$this->layouts = array_map(function ($layout) {
return Str::split($layout);
}, $layouts);
}
protected function setSettings(array $settings = [])
{
if (empty($settings) === true) {
$this->settings = null;
return;
}
$settings['icon'] = 'dashboard';
$settings['type'] = 'layout';
$settings['parent'] = $this->model();
$this->settings = Fieldset::factory($settings);
}
public function settings()
{
return $this->settings;
}
public function store($value)
{
$value = Layouts::factory($value, ['parent' => $this->model])->toArray();
foreach ($value as $layoutIndex => $layout) {
if ($this->settings !== null) {
$value[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->content();
}
foreach ($layout['columns'] as $columnIndex => $column) {
$value[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks'] ?? [], 'content');
}
}
return $this->valueToJson($value, $this->pretty());
}
public function validations(): array
{
return [
'layout' => function ($value) {
$fields = [];
$layoutIndex = 0;
foreach ($value as $layout) {
$layoutIndex++;
// validate settings form
foreach ($this->attrsForm($layout['attrs'] ?? [])->fields() as $field) {
$errors = $field->errors();
if (empty($errors) === false) {
throw new InvalidArgumentException([
'key' => 'layout.validation.settings',
'data' => [
'index' => $layoutIndex
]
]);
}
}
// validate blocks in the layout
$blockIndex = 0;
foreach ($layout['columns'] ?? [] as $column) {
foreach ($column['blocks'] ?? [] as $block) {
$blockIndex++;
$blockType = $block['type'];
try {
$blockFields = $fields[$blockType] ?? $this->fields($blockType) ?? [];
} catch (Throwable $e) {
// skip invalid blocks
continue;
}
// store the fields for the next round
$fields[$blockType] = $blockFields;
// overwrite the content with the serialized form
foreach ($this->form($blockFields, $block['content'])->fields() as $field) {
$errors = $field->errors();
// rough first validation
if (empty($errors) === false) {
throw new InvalidArgumentException([
'key' => 'layout.validation.block',
'data' => [
'blockIndex' => $blockIndex,
'layoutIndex' => $layoutIndex
]
]);
}
}
}
}
}
return true;
}
];
}
}

627
kirby/src/Form/FieldClass.php Executable file
View File

@@ -0,0 +1,627 @@
<?php
namespace Kirby\Form;
use Exception;
use Kirby\Cms\HasSiblings;
use Kirby\Cms\ModelWithContent;
use Kirby\Data\Data;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
use Throwable;
abstract class FieldClass
{
use HasSiblings;
protected $after;
protected $autofocus;
protected $before;
protected $default;
protected $disabled;
protected $help;
protected $icon;
protected $label;
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $model;
protected $name;
protected $params;
protected $placeholder;
protected $required;
protected $siblings;
protected $translate;
protected $value;
protected $when;
protected $width;
public function __call(string $param, array $args)
{
if (isset($this->$param) === true) {
return $this->$param;
}
return $this->params[$param] ?? null;
}
public function __construct(array $params = [])
{
$this->params = $params;
$this->setAfter($params['after'] ?? null);
$this->setAutofocus($params['autofocus'] ?? false);
$this->setBefore($params['before'] ?? null);
$this->setDefault($params['default'] ?? null);
$this->setDisabled($params['disabled'] ?? false);
$this->setHelp($params['help'] ?? null);
$this->setIcon($params['icon'] ?? null);
$this->setLabel($params['label'] ?? null);
$this->setModel($params['model'] ?? site());
$this->setName($params['name'] ?? null);
$this->setPlaceholder($params['placeholder'] ?? null);
$this->setRequired($params['required'] ?? false);
$this->setSiblings($params['siblings'] ?? null);
$this->setTranslate($params['translate'] ?? true);
$this->setWhen($params['when'] ?? null);
$this->setWidth($params['width'] ?? null);
if (array_key_exists('value', $params) === true) {
$this->fill($params['value']);
}
}
public function after(): ?string
{
return $this->stringTemplate($this->after);
}
public function api(): array
{
return $this->routes();
}
public function autofocus(): bool
{
return $this->autofocus;
}
public function before(): ?string
{
return $this->stringTemplate($this->before);
}
/**
* DEPRECATED!
*
* Returns the field data
* in a format to be stored
* in Kirby's content fields
*
* @param bool $default
* @return mixed
*/
public function data(bool $default = false)
{
return $this->store($this->value($default));
}
/**
* Returns the default value for the field,
* which will be used when a page/file/user is created
*/
public function default()
{
if ($this->default === null) {
return;
}
if (is_string($this->default) === false) {
return $this->default;
}
return $this->stringTemplate($this->default);
}
/**
* If `true`, the field is no longer editable and will not be saved
*/
public function disabled(): bool
{
return $this->disabled;
}
/**
* Optional help text below the field
*/
public function help(): ?string
{
if (empty($this->help) === false) {
$help = $this->stringTemplate($this->help);
$help = $this->kirby()->kirbytext($help);
return $help;
}
return null;
}
protected function i18n($param)
{
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
{
return $this->icon;
}
public function id(): string
{
return $this->name();
}
public function isDisabled(): bool
{
return $this->disabled;
}
public function isEmpty(): bool
{
return $this->isEmptyValue($this->value());
}
public function isEmptyValue($value): bool
{
return in_array($value, [null, '', []], true);
}
/**
* Checks if the field is invalid
*
* @return bool
*/
public function isInvalid(): bool
{
return $this->isValid() === false;
}
public function isRequired(): bool
{
return $this->required;
}
public function isSaveable(): bool
{
return true;
}
/**
* Checks if the field is valid
*
* @return bool
*/
public function isValid(): bool
{
return empty($this->errors()) === true;
}
/**
* Runs all validations and returns an array of
* error messages
*
* @return array
*/
public function errors(): array
{
return $this->validate();
}
/**
* Setter for the value
*
* @param mixed $value
* @return void
*/
public function fill($value = null)
{
$this->value = $value;
}
/**
* Returns the Kirby instance
*
* @return \Kirby\Cms\App
*/
public function kirby()
{
return $this->model->kirby();
}
/**
* 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 parent model
*
* @return mixed|null
*/
public function model()
{
return $this->model;
}
/**
* Returns the field name
*
* @return string
*/
public function name(): string
{
return $this->name ?? $this->type();
}
/**
* Checks if the field needs a value before being saved;
* this is the case if all of the following requirements are met:
* - The field is saveable
* - The field is required
* - The field is currently empty
* - The field is not currently inactive because of a `when` rule
*
* @return bool
*/
protected function needsValue(): bool
{
// check simple conditions first
if (
$this->isSaveable() === false ||
$this->isRequired() === false ||
$this->isEmpty() === false
) {
return false;
}
// check the data of the relevant fields if there is a `when` option
if (empty($this->when) === false && is_array($this->when) === true) {
$formFields = $this->siblings();
if ($formFields !== null) {
foreach ($this->when as $field => $value) {
$field = $formFields->get($field);
$inputValue = $field !== null ? $field->value() : '';
// if the input data doesn't match the requested `when` value,
// that means that this field is not required and can be saved
// (*all* `when` conditions must be met for this field to be required)
if ($inputValue !== $value) {
return false;
}
}
}
}
// either there was no `when` condition or all conditions matched
return true;
}
/**
* Returns all original params for the field
*
* @return array
*/
public function params(): array
{
return $this->params;
}
/**
* Optional placeholder value that will be shown when the field is empty
*/
public function placeholder(): ?string
{
return $this->stringTemplate($this->placeholder);
}
/**
* Define the props that will be sent to
* the Vue component
*
* @return array
*/
public function props(): array
{
return [
'after' => $this->after(),
'autofocus' => $this->autofocus(),
'before' => $this->before(),
'default' => $this->default(),
'disabled' => $this->isDisabled(),
'help' => $this->help(),
'icon' => $this->icon(),
'label' => $this->label(),
'name' => $this->name(),
'placeholder' => $this->placeholder(),
'required' => $this->isRequired(),
'saveable' => $this->isSaveable(),
'translate' => $this->translate(),
'type' => $this->type(),
'width' => $this->width(),
];
}
/**
* If `true`, the field has to be filled in correctly to be saved.
*
* @return bool
*/
public function required(): bool
{
return $this->required;
}
/**
* Routes for the field API
*
* @return array
*/
public function routes(): array
{
return [];
}
/**
* DEPRECATED
*
* @return bool
*/
public function save()
{
return $this->isSaveable();
}
protected function setAfter($after = null)
{
$this->after = $this->i18n($after);
}
protected function setAutofocus(bool $autofocus = false)
{
$this->autofocus = $autofocus;
}
protected function setBefore($before = null)
{
$this->before = $this->i18n($before);
}
protected function setDefault($default = null)
{
$this->default = $default;
}
protected function setDisabled(bool $disabled = false)
{
$this->disabled = $disabled;
}
protected function setHelp($help = null)
{
$this->help = $this->i18n($help);
}
protected function setIcon(string $icon = null)
{
$this->icon = $icon;
}
protected function setLabel($label = null)
{
$this->label = $this->i18n($label);
}
protected function setModel(ModelWithContent $model)
{
$this->model = $model;
}
protected function setName(string $name = null)
{
$this->name = $name;
}
protected function setPlaceholder($placeholder = null)
{
$this->placeholder = $this->i18n($placeholder);
}
protected function setRequired(bool $required = false)
{
$this->required = $required;
}
protected function setSiblings(Fields $siblings = null)
{
$this->siblings = $siblings ?? new Fields([]);
}
protected function setTranslate(bool $translate = true)
{
$this->translate = $translate;
}
protected function setWhen($when = null)
{
$this->when = $when;
}
protected function setWidth(string $width = null)
{
$this->width = $width;
}
protected function siblingsCollection()
{
return $this->siblings;
}
protected function stringTemplate(?string $string = null): ?string
{
if ($string !== null) {
return $this->model->toString($string);
}
return null;
}
public function store($value)
{
return $value;
}
/**
* Should the field be translatable?
*
* @return bool
*/
public function translate(): bool
{
return $this->translate;
}
/**
* Converts the field to a plain array
*
* @return array
*/
public function toArray(): array
{
$props = $this->props();
$props['signature'] = md5(json_encode($props));
ksort($props);
return array_filter($props, function ($item) {
return $item !== null;
});
}
public function type(): string
{
return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class)));
}
/**
* Runs the validations defined for the field
*
* @return array
*/
protected function validate(): array
{
$validations = $this->validations();
$value = $this->value();
$errors = [];
// validate required values
if ($this->needsValue() === true) {
$errors['required'] = I18n::translate('error.validation.required');
}
foreach ($validations as $key => $validation) {
if (is_int($key) === true) {
// predefined validation
try {
Validations::$validation($this, $value);
} catch (Exception $e) {
$errors[$validation] = $e->getMessage();
}
continue;
}
if (is_a($validation, 'Closure') === true) {
try {
$validation->call($this, $value);
} catch (Exception $e) {
$errors[$key] = $e->getMessage();
}
}
}
return $errors;
}
/**
* Defines all validation rules
*
* @return array
*/
protected function validations(): array
{
return [];
}
/**
* Returns the value of the field if saveable
* otherwise it returns null
*
* @return mixed
*/
public function value(bool $default = false)
{
if ($this->isSaveable() === false) {
return null;
}
if ($default === true && $this->isEmpty() === true) {
return $this->default();
}
return $this->value;
}
protected function valueFromJson($value): array
{
try {
return Data::decode($value, 'json');
} catch (Throwable $e) {
return [];
}
}
protected function valueFromYaml($value)
{
return Data::decode($value, 'yaml');
}
protected function valueToJson(array $value = null, bool $pretty = false): string
{
if ($pretty === true) {
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
return json_encode($value);
}
protected function valueToYaml(array $value = null): string
{
return Data::encode($value, 'yaml');
}
/**
* Returns the width of the field in
* the Panel grid
*
* @return string
*/
public function width(): string
{
return $this->width ?? '1/1';
}
}

View File

@@ -30,7 +30,7 @@ class Fields extends Collection
if (is_array($field) === true) {
// use the array key as name if the name is not set
$field['name'] = $field['name'] ?? $name;
$field = new Field($field['type'], $field);
$field = Field::factory($field['type'], $field, $this);
}
return parent::__set($field->name(), $field);

View File

@@ -2,6 +2,7 @@
namespace Kirby\Form;
use Kirby\Cms\App;
use Kirby\Data\Data;
use Throwable;
@@ -81,7 +82,7 @@ class Form
}
try {
$field = new Field($props['type'], $props, $this->fields);
$field = Field::factory($props['type'], $props, $this->fields);
} catch (Throwable $e) {
$field = static::exceptionField($e, $props);
}
@@ -178,13 +179,19 @@ class Form
*/
public static function exceptionField(Throwable $exception, array $props = [])
{
$message = $exception->getMessage();
if (App::instance()->option('debug') === true) {
$message .= ' in file: ' . $exception->getFile() . ' line: ' . $exception->getLine();
}
$props = array_merge($props, [
'label' => 'Error in "' . $props['name'] . '" field',
'label' => 'Error in "' . $props['name'] . '" field.',
'theme' => 'negative',
'text' => strip_tags($exception->getMessage()),
'text' => strip_tags($message),
]);
return new Field('info', $props);
return Field::factory('info', $props);
}
/**

View File

@@ -0,0 +1,18 @@
<?php
namespace Kirby\Form\Mixin;
trait EmptyState
{
protected $empty;
protected function setEmpty($empty = null)
{
$this->empty = $this->i18n($empty);
}
public function empty(): ?string
{
return $this->stringTemplate($this->empty);
}
}

18
kirby/src/Form/Mixin/Max.php Executable file
View File

@@ -0,0 +1,18 @@
<?php
namespace Kirby\Form\Mixin;
trait Max
{
protected $max;
public function max(): ?int
{
return $this->max;
}
protected function setMax(int $max = null)
{
$this->max = $max;
}
}

18
kirby/src/Form/Mixin/Min.php Executable file
View File

@@ -0,0 +1,18 @@
<?php
namespace Kirby\Form\Mixin;
trait Min
{
protected $min;
public function min(): ?int
{
return $this->min;
}
protected function setMin(int $min = null)
{
$this->min = $min;
}
}

View File

@@ -56,14 +56,8 @@ class Cookie
$_COOKIE[$key] = $value;
// store the cookie
// the array syntax is only supported by PHP 7.3+
// TODO: Always use the first alternative when support for PHP 7.2 is dropped
if (version_compare(PHP_VERSION, '7.3.0', '>=') === true) {
$options = compact('expires', 'path', 'domain', 'secure', 'httponly', 'samesite');
return setcookie($key, $value, $options);
} else {
return setcookie($key, $value, $expires, $path, $domain, $secure, $httponly);
}
$options = compact('expires', 'path', 'domain', 'secure', 'httponly', 'samesite');
return setcookie($key, $value, $options);
}
/**

View File

@@ -93,7 +93,7 @@ class Route
* @param Closure $action
* @param array $attributes
*/
public function __construct($pattern, $method = 'GET', Closure $action, array $attributes = [])
public function __construct($pattern, $method, Closure $action, array $attributes = [])
{
$this->action = $action;
$this->attributes = $attributes;

View File

@@ -114,7 +114,7 @@ class Dimensions
*/
public function fit(int $box, bool $force = false)
{
if ($this->width == 0 || $this->height == 0) {
if ($this->width === 0 || $this->height === 0) {
$this->width = $box;
$this->height = $box;
return $this;
@@ -379,7 +379,7 @@ class Dimensions
*/
public function square(): bool
{
return $this->width == $this->height;
return $this->width === $this->height;
}
/**

View File

@@ -2,11 +2,10 @@
namespace Kirby\Image;
use Exception;
use Kirby\Exception\Exception;
use Kirby\Http\Response;
use Kirby\Toolkit\File;
use Kirby\Toolkit\Html;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Mime;
use Kirby\Toolkit\V;
@@ -204,15 +203,44 @@ class Image extends File
*/
public function match(array $rules): bool
{
if (($rules['mime'] ?? null) !== null) {
if (Mime::isAccepted($this->mime(), $rules['mime']) !== true) {
throw new Exception(I18n::template('error.file.mime.invalid', [
'mime' => $this->mime()
]));
$rules = array_change_key_case($rules);
if (is_array($rules['mime'] ?? null) === true) {
$mime = $this->mime();
// determine if any pattern matches the MIME type;
// once any pattern matches, `$carry` is `true` and the rest is skipped
$matches = array_reduce($rules['mime'], function ($carry, $pattern) use ($mime) {
return $carry || Mime::matches($mime, $pattern);
}, false);
if ($matches !== true) {
throw new Exception([
'key' => 'file.mime.invalid',
'data' => compact('mime')
]);
}
}
$rules = array_change_key_case($rules);
if (is_array($rules['extension'] ?? null) === true) {
$extension = $this->extension();
if (in_array($extension, $rules['extension']) !== true) {
throw new Exception([
'key' => 'file.extension.invalid',
'data' => compact('extension')
]);
}
}
if (is_array($rules['type'] ?? null) === true) {
$type = $this->type();
if (in_array($type, $rules['type']) !== true) {
throw new Exception([
'key' => 'file.type.invalid',
'data' => compact('type')
]);
}
}
$validations = [
'maxsize' => ['size', 'max'],
@@ -232,9 +260,10 @@ class Image extends File
$validator = $arguments[1];
if (V::$validator($this->$property(), $rule) === false) {
throw new Exception(I18n::template('error.file.' . $key, [
$property => $rule
]));
throw new Exception([
'key' => 'file.' . $key,
'data' => [$property => $rule]
]);
}
}
}

View File

@@ -79,7 +79,7 @@ class Location
$seconds = count($coord) > 2 ? $this->num($coord[2]) : 0;
$hemi = strtoupper($hemi);
$flip = ($hemi == 'W' || $hemi == 'S') ? -1 : 1;
$flip = ($hemi === 'W' || $hemi === 'S') ? -1 : 1;
return $flip * ($degrees + $minutes / 60 + $seconds / 3600);
}
@@ -94,7 +94,7 @@ class Location
{
$parts = explode('/', $part);
if (count($parts) == 1) {
if (count($parts) === 1) {
return $parts[0];
}

100
kirby/src/Parsley/Element.php Executable file
View File

@@ -0,0 +1,100 @@
<?php
namespace Kirby\Parsley;
use DOMElement;
use DOMXpath;
use Kirby\Toolkit\Str;
class Element
{
protected $marks;
protected $node;
public function __construct(DOMElement $node, array $marks = [])
{
$this->marks = $marks;
$this->node = $node;
}
public function attr(string $attr, $fallback = null)
{
if ($this->node->hasAttribute($attr)) {
return $this->node->getAttribute($attr) ?? $fallback;
}
return $fallback;
}
public function children()
{
return $this->node->childNodes;
}
public function classList(): array
{
return Str::split($this->className(), ' ');
}
public function className()
{
return $this->node->getAttribute('class');
}
public function element()
{
return $this->node;
}
public function filter(string $query)
{
$result = [];
if ($queryResult = $this->query($query)) {
foreach ($queryResult as $node) {
$result[] = new static($node);
}
}
return $result;
}
public function find(string $query)
{
if ($result = $this->query($query)[0]) {
return new static($result);
}
return false;
}
public function innerHtml(array $marks = null): string
{
return (new Inline($this->node, $marks ?? $this->marks))->innerHtml();
}
public function innerText()
{
return trim($this->node->textContent);
}
public function outerHtml(array $marks = null): string
{
return $this->node->ownerDocument->saveHtml($this->node);
}
public function query($query)
{
return (new DOMXPath($this->node->ownerDocument))->query($query, $this->node);
}
public function remove()
{
$this->node->parentNode->removeChild($this->node);
}
public function tagName(): string
{
return $this->node->tagName;
}
}

74
kirby/src/Parsley/Inline.php Executable file
View File

@@ -0,0 +1,74 @@
<?php
namespace Kirby\Parsley;
class Inline
{
protected $html = '';
protected $marks = [];
public function __construct($node, array $marks = [])
{
$this->createMarkRules($marks);
$this->html = trim($this->parseNode($node));
}
public function createMarkRules($marks)
{
foreach ($marks as $mark) {
$this->marks[$mark['tag']] = $mark;
}
}
public function parseChildren($children): string
{
if (!$children) {
return '';
}
$html = '';
foreach ($children as $child) {
$html .= $this->parseNode($child);
}
return $html;
}
public function parseNode($node)
{
$html = '';
if (is_a($node, 'DOMText') === true) {
return $node->textContent;
}
// ignore comments
if (is_a($node, 'DOMComment') === true) {
return '';
}
// known marks
if (array_key_exists($node->tagName, $this->marks) === true) {
$mark = $this->marks[$node->tagName];
$attrs = [];
$defaults = $mark['defaults'] ?? [];
foreach ($mark['attrs'] ?? [] as $attr) {
if ($node->hasAttribute($attr)) {
$attrs[$attr] = $node->getAttribute($attr);
} else {
$attrs[$attr] = $defaults[$attr] ?? null;
}
}
return '<' . $node->tagName . attr($attrs, ' ') . '>' . $this->parseChildren($node->childNodes) . '</' . $node->tagName . '>';
}
// unknown marks
return $this->parseChildren($node->childNodes);
}
public function innerHtml()
{
return $this->html;
}
}

231
kirby/src/Parsley/Parsley.php Executable file
View File

@@ -0,0 +1,231 @@
<?php
namespace Kirby\Parsley;
use DOMDocument;
use DOMXPath;
use Kirby\Parsley\Schema\Plain;
class Parsley
{
protected $blocks = [];
protected $body;
protected $doc;
protected $marks = [];
protected $nodes = [];
protected $schema;
protected $skip = [];
public static $useXmlExtension = true;
public function __construct(string $html, Schema $schema = null)
{
// fail gracefully if the XML extension is not installed
// or should be skipped
if ($this->useXmlExtension() === false) {
$this->blocks[] = [
'type' => 'markdown',
'content' => [
'text' => $html,
]
];
return;
}
libxml_use_internal_errors(true);
$this->doc = new DOMDocument();
$this->doc->preserveWhiteSpace = false;
$this->doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
libxml_clear_errors();
$this->schema = $schema ?? new Plain();
$this->skip = $this->schema->skip();
$this->marks = $this->schema->marks();
$this->inline = [];
$this->createNodeRules($this->schema->nodes());
$this->parseNode($this->body());
$this->endInlineBlock();
}
public function blocks(): array
{
return $this->blocks;
}
public function body()
{
return $this->body = $this->body ?? $this->query($this->doc, '/html/body')[0];
}
public function createNodeRules($nodes)
{
foreach ($nodes as $node) {
$this->nodes[$node['tag']] = $node;
}
}
public function containsBlock($element): bool
{
if (!$element->childNodes) {
return false;
}
foreach ($element->childNodes as $childNode) {
if ($this->isBlock($childNode) === true || $this->containsBlock($childNode)) {
return true;
}
}
return false;
}
public function endInlineBlock()
{
$html = [];
foreach ($this->inline as $inline) {
$node = new Inline($inline, $this->marks);
$html[] = $node->innerHTML();
}
$innerHTML = implode(' ', $html);
if ($fallback = $this->fallback($innerHTML)) {
$this->mergeOrAppend($fallback);
}
$this->inline = [];
}
public function fallback($node)
{
if (is_a($node, 'DOMText') === true) {
$html = $node->textContent;
} elseif (is_a($node, Element::class) === true) {
$html = $node->innerHtml();
} elseif (is_string($node) === true) {
$html = $node;
} else {
$html = '';
}
if ($fallback = $this->schema->fallback($html)) {
return $fallback;
}
return false;
}
public function isBlock($element): bool
{
if (is_a($element, 'DOMElement') === false) {
return false;
}
return array_key_exists($element->tagName, $this->nodes) === true;
}
public function isInline($element)
{
if (is_a($element, 'DOMText') === true) {
return true;
}
if (is_a($element, 'DOMElement') === true) {
if ($this->containsBlock($element) === true) {
return false;
}
if ($element->tagName === 'p') {
return false;
}
$marks = array_column($this->marks, 'tag');
return in_array($element->tagName, $marks);
}
return false;
}
public function mergeOrAppend($block)
{
$lastIndex = count($this->blocks) - 1;
$lastItem = $this->blocks[$lastIndex] ?? null;
// merge with previous block
if ($block['type'] === 'text' && $lastItem && $lastItem['type'] === 'text') {
$this->blocks[$lastIndex]['content']['text'] .= "\n\n" . $block['content']['text'];
// append
} else {
$this->blocks[] = $block;
}
}
public function parseNode($element)
{
// comments
if (is_a($element, 'DOMComment') === true) {
return true;
}
// inline context
if ($this->isInline($element)) {
$this->inline[] = $element;
return true;
} else {
$this->endInlineBlock();
}
// known block nodes
if ($this->isBlock($element) === true) {
if ($parser = ($this->nodes[$element->tagName]['parse'] ?? null)) {
if ($result = $parser(new Element($element, $this->marks))) {
$this->blocks[] = $result;
}
}
return true;
}
// has only unkown children (div, etc.)
if ($this->containsBlock($element) === false) {
if (in_array($element->tagName, $this->skip) === true) {
return true;
}
if ($element->tagName !== 'body') {
$node = new Element($element, $this->marks);
if ($block = $this->fallback($node)) {
$this->mergeOrAppend($block);
}
return true;
}
}
// parse all children
foreach ($element->childNodes as $childNode) {
$this->parseNode($childNode);
}
}
public function query($element, $query)
{
return (new DOMXPath($element))->query($query);
}
public function useXmlExtension(): bool
{
if (static::$useXmlExtension !== true) {
return false;
}
return class_exists('DOMDocument') === true;
}
}

11
kirby/src/Parsley/Schema.php Executable file
View File

@@ -0,0 +1,11 @@
<?php
namespace Kirby\Parsley;
abstract class Schema
{
abstract public function fallback(string $html);
abstract public function marks(): array;
abstract public function nodes(): array;
abstract public function skip(): array;
}

View File

@@ -0,0 +1,317 @@
<?php
namespace Kirby\Parsley\Schema;
use Kirby\Parsley\Element;
use Kirby\Toolkit\Str;
class Blocks extends Plain
{
public function fallback(string $html)
{
$html = trim($html);
if (Str::length($html) === 0) {
return false;
}
return [
'content' => [
'text' => '<p>' . $html . '</p>',
],
'type' => 'text',
];
}
public function heading($node, $level)
{
$content = [
'level' => $level,
'text' => $node->innerHTML()
];
if ($id = $node->attr('id')) {
$content['id'] = $id;
}
ksort($content);
return [
'content' => $content,
'type' => 'heading',
];
}
public function list($node)
{
$html = [];
foreach ($node->filter('li') as $li) {
$innerHtml = '';
foreach ($li->children() as $child) {
if (is_a($child, 'DOMText') === true) {
$innerHtml .= $child->textContent;
} elseif (is_a($child, 'DOMElement') === true) {
$child = new Element($child);
if (in_array($child->tagName(), ['ul', 'ol']) === true) {
$innerHtml .= $this->list($child);
} else {
$innerHtml .= $child->innerHTML($this->marks());
}
}
}
$html[] = '<li>' . trim($innerHtml) . '</li>';
}
return '<' . $node->tagName() . '>' . implode($html) . '</' . $node->tagName() . '>';
}
public function marks(): array
{
return [
[
'tag' => 'a',
'attrs' => ['href', 'target', 'title'],
],
[
'tag' => 'abbr',
],
[
'tag' => 'b'
],
[
'tag' => 'code'
],
[
'tag' => 'del',
],
[
'tag' => 'em',
],
[
'tag' => 'i',
],
[
'tag' => 'strike',
],
[
'tag' => 'sub',
],
[
'tag' => 'sup',
],
[
'tag' => 'strong',
],
[
'tag' => 'u',
],
];
}
public function nodes(): array
{
return [
[
'tag' => 'blockquote',
'parse' => function ($node) {
$citation = null;
$text = [];
// get all the text for the quote
foreach ($node->element()->childNodes as $child) {
if (is_a($child, 'DOMText') === true) {
$text[] = trim($child->textContent);
}
if (is_a($child, 'DOMElement') === true && $child->tagName !== 'footer') {
$text[] = (new Element($child))->innerHTML($this->marks());
}
}
// filter empty blocks and separate text blocks with breaks
$text = implode('<br></br>', array_filter($text));
// get the citation from the footer
if ($footer = $node->find('footer')) {
$citation = $footer->innerHTML($this->marks());
}
return [
'content' => [
'citation' => $citation,
'text' => $text
],
'type' => 'quote',
];
}
],
[
'tag' => 'h1',
'parse' => function ($node) {
return $this->heading($node, 'h1');
}
],
[
'tag' => 'h2',
'parse' => function ($node) {
return $this->heading($node, 'h2');
}
],
[
'tag' => 'h3',
'parse' => function ($node) {
return $this->heading($node, 'h3');
}
],
[
'tag' => 'h4',
'parse' => function ($node) {
return $this->heading($node, 'h4');
}
],
[
'tag' => 'h5',
'parse' => function ($node) {
return $this->heading($node, 'h5');
}
],
[
'tag' => 'h6',
'parse' => function ($node) {
return $this->heading($node, 'h6');
}
],
[
'tag' => 'iframe',
'parse' => function ($node) {
$caption = null;
$src = $node->attr('src');
if ($figcaption = $node->find('ancestor::figure[1]//figcaption')) {
$caption = $figcaption->innerHTML($this->marks());
// avoid parsing the caption twice
$figcaption->remove();
}
// reverse engineer video URLs
if (preg_match('!player.vimeo.com\/video\/([0-9]+)!i', $src, $array) === 1) {
$src = 'https://vimeo.com/' . $array[1];
} elseif (preg_match('!youtube.com\/embed\/([a-zA-Z0-9_-]+)!', $src, $array) === 1) {
$src = 'https://youtube.com/watch?v=' . $array[1];
} elseif (preg_match('!youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)!', $src, $array) === 1) {
$src = 'https://youtube.com/watch?v=' . $array[1];
} else {
$src = false;
}
// correct video URL
if ($src) {
return [
'content' => [
'caption' => $caption,
'url' => $src
],
'type' => 'video',
];
}
return [
'content' => [
'text' => $node->outerHTML()
],
'type' => 'markdown',
];
}
],
[
'tag' => 'img',
'parse' => function ($node) {
$caption = null;
$link = null;
if ($figcaption = $node->find('ancestor::figure[1]//figcaption')) {
$caption = $figcaption->innerHTML($this->marks());
// avoid parsing the caption twice
$figcaption->remove();
}
if ($a = $node->find('ancestor::a')) {
$link = $a->attr('href');
}
return [
'content' => [
'alt' => $node->attr('alt'),
'caption' => $caption,
'link' => $link,
'location' => 'web',
'src' => $node->attr('src'),
],
'type' => 'image',
];
}
],
[
'tag' => 'ol',
'parse' => function ($node) {
return [
'content' => [
'text' => $this->list($node)
],
'type' => 'list',
];
}
],
[
'tag' => 'pre',
'parse' => function ($node) {
$language = 'text';
if ($code = $node->find('//code')) {
foreach ($code->classList() as $className) {
if (preg_match('!language-(.*?)!', $className)) {
$language = str_replace('language-', '', $className);
break;
}
}
}
return [
'content' => [
'code' => $node->innerText(),
'language' => $language
],
'type' => 'code',
];
}
],
[
'tag' => 'table',
'parse' => function ($node) {
return [
'content' => [
'text' => $node->outerHTML(),
],
'type' => 'markdown',
];
}
],
[
'tag' => 'ul',
'parse' => function ($node) {
return [
'content' => [
'text' => $this->list($node)
],
'type' => 'list',
];
}
],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Kirby\Parsley\Schema;
use Kirby\Parsley\Schema;
use Kirby\Toolkit\Str;
class Plain extends Schema
{
public function fallback(string $html)
{
$text = trim($html);
if (Str::length($text) === 0) {
return false;
}
return [
'type' => 'text',
'content' => [
'text' => $text
]
];
}
public function marks(): array
{
return [];
}
public function nodes(): array
{
return [];
}
public function skip(): array
{
return ['meta', 'script', 'style'];
}
}

View File

@@ -141,13 +141,13 @@ class A
*
* @param array $array1
* @param array $array2
* @param bool $mode Behavior for elements with numeric keys;
* A::MERGE_APPEND: elements are appended, keys are reset;
* A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
* A::MERGE_REPLACE: non-associative arrays are completely replaced
* @param int $mode Behavior for elements with numeric keys;
* A::MERGE_APPEND: elements are appended, keys are reset;
* A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
* A::MERGE_REPLACE: non-associative arrays are completely replaced
* @return array
*/
public static function merge($array1, $array2, $mode = A::MERGE_APPEND)
public static function merge($array1, $array2, int $mode = A::MERGE_APPEND)
{
$merged = $array1;
@@ -158,7 +158,7 @@ class A
foreach ($array2 as $key => $value) {
// append to the merged array, don't overwrite numeric keys
if (is_int($key) === true && $mode == static::MERGE_APPEND) {
if (is_int($key) === true && $mode === static::MERGE_APPEND) {
$merged[] = $value;
// recursively merge the two array values
@@ -171,7 +171,7 @@ class A
}
}
if ($mode == static::MERGE_APPEND) {
if ($mode === static::MERGE_APPEND) {
// the keys don't make sense anymore, reset them
// array_merge() is the simplest way to renumber
// arrays that have both numeric and string keys;
@@ -553,7 +553,7 @@ class A
*/
public static function sort(array $array, string $field, string $direction = 'desc', $method = SORT_REGULAR): array
{
$direction = strtolower($direction) == 'desc' ? SORT_DESC : SORT_ASC;
$direction = strtolower($direction) === 'desc' ? SORT_DESC : SORT_ASC;
$helper = [];
$result = [];

View File

@@ -236,55 +236,53 @@ class Collection extends Iterator implements Countable
return $collection->set($items);
}
/**
* Filters elements by a custom
* filter function or an array of filters
*
* @param array|\Closure $filter
* @return self
* @throws \Exception if $filter is neither a closure nor an array
*/
public function filter($filter)
{
if (is_callable($filter) === true) {
$collection = clone $this;
$collection->data = array_filter($this->data, $filter);
return $collection;
} elseif (is_array($filter) === true) {
$collection = $this;
foreach ($filter as $arguments) {
$collection = $collection->filterBy(...$arguments);
}
return $collection;
}
throw new Exception('The filter method needs either an array of filterBy rules or a closure function to be passed as parameter.');
}
/**
* Filters elements by one of the
* predefined filter methods.
* predefined filter methods, by a
* custom filter function or an array of filters
*
* @param string $field
* @param string|array|\Closure $field
* @param array ...$args
* @return \Kirby\Toolkit\Collection
*/
public function filterBy(string $field, ...$args)
public function filter($field, ...$args)
{
$operator = '==';
$test = $args[0] ?? null;
$split = $args[1] ?? false;
if (is_string($test) === true && isset(static::$filters[$test]) === true) {
// filter by custom filter function
if (is_callable($field) === true) {
$collection = clone $this;
$collection->data = array_filter($this->data, $field);
return $collection;
}
// array of filters
if (is_array($field) === true) {
$collection = $this;
foreach ($field as $filter) {
$collection = $collection->filter(...$filter);
}
return $collection;
}
if (
is_string($test) === true &&
isset(static::$filters[$test]) === true
) {
$operator = $test;
$test = $args[1] ?? null;
$split = $args[2] ?? false;
}
if (is_object($test) === true && method_exists($test, '__toString') === true) {
if (
is_object($test) === true &&
method_exists($test, '__toString') === true
) {
$test = (string)$test;
}
@@ -321,11 +319,18 @@ class Collection extends Iterator implements Countable
}
/**
* @param string $validator
* @param mixed $values
* @param mixed $test
* @return bool
* Alias for `Kirby\Toolkit\Collection::filter`
*
* @param string|Closure $field
* @param array ...$args
* @return self
*/
public function filterBy(...$args)
{
return $this->filter(...$args);
}
protected function filterMatchesAny($validator, $values, $test): bool
{
foreach ($values as $value) {
@@ -472,7 +477,7 @@ class Collection extends Iterator implements Countable
* Extracts an attribute value from the given element
* in the collection. This is useful if elements in the collection
* might be objects, arrays or anything else and you need to
* get the value independently from that. We use it for filterBy.
* get the value independently from that. We use it for `filter`.
*
* @param array|object $item
* @param string $attribute
@@ -516,69 +521,77 @@ class Collection extends Iterator implements Countable
}
/**
* Groups the elements by a given callback
* Groups the elements by a given field or callback function
*
* @param \Closure $callback
* @return \Kirby\Toolkit\Collection A new collection with an element for each group and a sub collection in each group
* @throws \Exception
* @param string|Closure $field
* @param bool $i
* @return \Kirby\Toolkit\Collection A new collection with an element for each group and a subcollection in each group
* @throws \Exception if $field is not a string nor a callback function
*/
public function group(Closure $callback)
public function group($field, bool $i = true)
{
$groups = [];
foreach ($this->data as $key => $item) {
// group by field name
if (is_string($field) === true) {
return $this->group(function ($item) use ($field, $i) {
$value = $this->getAttribute($item, $field);
// get the value to group by
$value = $callback($item);
// ignore upper/lowercase for group names
return $i === true ? Str::lower($value) : $value;
});
}
// make sure that there's always a proper value to group by
if (!$value) {
throw new Exception('Invalid grouping value for key: ' . $key);
}
// group via callback function
if (is_callable($field) === true) {
$groups = [];
// make sure we have a proper key for each group
if (is_array($value) === true) {
throw new Exception('You cannot group by arrays or objects');
} elseif (is_object($value) === true) {
if (method_exists($value, '__toString') === false) {
foreach ($this->data as $key => $item) {
// get the value to group by
$value = $field($item);
// make sure that there's always a proper value to group by
if (!$value) {
throw new Exception('Invalid grouping value for key: ' . $key);
}
// make sure we have a proper key for each group
if (is_array($value) === true) {
throw new Exception('You cannot group by arrays or objects');
} elseif (is_object($value) === true) {
if (method_exists($value, '__toString') === false) {
throw new Exception('You cannot group by arrays or objects');
} else {
$value = (string)$value;
}
}
if (isset($groups[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups[$value] = new static([$key => $item]);
} else {
$value = (string)$value;
// add the element to an existing group
$groups[$value]->set($key, $item);
}
}
if (isset($groups[$value]) === false) {
// create a new entry for the group if it does not exist yet
$groups[$value] = new static([$key => $item]);
} else {
// add the element to an existing group
$groups[$value]->set($key, $item);
}
return new Collection($groups);
}
return new Collection($groups);
throw new Exception('Can only group by string values or by providing a callback function');
}
/**
* Groups the elements by a given field
* Alias for `Kirby\Toolkit\Collection::group`
*
* @param string $field
* @param string|Closure $field
* @param bool $i
* @return \Kirby\Toolkit\Collection A new collection with an element for each group and a sub collection in each group
* @throws \Exception
*/
public function groupBy($field, bool $i = true)
public function groupBy(...$args)
{
if (is_string($field) === false) {
throw new Exception('Cannot group by non-string values. Did you mean to call group()?');
}
return $this->group(function ($item) use ($field, $i) {
$value = $this->getAttribute($item, $field);
// ignore upper/lowercase for group names
return $i === true ? Str::lower($value) : $value;
});
return $this->group(...$args);
}
/**
@@ -799,7 +812,7 @@ class Collection extends Iterator implements Countable
}
/**
* Runs a combination of filterBy, sortBy, not
* Runs a combination of filter, sort, not,
* offset, limit and paginate on the collection.
* Any part of the query is optional.
*
@@ -814,10 +827,17 @@ class Collection extends Iterator implements Countable
$result = $result->not(...$arguments['not']);
}
if (isset($arguments['filterBy']) === true) {
foreach ($arguments['filterBy'] as $filter) {
if (isset($filter['field']) === true && isset($filter['value']) === true) {
$result = $result->filterBy($filter['field'], $filter['operator'] ?? '==', $filter['value']);
if ($filters = $arguments['filterBy'] ?? $arguments['filter'] ?? null) {
foreach ($filters as $filter) {
if (
isset($filter['field']) === true &&
isset($filter['value']) === true
) {
$result = $result->filter(
$filter['field'],
$filter['operator'] ?? '==',
$filter['value']
);
}
}
}
@@ -830,18 +850,18 @@ class Collection extends Iterator implements Countable
$result = $result->limit($arguments['limit']);
}
if (isset($arguments['sortBy']) === true) {
if (is_array($arguments['sortBy'])) {
$sort = explode(' ', implode(' ', $arguments['sortBy']));
if ($sort = $arguments['sortBy'] ?? $arguments['sort'] ?? null) {
if (is_array($sort)) {
$sort = explode(' ', implode(' ', $sort));
} else {
// if there are commas in the sortBy argument, removes it
if (Str::contains($arguments['sortBy'], ',') === true) {
$arguments['sortBy'] = Str::replace($arguments['sortBy'], ',', '');
// if there are commas in the sort argument, removes it
if (Str::contains($sort, ',') === true) {
$sort = Str::replace($sort, ',', '');
}
$sort = explode(' ', $arguments['sortBy']);
$sort = explode(' ', $sort);
}
$result = $result->sortBy(...$sort);
$result = $result->sort(...$sort);
}
if (isset($arguments['paginate']) === true) {
@@ -924,17 +944,17 @@ class Collection extends Iterator implements Countable
/**
* Get sort arguments from a string
*
* @param string $sortBy
* @param string $sort
* @return array
*/
public static function sortArgs(string $sortBy): array
public static function sortArgs(string $sort): array
{
// if there are commas in the sortBy argument, removes it
if (Str::contains($sortBy, ',') === true) {
$sortBy = Str::replace($sortBy, ',', '');
if (Str::contains($sort, ',') === true) {
$sort = Str::replace($sort, ',', '');
}
$sortArgs = Str::split($sortBy, ' ');
$sortArgs = Str::split($sort, ' ');
// fill in PHP constants
array_walk($sortArgs, function (string &$value) {
@@ -954,7 +974,7 @@ class Collection extends Iterator implements Countable
* @param int $method The sort flag, SORT_REGULAR, SORT_NUMERIC etc.
* @return \Kirby\Toolkit\Collection
*/
public function sortBy()
public function sort()
{
// there is no need to sort empty collections
if (empty($this->data) === true) {
@@ -1062,6 +1082,19 @@ class Collection extends Iterator implements Countable
return $collection;
}
/**
* Alias for `Kirby\Toolkit\Collection::sort`
*
* @param string|callable $field Field name or value callback to sort by
* @param string $direction asc or desc
* @param int $method The sort flag, SORT_REGULAR, SORT_NUMERIC etc.
* @return Collection
*/
public function sortBy(...$args)
{
return $this->sort(...$args);
}
/**
* Converts the object into an array
*

View File

@@ -554,6 +554,10 @@ class F
*/
public static function read(string $file)
{
if (is_file($file) !== true) {
return false;
}
return @file_get_contents($file);
}
@@ -764,6 +768,18 @@ class F
return null;
}
/**
* Returns all extensions of a given file type
* or `null` if the file type is unknown
*
* @param string $type
* @return array|null
*/
public static function typeToExtensions(string $type): ?array
{
return static::$types[$type] ?? null;
}
/**
* Unzips a zip file
*

View File

@@ -3,6 +3,7 @@
namespace Kirby\Toolkit;
use Closure;
use NumberFormatter;
/**
* Localization class, roughly inspired by VueI18n
@@ -43,6 +44,13 @@ class I18n
*/
public static $fallback = 'en';
/**
* Cache of `NumberFormatter` objects by locale
*
* @var array
*/
protected static $decimalNumberFormatters = [];
/**
* Returns the fallback code
*
@@ -78,6 +86,25 @@ class I18n
return $count === 1 ? 'singular' : 'plural';
}
/**
* Formats a number
*
* @param int|float $number
* @param string $locale
* @return string
*/
public static function formatNumber($number, string $locale = null): string
{
$locale = $locale ?? static::locale();
$formatter = static::decimalNumberFormatter($locale);
if ($formatter !== null) {
$number = $formatter->format($number);
}
return (string)$number;
}
/**
* Returns the locale code
*
@@ -189,6 +216,24 @@ class I18n
return static::$translations;
}
/**
* Returns (and creates) a decimal number formatter for a given locale
*
* @return \NumberFormatter|null
*/
protected static function decimalNumberFormatter(string $locale): ?NumberFormatter
{
if (isset(static::$decimalNumberFormatters[$locale])) {
return static::$decimalNumberFormatters[$locale];
}
if (extension_loaded('intl') !== true || class_exists('NumberFormatter') !== true) {
return null;
}
return static::$decimalNumberFormatters[$locale] = new NumberFormatter($locale, NumberFormatter::DECIMAL);
}
/**
* Translates amounts
*
@@ -202,10 +247,13 @@ class I18n
* @param string $key
* @param int $count
* @param string $locale
* @param bool $formatNumber If set to `false`, the count is not formatted
* @return mixed
*/
public static function translateCount(string $key, int $count, string $locale = null)
public static function translateCount(string $key, int $count, string $locale = null, bool $formatNumber = true)
{
$locale = $locale ?? static::locale();
$translation = static::translate($key, null, $locale);
if ($translation === null) {
@@ -226,6 +274,10 @@ class I18n
}
}
if ($formatNumber === true) {
$count = static::formatNumber($count, $locale);
}
return str_replace('{{ count }}', $count, $message);
}
}

101
kirby/src/Toolkit/Locale.php Executable file
View File

@@ -0,0 +1,101 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Exception\InvalidArgumentException;
/**
* PHP locale handling
*
* @package Kirby Toolkit
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Locale
{
/**
* Converts a normalized locale array to an array with the
* locale constants replaced with their string representations
*
* @param array $locale
* @return array
*/
public static function export(array $locale): array
{
// list of all possible constant names
$constantNames = [
'LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY',
'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES'
];
// build an associative array with the locales
// that are actually supported on this system
$constants = [];
foreach ($constantNames as $name) {
if (defined($name) === true) {
$constants[constant($name)] = $name;
}
}
// replace the keys in the locale data array with the locale names
$return = [];
foreach ($locale as $key => $value) {
if (isset($constants[$key]) === true) {
// the key is a valid constant,
// replace it with its string representation
$return[$constants[$key]] = $value;
} else {
// not found, keep it as-is
$return[$key] = $value;
}
}
return $return;
}
/**
* Converts a locale string or an array with constant or
* string keys to a normalized constant => value array
*
* @param array|string $locale
* @return array
*/
public static function normalize($locale): array
{
if (is_array($locale)) {
// replace string constant keys with the constant values
$convertedLocale = [];
foreach ($locale as $key => $value) {
if (is_string($key) === true && Str::startsWith($key, 'LC_') === true) {
$key = constant($key);
}
$convertedLocale[$key] = $value;
}
return $convertedLocale;
} elseif (is_string($locale)) {
return [LC_ALL => $locale];
} else {
throw new InvalidArgumentException('Locale must be string or array');
}
}
/**
* Sets the PHP locale with a locale string or
* an array with constant or string keys
*
* @param array|string $locale
* @return void
*/
public static function set($locale): void
{
$locale = static::normalize($locale);
foreach ($locale as $key => $value) {
setlocale($key, $value);
}
}
}

View File

@@ -126,48 +126,30 @@ class Pagination
/**
* Getter for the current page
*
* @deprecated 3.3.0 Setter is no longer supported, use $pagination->clone()
* @return int
* @codeCoverageIgnore
*/
public function page(int $page = null): int
public function page(): int
{
if ($page !== null) {
throw new Exception('$pagination->page() setter is no longer supported, use $pagination->clone()'); // @codeCoverageIgnore
}
return $this->page;
}
/**
* Getter for the total number of items
*
* @deprecated 3.3.0 Setter is no longer supported, use $pagination->clone()
* @return int
* @codeCoverageIgnore
*/
public function total(int $total = null): int
public function total(): int
{
if ($total !== null) {
throw new Exception('$pagination->total() setter is no longer supported, use $pagination->clone()'); // @codeCoverageIgnore
}
return $this->total;
}
/**
* Getter for the number of items per page
*
* @deprecated 3.3.0 Setter is no longer supported, use $pagination->clone()
* @return int
* @codeCoverageIgnore
*/
public function limit(int $limit = null): int
public function limit(): int
{
if ($limit !== null) {
throw new Exception('$pagination->limit() setter is no longer supported, use $pagination->clone()'); // @codeCoverageIgnore
}
return $this->limit;
}

View File

@@ -289,7 +289,7 @@ class Str
for ($i = 0; $i < static::length($string); $i++) {
$char = static::substr($string, $i, 1);
list(, $code) = unpack('N', mb_convert_encoding($char, 'UCS-4BE', 'UTF-8'));
$encoded .= rand(1, 2) == 1 ? '&#' . $code . ';' : '&#x' . dechex($code) . ';';
$encoded .= rand(1, 2) === 1 ? '&#' . $code . ';' : '&#x' . dechex($code) . ';';
}
return $encoded;
@@ -957,7 +957,7 @@ class Str
* @param mixed $type
* @return mixed
*/
public static function toType($string = null, $type)
public static function toType($string, $type)
{
if (is_string($type) === false) {
$type = gettype($type);