diff --git a/public/assets/css/panel.css b/public/assets/css/panel.css
index c7ca367..df6709e 100644
--- a/public/assets/css/panel.css
+++ b/public/assets/css/panel.css
@@ -6,11 +6,10 @@
--color-l-300: 85%;
--color-l-400: 80%;
--color-l-500: 74%;
- --menu-color-back: white;
}
.k-login-dialog::before {
- content: 'Design to Pack';
+ content: "Design to Pack";
text-align: center;
height: 8rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='144' height='53' class='logo-courval' viewBox='0 0 144 53'%3E%3Cdefs%3E%3Cpath id='a' d='M0 52h144V0H0z'/%3E%3C/defs%3E%3Cg fill='none' fill-rule='evenodd' transform='translate(0 .5)'%3E%3Cpath fill='%23717373' d='M11.3210912 45.6153321c.5185261-.5146003 1.2495928-.7882083 2.1162459-.7882083 2.0521173 0 2.9700733 1.5293052 2.9700733 3.0441146 0 1.4713221-.9289495 3.0567984-2.9700733 3.0567984-2.0191368 0-2.9499185-1.5854763-2.9590798-3.0586104-.0054967-.9059864.2931596-1.7068785.8428339-2.2540943m2.1162459-1.8608963c-2.8161645 0-4.07675078 2.0638373-4.07675078 4.1077427 0 1.9913583 1.27524428 4.1367343 4.07675078 4.1367343 2.7868486 0 4.0657574-2.1326922 4.0767508-4.1149906.007329-1.1977141-.4067589-2.2722141-1.1634772-3.0241828-.7274022-.7229773-1.7351384-1.1053036-2.9132736-1.1053036M24.7888436 44.8379957c.8226792 0 1.5885587.3080354 2.1547232.869747l.1813925.1775733.8116857-.6957976-.207044-.2047529c-.7805375-.7719005-1.8249186-1.1977141-2.9407574-1.1977141-1.2147801 0-2.25.3950101-2.997557 1.1415429-.7567182.7574047-1.1671416 1.8192209-1.1543159 2.9879434.0109935 2.01129 1.2990635 4.0406997 4.1518729 4.0406997 1.1158388 0 2.1602199-.4258136 2.9407574-1.1977141l.1978827-.197505-.7823697-.7302251-.1868892.1811973c-.569829.5544637-1.36136.8715589-2.1693812.8715589-2.220684 0-3.0287052-1.6126559-3.0470276-2.9933793-.0036645-.8969266.2949918-1.6905708.8446661-2.2377866.5368485-.534532 1.2990635-.8153878 2.2023615-.8153878M42.926079 48.3760541h4.4175489v-1.0618162H42.926079v-2.3591888h4.6337541v-1.0835598H41.808408v7.9998606h5.8815146v-1.0944316H42.926079zM50.5802932 44.9340302h2.7263843v6.9380445h1.1286645v-6.9380445h2.7245521v-1.0509443h-6.5796009zM63.5113803 50.9481497h-1.0920196v-3.8667503h1.0920196c1.3357085 0 1.9550081.9458499 1.9733306 1.8826399.0109935.5598997-.1868892 1.0871838-.5423453 1.4459544-.3499592.3515228-.8428338.538156-1.4309853.538156m0-4.7836086h-2.0411238v5.7077148h2.0411238c.8648208 0 1.6013844-.2808558 2.1272394-.8135759.5240228-.530908.8135179-1.2973726.7951954-2.1055125-.0311482-1.342672-.9600977-2.7886264-2.9224348-2.7886264M72.2669585 49.6458847c0 .9567218-.6907574 1.3934072-1.3338763 1.3934072-.3499592 0-.6760993-.1214022-.9161237-.3424629-.2711727-.2500522-.414088-.6142588-.414088-1.0509443v-3.4808h-.939943v3.4808c0 .7048575.2455212 1.2991846.7127443 1.7213743.4122557.3714545.9655945.5780194 1.5574104.5780194 1.1304968 0 2.2756515-.7900202 2.2756515-2.2993937v-3.4808h-.9417752v3.4808ZM82.3146376 44.8379957c.8226792 0 1.5885587.3080354 2.1547231.869747l.1813926.1775733.8116856-.6957976-.2070439-.2047529c-.7805375-.7719005-1.8249186-1.1977141-2.9407574-1.1977141-1.2147801 0-2.25.3950101-2.997557 1.1415429-.7567182.7574047-1.1671417 1.8192209-1.1543159 2.9879434.0109934 2.01129 1.2990635 4.0406997 4.1518729 4.0406997 1.1158388 0 2.1602199-.4258136 2.9407574-1.1977141l.1978827-.197505-.7823697-.7302251-.1887215.1811973c-.5679968.5544637-1.3595277.8715589-2.1675489.8715589-2.220684 0-3.0287052-1.6126559-3.0470277-2.9933793-.0054967-.8969266.2949919-1.6905708.8446662-2.2377866.5350162-.534532 1.2990635-.8153878 2.2023615-.8153878M89.9250611 45.6153321c.518526-.5146003 1.2495928-.7882083 2.1162459-.7882083 2.0521173 0 2.9700733 1.5293052 2.9700733 3.0441146 0 1.4713221-.9289495 3.0567984-2.9700733 3.0567984-2.0191368 0-2.9499186-1.5854763-2.9590798-3.0586104-.0054967-.9059864.2931596-1.7068785.8428339-2.2540943m2.1162459-1.8608963c-2.8161645 0-4.0767508 2.0638373-4.0767508 4.1077427 0 1.9913583 1.2752443 4.1367343 4.0767508 4.1367343 2.7868485 0 4.0657573-2.1326922 4.0767508-4.1149906.007329-1.1977141-.4067589-2.2722141-1.1634772-3.0241828-.7274023-.7229773-1.7351384-1.1053036-2.9132736-1.1053036M104.800529 48.7777685c0 1.4640741-1.060871 2.1308802-2.04662 2.1308802-.536849 0-1.037052-.1866332-1.405334-.5254722-.41592-.3841382-.63579-.9404139-.63579-1.605408v-4.9050108h-1.117671v4.9050108c0 .9839013.3444625 1.8119729.991246 2.3972402.573493.5164123 1.343037.802704 2.167549.802704 1.572068 0 3.164291-1.0998676 3.164291-3.1999442v-4.9050108h-1.117671v4.9050108ZM122.360607 50.2429298l-2.607289-6.3708969h-1.216612l3.329194 8.0197923h.989414l3.327361-8.0197923h-1.21478zM130.002178 48.9658513l1.606881-3.6003903 1.606882 3.6003903h-3.213763Zm1.117671-5.093456-3.62785 7.9980486h1.223942l.819014-1.8319046h4.150041l.830008 1.8319046h1.222109l-3.62785-7.9980486h-.989414ZM139.753583 50.7883337v-6.9163008h-1.126832v7.9998606h5.37215v-1.0835598zM1.12683225 44.9447209h2.45337948c1.10667752 0 1.6105456.8516273 1.6105456 1.6434595 0 .4348735-.14474756.8407554-.40492671 1.137919-.28949512.3297791-.70541531.5037285-1.20561889.5037285H1.44381107L2.064943 49.293456h1.51526873c1.79193811 0 2.73004886-1.3644156 2.73004886-2.7107116 0-1.3462959-.93811075-2.7107115-2.73004886-2.7107115H0v7.9998606h1.12683225v-6.9271726Z'/%3E%3Cmask id='b' fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3C/mask%3E%3Cpath fill='%23717373' d='M30.9979642 51.8731619h1.1268322v-7.9998606h-1.1268322zM36.7867671 48.4827793v3.3883894h1.1268322v-7.9980486h-1.1268322v3.547843h-4.344259l.6192997 1.0618162zM110.889271 44.9447209h2.464373c.470888 0 .868485.1485818 1.148819.4276256.273005.2736079.425082.6668061.421417 1.0799359-.007329.6885497-.296824 1.5129974-1.634365 1.5129974h-2.083265l.632125 1.0817479h.716409l2.607288 2.8248658h1.50794l-2.704397-2.9009687c.57899-.0942226 1.0682-.3297791 1.438314-.6976096.458062-.4584291.707248-1.1016795.699919-1.810161-.010994-1.2865008-.960098-2.5911213-2.750204-2.5911213h-3.591205v7.9998606h1.126832v-6.9271726Z' mask='url(%23b)'/%3E%3Cpath fill='%23D60F3C' d='M80.4451954 30.3683044H63.5537052L55.107044 15.9015123l8.4466612-14.46679211h16.8914902l8.4466613 14.46679211-8.4466613 14.4667921Zm9.5221906-15.4941808L81.8816775 1.02702627c-.370114-.6360025-1.0553746-1.02738866-1.7974348-1.02738866H63.914658c-.7420603 0-1.4273209.39138616-1.7992671 1.02738866L54.0315147 14.8741236c-.3719463.6360025-.3719463 1.4187749 0 2.0547774l8.0838762 13.8470973c.3719462.6360025 1.0572068 1.0273887 1.7992671 1.0273887h16.1695847c.7420602 0 1.4273208-.3913862 1.7974348-1.0273887L89.967386 16.928901c.370114-.6360025.370114-1.4187749 0-2.0547774Z' mask='url(%23b)'/%3E%3Cpath fill='%23D60F3C' d='m73.1178542 10.2746115.058632.063419c1.9311889 2.0982647 2.9096091 4.0642554 2.9096091 5.8418008 0 2.6907799-1.7186482 4.6458987-4.0877443 4.6458987-2.3672639 0-4.0877443-1.9551188-4.0877443-4.6458987 0-1.7775454.9802524-3.7435361 2.9114413-5.8418008l.058632-.063419c-1.9788274-1.85908429-4.0309447-3.19450836-4.0309447-3.19450836l-5.1504478 8.82249636 5.1504478 8.8206844h10.2990636l5.1504478-8.8206844-5.1504478-8.82249636s-2.0521173 1.33542407-4.0309447 3.19450836' mask='url(%23b)'/%3E%3Cpath fill='%23D60F3C' d='M71.9992671 19.2502195c1.52443 0 2.4955212-1.3807234 2.4955212-3.0712941 0-1.5818524-1.1158388-3.2814831-2.4955212-4.7799847-1.3796824 1.4985016-2.4955212 3.1981323-2.4955212 4.7799847 0 1.6905707.9710912 3.0712941 2.4955212 3.0712941' mask='url(%23b)'/%3E%3C/g%3E%3C/svg%3E%0A");
diff --git a/public/composer.json b/public/composer.json
index aff2785..e1be046 100644
--- a/public/composer.json
+++ b/public/composer.json
@@ -23,7 +23,7 @@
},
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
- "getkirby/cms": "^4.6",
+ "getkirby/cms": "^5.0.0",
"getkirby/kql": "^1.2"
},
"config": {
diff --git a/public/composer.lock b/public/composer.lock
index 4ca3eb4..2d2eb27 100644
--- a/public/composer.lock
+++ b/public/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "355788661384db21067c2cd5fd1c83ab",
+ "content-hash": "fa260be9c3ea255684234b81e836c746",
"packages": [
{
"name": "christian-riesen/base32",
@@ -201,16 +201,16 @@
},
{
"name": "filp/whoops",
- "version": "2.18.0",
+ "version": "2.18.4",
"source": {
"type": "git",
"url": "https://github.com/filp/whoops.git",
- "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e"
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
- "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
+ "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"shasum": ""
},
"require": {
@@ -260,7 +260,7 @@
],
"support": {
"issues": "https://github.com/filp/whoops/issues",
- "source": "https://github.com/filp/whoops/tree/2.18.0"
+ "source": "https://github.com/filp/whoops/tree/2.18.4"
},
"funding": [
{
@@ -268,20 +268,20 @@
"type": "github"
}
],
- "time": "2025-03-15T12:00:00+00:00"
+ "time": "2025-08-08T12:00:00+00:00"
},
{
"name": "getkirby/cms",
- "version": "4.7.0",
+ "version": "5.0.4",
"source": {
"type": "git",
"url": "https://github.com/getkirby/kirby.git",
- "reference": "938fe98951cace6c77aab744779bf4e0799ad705"
+ "reference": "c9708e5c648fbc3ee381114ceae8876d4ca4f9a1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getkirby/kirby/zipball/938fe98951cace6c77aab744779bf4e0799ad705",
- "reference": "938fe98951cace6c77aab744779bf4e0799ad705",
+ "url": "https://api.github.com/repos/getkirby/kirby/zipball/c9708e5c648fbc3ee381114ceae8876d4ca4f9a1",
+ "reference": "c9708e5c648fbc3ee381114ceae8876d4ca4f9a1",
"shasum": ""
},
"require": {
@@ -299,15 +299,15 @@
"ext-mbstring": "*",
"ext-openssl": "*",
"ext-simplexml": "*",
- "filp/whoops": "2.18.0",
+ "filp/whoops": "2.18.4",
"getkirby/composer-installer": "^1.2.1",
- "laminas/laminas-escaper": "2.16.0",
+ "laminas/laminas-escaper": "2.17.0",
"michelf/php-smartypants": "1.8.1",
- "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
- "phpmailer/phpmailer": "6.9.3",
- "symfony/polyfill-intl-idn": "1.31.0",
- "symfony/polyfill-mbstring": "1.31.0",
- "symfony/yaml": "6.4.18"
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
+ "phpmailer/phpmailer": "6.10.0",
+ "symfony/polyfill-intl-idn": "1.32.0",
+ "symfony/polyfill-mbstring": "1.32.0",
+ "symfony/yaml": "7.3.2"
},
"replace": {
"symfony/polyfill-php72": "*"
@@ -319,6 +319,7 @@
"ext-fileinfo": "Improved mime type detection for files",
"ext-intl": "Improved i18n number formatting",
"ext-memcached": "Support for the Memcached cache driver",
+ "ext-redis": "Support for the Redis cache driver",
"ext-sodium": "Support for the crypto class and more robust session handling",
"ext-zip": "Support for ZIP archive file functions",
"ext-zlib": "Sanitization and validation for svgz files"
@@ -371,7 +372,7 @@
"type": "custom"
}
],
- "time": "2025-03-25T11:15:09+00:00"
+ "time": "2025-08-19T07:39:58+00:00"
},
{
"name": "getkirby/composer-installer",
@@ -477,16 +478,16 @@
},
{
"name": "laminas/laminas-escaper",
- "version": "2.16.0",
+ "version": "2.17.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-escaper.git",
- "reference": "9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8"
+ "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8",
- "reference": "9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8",
+ "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/df1ef9503299a8e3920079a16263b578eaf7c3ba",
+ "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba",
"shasum": ""
},
"require": {
@@ -534,7 +535,7 @@
"type": "community_bridge"
}
],
- "time": "2025-02-17T12:40:19+00:00"
+ "time": "2025-05-06T19:29:36+00:00"
},
{
"name": "league/color-extractor",
@@ -653,16 +654,16 @@
},
{
"name": "phpmailer/phpmailer",
- "version": "v6.9.3",
+ "version": "v6.10.0",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
- "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e"
+ "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e",
- "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e",
+ "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
+ "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
"shasum": ""
},
"require": {
@@ -722,7 +723,7 @@
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
- "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.3"
+ "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.10.0"
},
"funding": [
{
@@ -730,7 +731,7 @@
"type": "github"
}
],
- "time": "2024-11-24T18:04:13+00:00"
+ "time": "2025-04-24T15:19:31+00:00"
},
{
"name": "psr/log",
@@ -784,16 +785,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.5.1",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
- "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
@@ -806,7 +807,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.5-dev"
+ "dev-main": "3.6-dev"
}
},
"autoload": {
@@ -831,7 +832,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
@@ -847,11 +848,11 @@
"type": "tidelift"
}
],
- "time": "2024-09-25T14:20:29+00:00"
+ "time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.31.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -910,7 +911,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
@@ -921,6 +922,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -930,16 +935,16 @@
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.31.0",
+ "version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773"
+ "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773",
- "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
+ "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
@@ -993,7 +998,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0"
},
"funding": [
{
@@ -1009,11 +1014,11 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.31.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -1074,7 +1079,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
},
"funding": [
{
@@ -1085,6 +1090,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -1094,19 +1103,20 @@
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.31.0",
+ "version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"shasum": ""
},
"require": {
+ "ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
@@ -1154,7 +1164,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
},
"funding": [
{
@@ -1170,32 +1180,32 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2024-12-23T08:48:59+00:00"
},
{
"name": "symfony/yaml",
- "version": "v6.4.18",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "bf598c9d9bb4a22f495a4e26e4c4fce2f8ecefc5"
+ "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/bf598c9d9bb4a22f495a4e26e4c4fce2f8ecefc5",
- "reference": "bf598c9d9bb4a22f495a4e26e4c4fce2f8ecefc5",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
+ "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
"shasum": ""
},
"require": {
- "php": ">=8.1",
- "symfony/deprecation-contracts": "^2.5|^3",
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
- "symfony/console": "<5.4"
+ "symfony/console": "<6.4"
},
"require-dev": {
- "symfony/console": "^5.4|^6.0|^7.0"
+ "symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
@@ -1226,7 +1236,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v6.4.18"
+ "source": "https://github.com/symfony/yaml/tree/v7.3.2"
},
"funding": [
{
@@ -1237,12 +1247,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-01-07T09:44:41+00:00"
+ "time": "2025-07-10T08:47:49+00:00"
}
],
"packages-dev": [],
diff --git a/public/kirby/.editorconfig b/public/kirby/.editorconfig
index 10fd327..c487405 100644
--- a/public/kirby/.editorconfig
+++ b/public/kirby/.editorconfig
@@ -21,6 +21,10 @@ insert_final_newline = true
indent_size = 2
insert_final_newline = false
+[views/**/*.php]
+indent_size = 2
+insert_final_newline = false
+
[*.yml]
indent_style = space
diff --git a/public/kirby/bootstrap.php b/public/kirby/bootstrap.php
index 9500125..70f27ce 100644
--- a/public/kirby/bootstrap.php
+++ b/public/kirby/bootstrap.php
@@ -5,7 +5,7 @@
* stop at older or too recent versions
*/
if (
- version_compare(PHP_VERSION, '8.1.0', '>=') === false ||
+ version_compare(PHP_VERSION, '8.2.0', '>=') === false ||
version_compare(PHP_VERSION, '8.5.0', '<') === false
) {
die(include __DIR__ . '/views/php.php');
@@ -27,10 +27,9 @@ if (is_file($autoloader = dirname(__DIR__) . '/vendor/autoload.php')) {
* @psalm-suppress MissingFile
*/
include $autoloader;
-} else {
- /**
- * If neither one exists, don't bother searching;
- * it's a custom directory setup and the users need to
- * load the autoloader themselves
- */
}
+/**
+ * If neither one exists, don't bother searching;
+ * it's a custom directory setup and the users need to
+ * load the autoloader themselves
+ */
diff --git a/public/kirby/cacert.pem b/public/kirby/cacert.pem
index 584af3c..0dee453 100644
--- a/public/kirby/cacert.pem
+++ b/public/kirby/cacert.pem
@@ -1,14 +1,14 @@
##
## Bundle of CA Root Certificates
##
-## Certificate data from Mozilla as of: Tue Feb 25 04:12:03 2025 GMT
+## Certificate data from Mozilla as of: Tue Aug 12 03:12:01 2025 GMT
##
## Find updated versions here: https://curl.se/docs/caextract.html
##
## This is a bundle of X.509 certificates of public Certificate Authorities
## (CA). These were automatically extracted from Mozilla's root certificates
## file (certdata.txt). This file can be found in the mozilla source tree:
-## https://hg.mozilla.org/releases/mozilla-release/raw-file/default/security/nss/lib/ckfw/builtins/certdata.txt
+## https://raw.githubusercontent.com/mozilla-firefox/firefox/refs/heads/release/security/nss/lib/ckfw/builtins/certdata.txt
##
## It contains the certificates in PEM format and therefore
## can be directly used with curl / libcurl / php_curl, or with
@@ -16,76 +16,10 @@
## Just configure this file as the SSLCACertificateFile.
##
## Conversion done with mk-ca-bundle.pl version 1.29.
-## SHA256: 620fd89c02acb0019f1899dab7907db5d20735904f5a9a0d3a8771a5857ac482
+## SHA256: c185b859c19b05f104c50e1b0b2a6c775149a1d9bb731d414d73b1722892a66c
##
-GlobalSign Root CA
-==================
------BEGIN CERTIFICATE-----
-MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMCQkUx
-GTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNVBAMTEkds
-b2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNV
-BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYD
-VQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa
-DuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavpxy0Sy6sc
-THAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp1Wrjsok6Vjk4bwY8iGlb
-Kk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdGsnUOhugZitVtbNV4FpWi6cgKOOvyJBNP
-c1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrX
-gzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
-HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUF
-AAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6Dj
-Y1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyG
-j/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhH
-hm4qxFYxldBniYUr+WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveC
-X4XSQRjbgbMEHMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==
------END CERTIFICATE-----
-
-Entrust.net Premium 2048 Secure Server CA
-=========================================
------BEGIN CERTIFICATE-----
-MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChMLRW50cnVzdC5u
-ZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBpbmNvcnAuIGJ5IHJlZi4gKGxp
-bWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNV
-BAMTKkVudHJ1c3QubmV0IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQx
-NzUwNTFaFw0yOTA3MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3
-d3d3LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTEl
-MCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEGA1UEAxMqRW50cnVzdC5u
-ZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
-MIIBCgKCAQEArU1LqRKGsuqjIAcVFmQqK0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOL
-Gp18EzoOH1u3Hs/lJBQesYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSr
-hRSGlVuXMlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVTXTzW
-nLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/HoZdenoVve8AjhUi
-VBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH4QIDAQABo0IwQDAOBgNVHQ8BAf8E
-BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJ
-KoZIhvcNAQEFBQADggEBADubj1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPy
-T/4xmf3IDExoU8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf
-zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5bu/8j72gZyxKT
-J1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+bYQLCIt+jerXmCHG8+c8eS9e
-nNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/ErfF6adulZkMV8gzURZVE=
------END CERTIFICATE-----
-
-Baltimore CyberTrust Root
-=========================
------BEGIN CERTIFICATE-----
-MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UE
-ChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3li
-ZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMC
-SUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFs
-dGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKME
-uyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsB
-UnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/C
-G9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9
-XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjpr
-l3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoI
-VDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEB
-BQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRh
-cL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5
-hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsa
-Y71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9H
-RCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp
------END CERTIFICATE-----
-
Entrust Root Certification Authority
====================================
-----BEGIN CERTIFICATE-----
@@ -112,30 +46,6 @@ W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0
tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8
-----END CERTIFICATE-----
-Comodo AAA Services root
-========================
------BEGIN CERTIFICATE-----
-MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEbMBkGA1UECAwS
-R3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0Eg
-TGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAw
-MFoXDTI4MTIzMTIzNTk1OVowezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hl
-c3RlcjEQMA4GA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
-BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
-ggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQuaBtDFcCLNSS1UY8y2bmhG
-C1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe3M/vg4aijJRPn2jymJBGhCfHdr/jzDUs
-i14HZGWCwEiwqJH5YZ92IFCokcdmtet4YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszW
-Y19zjNoFmag4qMsXeDZRrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjH
-Ypy+g8cmez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQUoBEK
-Iz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wewYDVR0f
-BHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNl
-cy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29tb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2Vz
-LmNybDANBgkqhkiG9w0BAQUFAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm
-7l3sAg9g1o1QGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
-Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2G9w84FoVxp7Z
-8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsil2D4kF501KKaU73yqWjgom7C
-12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
------END CERTIFICATE-----
-
QuoVadis Root CA 2
==================
-----BEGIN CERTIFICATE-----
@@ -202,78 +112,6 @@ vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr
qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto=
-----END CERTIFICATE-----
-XRamp Global CA Root
-====================
------BEGIN CERTIFICATE-----
-MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCBgjELMAkGA1UE
-BhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2Vj
-dXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB
-dXRob3JpdHkwHhcNMDQxMTAxMTcxNDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMx
-HjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkg
-U2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3Jp
-dHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS638eMpSe2OAtp87ZOqCwu
-IR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCPKZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMx
-foArtYzAQDsRhtDLooY2YKTVMIJt2W7QDxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FE
-zG+gSqmUsE3a56k0enI4qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqs
-AxcZZPRaJSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNViPvry
-xS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
-EwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASsjVy16bYbMDYGA1UdHwQvMC0wK6Ap
-oCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMC
-AQEwDQYJKoZIhvcNAQEFBQADggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc
-/Kh4ZzXxHfARvbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt
-qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLaIR9NmXmd4c8n
-nxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSyi6mx5O+aGtA9aZnuqCij4Tyz
-8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQO+7ETPTsJ3xCwnR8gooJybQDJbw=
------END CERTIFICATE-----
-
-Go Daddy Class 2 CA
-===================
------BEGIN CERTIFICATE-----
-MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMY
-VGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRp
-ZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkG
-A1UEBhMCVVMxITAfBgNVBAoTGFRoZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28g
-RGFkZHkgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQAD
-ggENADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCAPVYYYwhv
-2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6wwdhFJ2+qN1j3hybX2C32
-qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXiEqITLdiOr18SPaAIBQi2XKVlOARFmR6j
-YGB0xUGlcmIbYsUfb18aQr4CUWWoriMYavx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmY
-vLEHZ6IVDd2gWMZEewo+YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0O
-BBYEFNLEsNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h/t2o
-atTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMu
-MTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwG
-A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wim
-PQoZ+YeAEW5p5JYXMP80kWNyOO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKt
-I3lpjbi2Tc7PTMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ
-HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mERdEr/VxqHD3VI
-Ls9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5CufReYNnyicsbkqWletNw+vHX/b
-vZ8=
------END CERTIFICATE-----
-
-Starfield Class 2 CA
-====================
------BEGIN CERTIFICATE-----
-MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzElMCMGA1UEChMc
-U3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZpZWxkIENsYXNzIDIg
-Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQwNjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBo
-MQswCQYDVQQGEwJVUzElMCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAG
-A1UECxMpU3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqG
-SIb3DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf8MOh2tTY
-bitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN+lq2cwQlZut3f+dZxkqZ
-JRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVm
-epsZGD3/cVE8MC5fvj13c7JdBmzDI1aaK4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSN
-F4Azbl5KXZnJHoe0nRrA1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HF
-MIHCMB0GA1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fRzt0f
-hvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNo
-bm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBDbGFzcyAyIENlcnRpZmljYXRpb24g
-QXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGs
-afPzWdqbAYcaT1epoXkJKtv3L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLM
-PUxA2IGvd56Deruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl
-xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynpVSJYACPq4xJD
-KVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEYWQPJIrSPnNVeKtelttQKbfi3
-QBFGmh95DmK/D5fs4C8fF5Q=
------END CERTIFICATE-----
-
DigiCert Assured ID Root CA
===========================
-----BEGIN CERTIFICATE-----
@@ -3610,6 +3448,51 @@ WoC9EV2Ta/vH5mQ/u2kc6d0li690yVRAysuTEwrt+2aSEcr1wPrYg1UDfNPFIkZ1cGt5SAYqgpq/
n/mtd+ArY0+ew+43u3gJhJ65bvspmZDogNOfJA==
-----END CERTIFICATE-----
+TrustAsia TLS ECC Root CA
+=========================
+-----BEGIN CERTIFICATE-----
+MIICMTCCAbegAwIBAgIUNnThTXxlE8msg1UloD5Sfi9QaMcwCgYIKoZIzj0EAwMwWDELMAkGA1UE
+BhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xIjAgBgNVBAMTGVRy
+dXN0QXNpYSBUTFMgRUNDIFJvb3QgQ0EwHhcNMjQwNTE1MDU0MTU2WhcNNDQwNTE1MDU0MTU1WjBY
+MQswCQYDVQQGEwJDTjElMCMGA1UEChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEiMCAG
+A1UEAxMZVHJ1c3RBc2lhIFRMUyBFQ0MgUm9vdCBDQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLh/
+pVs/AT598IhtrimY4ZtcU5nb9wj/1WrgjstEpvDBjL1P1M7UiFPoXlfXTr4sP/MSpwDpguMqWzJ8
+S5sUKZ74LYO1644xST0mYekdcouJtgq7nDM1D9rs3qlKH8kzsaNCMEAwDwYDVR0TAQH/BAUwAwEB
+/zAdBgNVHQ4EFgQULIVTu7FDzTLqnqOH/qKYqKaT6RAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49
+BAMDA2gAMGUCMFRH18MtYYZI9HlaVQ01L18N9mdsd0AaRuf4aFtOJx24mH1/k78ITcTaRTChD15K
+eAIxAKORh/IRM4PDwYqROkwrULG9IpRdNYlzg8WbGf60oenUoWa2AaU2+dhoYSi3dOGiMQ==
+-----END CERTIFICATE-----
+
+TrustAsia TLS RSA Root CA
+=========================
+-----BEGIN CERTIFICATE-----
+MIIFgDCCA2igAwIBAgIUHBjYz+VTPyI1RlNUJDxsR9FcSpwwDQYJKoZIhvcNAQEMBQAwWDELMAkG
+A1UEBhMCQ04xJTAjBgNVBAoTHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xIjAgBgNVBAMT
+GVRydXN0QXNpYSBUTFMgUlNBIFJvb3QgQ0EwHhcNMjQwNTE1MDU0MTU3WhcNNDQwNTE1MDU0MTU2
+WjBYMQswCQYDVQQGEwJDTjElMCMGA1UEChMcVHJ1c3RBc2lhIFRlY2hub2xvZ2llcywgSW5jLjEi
+MCAGA1UEAxMZVHJ1c3RBc2lhIFRMUyBSU0EgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIP
+ADCCAgoCggIBAMMWuBtqpERz5dZO9LnPWwvB0ZqB9WOwj0PBuwhaGnrhB3YmH49pVr7+NmDQDIPN
+lOrnxS1cLwUWAp4KqC/lYCZUlviYQB2srp10Zy9U+5RjmOMmSoPGlbYJQ1DNDX3eRA5gEk9bNb2/
+mThtfWza4mhzH/kxpRkQcwUqwzIZheo0qt1CHjCNP561HmHVb70AcnKtEj+qpklz8oYVlQwQX1Fk
+zv93uMltrOXVmPGZLmzjyUT5tUMnCE32ft5EebuyjBza00tsLtbDeLdM1aTk2tyKjg7/D8OmYCYo
+zza/+lcK7Fs/6TAWe8TbxNRkoDD75f0dcZLdKY9BWN4ArTr9PXwaqLEX8E40eFgl1oUh63kd0Nyr
+z2I8sMeXi9bQn9P+PN7F4/w6g3CEIR0JwqH8uyghZVNgepBtljhb//HXeltt08lwSUq6HTrQUNoy
+IBnkiz/r1RYmNzz7dZ6wB3C4FGB33PYPXFIKvF1tjVEK2sUYyJtt3LCDs3+jTnhMmCWr8n4uIF6C
+FabW2I+s5c0yhsj55NqJ4js+k8UTav/H9xj8Z7XvGCxUq0DTbE3txci3OE9kxJRMT6DNrqXGJyV1
+J23G2pyOsAWZ1SgRxSHUuPzHlqtKZFlhaxP8S8ySpg+kUb8OWJDZgoM5pl+z+m6Ss80zDoWo8SnT
+q1mt1tve1CuBAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLgHkXlcBvRG/XtZ
+ylomkadFK/hTMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwFAAOCAgEAIZtqBSBdGBanEqT3
+Rz/NyjuujsCCztxIJXgXbODgcMTWltnZ9r96nBO7U5WS/8+S4PPFJzVXqDuiGev4iqME3mmL5Dw8
+veWv0BIb5Ylrc5tvJQJLkIKvQMKtuppgJFqBTQUYo+IzeXoLH5Pt7DlK9RME7I10nYEKqG/odv6L
+TytpEoYKNDbdgptvT+Bz3Ul/KD7JO6NXBNiT2Twp2xIQaOHEibgGIOcberyxk2GaGUARtWqFVwHx
+tlotJnMnlvm5P1vQiJ3koP26TpUJg3933FEFlJ0gcXax7PqJtZwuhfG5WyRasQmr2soaB82G39tp
+27RIGAAtvKLEiUUjpQ7hRGU+isFqMB3iYPg6qocJQrmBktwliJiJ8Xw18WLK7nn4GS/+X/jbh87q
+qA8MpugLoDzga5SYnH+tBuYc6kIQX+ImFTw3OffXvO645e8D7r0i+yiGNFjEWn9hongPXvPKnbwb
+PKfILfanIhHKA9jnZwqKDss1jjQ52MjqjZ9k4DewbNfFj8GQYSbbJIweSsCI3zWQzj8C9GRh3sfI
+B5XeMhg6j6JCQCTl1jNdfK7vsU1P1FeQNWrcrgSXSYk0ly4wBOeY99sLAZDBHwo/+ML+TvrbmnNz
+FrwFuHnYWa8G5z9nODmxfKuU4CkUpijy323imttUQ/hHWKNddBWcwauwxzQ=
+-----END CERTIFICATE-----
+
D-TRUST EV Root CA 2 2023
=========================
-----BEGIN CERTIFICATE-----
@@ -3640,3 +3523,34 @@ S5THaJQXfuKOKD62xur1NGyfN4gHONuGcfrNlUhDbqNPgofXNJhuS5N5YHVpD/Aa1VP6IQzCP+k/
HxiMkl14p3ZnGbuy6n/pcAlWVqOwDAstNl7F6cTVg8uGF5csbBNvh1qvSaYd2804BC5f4ko1Di1L
+KIkBI3Y4WNeApI02phhXBxvWHZks/wCuPWdCg==
-----END CERTIFICATE-----
+
+SwissSign RSA TLS Root CA 2022 - 1
+==================================
+-----BEGIN CERTIFICATE-----
+MIIFkzCCA3ugAwIBAgIUQ/oMX04bgBhE79G0TzUfRPSA7cswDQYJKoZIhvcNAQELBQAwUTELMAkG
+A1UEBhMCQ0gxFTATBgNVBAoTDFN3aXNzU2lnbiBBRzErMCkGA1UEAxMiU3dpc3NTaWduIFJTQSBU
+TFMgUm9vdCBDQSAyMDIyIC0gMTAeFw0yMjA2MDgxMTA4MjJaFw00NzA2MDgxMTA4MjJaMFExCzAJ
+BgNVBAYTAkNIMRUwEwYDVQQKEwxTd2lzc1NpZ24gQUcxKzApBgNVBAMTIlN3aXNzU2lnbiBSU0Eg
+VExTIFJvb3QgQ0EgMjAyMiAtIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDLKmji
+C8NXvDVjvHClO/OMPE5Xlm7DTjak9gLKHqquuN6orx122ro10JFwB9+zBvKK8i5VUXu7LCTLf5Im
+gKO0lPaCoaTo+nUdWfMHamFk4saMla+ju45vVs9xzF6BYQ1t8qsCLqSX5XH8irCRIFucdFJtrhUn
+WXjyCcplDn/L9Ovn3KlMd/YrFgSVrpxxpT8q2kFC5zyEEPThPYxr4iuRR1VPuFa+Rd4iUU1OKNlf
+GUEGjw5NBuBwQCMBauTLE5tzrE0USJIt/m2n+IdreXXhvhCxqohAWVTXz8TQm0SzOGlkjIHRI36q
+OTw7D59Ke4LKa2/KIj4x0LDQKhySio/YGZxH5D4MucLNvkEM+KRHBdvBFzA4OmnczcNpI/2aDwLO
+EGrOyvi5KaM2iYauC8BPY7kGWUleDsFpswrzd34unYyzJ5jSmY0lpx+Gs6ZUcDj8fV3oT4MM0ZPl
+EuRU2j7yrTrePjxF8CgPBrnh25d7mUWe3f6VWQQvdT/TromZhqwUtKiE+shdOxtYk8EXlFXIC+OC
+eYSf8wCENO7cMdWP8vpPlkwGqnj73mSiI80fPsWMvDdUDrtaclXvyFu1cvh43zcgTFeRc5JzrBh3
+Q4IgaezprClG5QtO+DdziZaKHG29777YtvTKwP1H8K4LWCDFyB02rpeNUIMmJCn3nTsPBQIDAQAB
+o2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBRvjmKLk0Ow
+4UD2p8P98Q+4DxU4pTAdBgNVHQ4EFgQUb45ii5NDsOFA9qfD/fEPuA8VOKUwDQYJKoZIhvcNAQEL
+BQADggIBAKwsKUF9+lz1GpUYvyypiqkkVHX1uECry6gkUSsYP2OprphWKwVDIqO310aewCoSPY6W
+lkDfDDOLazeROpW7OSltwAJsipQLBwJNGD77+3v1dj2b9l4wBlgzHqp41eZUBDqyggmNzhYzWUUo
+8aWjlw5DI/0LIICQ/+Mmz7hkkeUFjxOgdg3XNwwQiJb0Pr6VvfHDffCjw3lHC1ySFWPtUnWK50Zp
+y1FVCypM9fJkT6lc/2cyjlUtMoIcgC9qkfjLvH4YoiaoLqNTKIftV+Vlek4ASltOU8liNr3Cjlvr
+zG4ngRhZi0Rjn9UMZfQpZX+RLOV/fuiJz48gy20HQhFRJjKKLjpHE7iNvUcNCfAWpO2Whi4Z2L6M
+OuhFLhG6rlrnub+xzI/goP+4s9GFe3lmozm1O2bYQL7Pt2eLSMkZJVX8vY3PXtpOpvJpzv1/THfQ
+wUY1mFwjmwJFQ5Ra3bxHrSL+ul4vkSkphnsh3m5kt8sNjzdbowhq6/TdAo9QAwKxuDdollDruF/U
+KIqlIgyKhPBZLtU30WHlQnNYKoH3dtvi4k0NX/a3vgW0rk4N3hY9A4GzJl5LuEsAz/+MF7psYC0n
+hzck5npgL7XTgwSqT0N1osGDsieYK7EOgLrAhV5Cud+xYJHT6xh+cHiudoO+cVrQkOPKwRYlZ0rw
+tnu64ZzZ
+-----END CERTIFICATE-----
diff --git a/public/kirby/composer.json b/public/kirby/composer.json
index 65379ce..d629846 100644
--- a/public/kirby/composer.json
+++ b/public/kirby/composer.json
@@ -3,7 +3,7 @@
"description": "The Kirby core",
"license": "proprietary",
"type": "kirby-cms",
- "version": "4.7.0",
+ "version": "5.0.4",
"keywords": [
"kirby",
"cms",
@@ -24,7 +24,7 @@
"source": "https://github.com/getkirby/kirby"
},
"require": {
- "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
"ext-SimpleXML": "*",
"ext-ctype": "*",
"ext-curl": "*",
@@ -39,14 +39,14 @@
"christian-riesen/base32": "1.6.0",
"claviska/simpleimage": "4.2.1",
"composer/semver": "3.4.3",
- "filp/whoops": "2.18.0",
+ "filp/whoops": "2.18.4",
"getkirby/composer-installer": "^1.2.1",
- "laminas/laminas-escaper": "2.16.0",
+ "laminas/laminas-escaper": "2.17.0",
"michelf/php-smartypants": "1.8.1",
- "phpmailer/phpmailer": "6.9.3",
- "symfony/polyfill-intl-idn": "1.31.0",
- "symfony/polyfill-mbstring": "1.31.0",
- "symfony/yaml": "6.4.18"
+ "phpmailer/phpmailer": "6.10.0",
+ "symfony/polyfill-intl-idn": "1.32.0",
+ "symfony/polyfill-mbstring": "1.32.0",
+ "symfony/yaml": "7.3.2"
},
"replace": {
"symfony/polyfill-php72": "*"
@@ -58,6 +58,7 @@
"ext-fileinfo": "Improved mime type detection for files",
"ext-intl": "Improved i18n number formatting",
"ext-memcached": "Support for the Memcached cache driver",
+ "ext-redis": "Support for the Redis cache driver",
"ext-sodium": "Support for the crypto class and more robust session handling",
"ext-zip": "Support for ZIP archive file functions",
"ext-zlib": "Sanitization and validation for svgz files"
@@ -80,7 +81,7 @@
},
"optimize-autoloader": true,
"platform": {
- "php": "8.1.0"
+ "php": "8.2.0"
},
"platform-check": false
},
diff --git a/public/kirby/composer.lock b/public/kirby/composer.lock
index aa048b9..a9a7b86 100644
--- a/public/kirby/composer.lock
+++ b/public/kirby/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fa52461c55dc4e276cd5853b57c95c60",
+ "content-hash": "dcb9ca6ed7a038f2028aba54fada5f7b",
"packages": [
{
"name": "christian-riesen/base32",
@@ -201,16 +201,16 @@
},
{
"name": "filp/whoops",
- "version": "2.18.0",
+ "version": "2.18.4",
"source": {
"type": "git",
"url": "https://github.com/filp/whoops.git",
- "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e"
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
- "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
+ "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"shasum": ""
},
"require": {
@@ -260,7 +260,7 @@
],
"support": {
"issues": "https://github.com/filp/whoops/issues",
- "source": "https://github.com/filp/whoops/tree/2.18.0"
+ "source": "https://github.com/filp/whoops/tree/2.18.4"
},
"funding": [
{
@@ -268,7 +268,7 @@
"type": "github"
}
],
- "time": "2025-03-15T12:00:00+00:00"
+ "time": "2025-08-08T12:00:00+00:00"
},
{
"name": "getkirby/composer-installer",
@@ -319,16 +319,16 @@
},
{
"name": "laminas/laminas-escaper",
- "version": "2.16.0",
+ "version": "2.17.0",
"source": {
"type": "git",
"url": "https://github.com/laminas/laminas-escaper.git",
- "reference": "9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8"
+ "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8",
- "reference": "9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8",
+ "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/df1ef9503299a8e3920079a16263b578eaf7c3ba",
+ "reference": "df1ef9503299a8e3920079a16263b578eaf7c3ba",
"shasum": ""
},
"require": {
@@ -376,7 +376,7 @@
"type": "community_bridge"
}
],
- "time": "2025-02-17T12:40:19+00:00"
+ "time": "2025-05-06T19:29:36+00:00"
},
{
"name": "league/color-extractor",
@@ -495,16 +495,16 @@
},
{
"name": "phpmailer/phpmailer",
- "version": "v6.9.3",
+ "version": "v6.10.0",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
- "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e"
+ "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e",
- "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e",
+ "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
+ "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
"shasum": ""
},
"require": {
@@ -564,7 +564,7 @@
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
- "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.3"
+ "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.10.0"
},
"funding": [
{
@@ -572,7 +572,7 @@
"type": "github"
}
],
- "time": "2024-11-24T18:04:13+00:00"
+ "time": "2025-04-24T15:19:31+00:00"
},
{
"name": "psr/log",
@@ -626,16 +626,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.5.1",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6"
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
- "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
@@ -648,7 +648,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.5-dev"
+ "dev-main": "3.6-dev"
}
},
"autoload": {
@@ -673,7 +673,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
@@ -689,11 +689,11 @@
"type": "tidelift"
}
],
- "time": "2024-09-25T14:20:29+00:00"
+ "time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.31.0",
+ "version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -752,7 +752,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
},
"funding": [
{
@@ -772,16 +772,16 @@
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.31.0",
+ "version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773"
+ "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773",
- "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
+ "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
@@ -835,7 +835,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0"
},
"funding": [
{
@@ -851,11 +851,11 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.31.0",
+ "version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -916,7 +916,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
},
"funding": [
{
@@ -936,19 +936,20 @@
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.31.0",
+ "version": "v1.32.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"shasum": ""
},
"require": {
+ "ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
@@ -996,7 +997,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
},
"funding": [
{
@@ -1012,32 +1013,32 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2024-12-23T08:48:59+00:00"
},
{
"name": "symfony/yaml",
- "version": "v6.4.18",
+ "version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "bf598c9d9bb4a22f495a4e26e4c4fce2f8ecefc5"
+ "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/bf598c9d9bb4a22f495a4e26e4c4fce2f8ecefc5",
- "reference": "bf598c9d9bb4a22f495a4e26e4c4fce2f8ecefc5",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
+ "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
"shasum": ""
},
"require": {
- "php": ">=8.1",
- "symfony/deprecation-contracts": "^2.5|^3",
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
- "symfony/console": "<5.4"
+ "symfony/console": "<6.4"
},
"require-dev": {
- "symfony/console": "^5.4|^6.0|^7.0"
+ "symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
@@ -1068,7 +1069,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v6.4.18"
+ "source": "https://github.com/symfony/yaml/tree/v7.3.2"
},
"funding": [
{
@@ -1079,12 +1080,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-01-07T09:44:41+00:00"
+ "time": "2025-07-10T08:47:49+00:00"
}
],
"packages-dev": [],
@@ -1094,7 +1099,7 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
"ext-simplexml": "*",
"ext-ctype": "*",
"ext-curl": "*",
@@ -1109,7 +1114,7 @@
},
"platform-dev": {},
"platform-overrides": {
- "php": "8.1.0"
+ "php": "8.2.0"
},
"plugin-api-version": "2.6.0"
}
diff --git a/public/kirby/config/aliases.php b/public/kirby/config/aliases.php
index 206d3b4..fe5537b 100644
--- a/public/kirby/config/aliases.php
+++ b/public/kirby/config/aliases.php
@@ -75,7 +75,6 @@ return [
// Any of these might be removed at any point in the future
'kirby\cms\asset' => 'Kirby\Filesystem\Asset',
'kirby\cms\content' => 'Kirby\Content\Content',
- 'kirby\cms\contenttranslation' => 'Kirby\Content\ContentTranslation',
'kirby\cms\dir' => 'Kirby\Filesystem\Dir',
'kirby\cms\filename' => 'Kirby\Filesystem\Filename',
'kirby\cms\filefoundation' => 'Kirby\Filesystem\IsFile',
@@ -83,6 +82,9 @@ return [
'kirby\cms\form' => 'Kirby\Form\Form',
'kirby\cms\kirbytag' => 'Kirby\Text\KirbyTag',
'kirby\cms\kirbytags' => 'Kirby\Text\KirbyTags',
+ 'kirby\cms\plugin' => 'Kirby\Plugin\Plugin',
+ 'kirby\cms\pluginasset' => 'Kirby\Plugin\Asset',
+ 'kirby\cms\pluginassets' => 'Kirby\Plugin\Assets',
'kirby\cms\template' => 'Kirby\Template\Template',
'kirby\form\options' => 'Kirby\Option\Options',
'kirby\form\optionsapi' => 'Kirby\Option\OptionsApi',
diff --git a/public/kirby/config/api/authentication.php b/public/kirby/config/api/authentication.php
index 15f3bbd..0cee131 100644
--- a/public/kirby/config/api/authentication.php
+++ b/public/kirby/config/api/authentication.php
@@ -11,17 +11,17 @@ return function () {
$auth->type($allowImpersonation) === 'session' &&
$auth->csrf() === false
) {
- throw new AuthException('Unauthenticated');
+ throw new AuthException(message: 'Unauthenticated');
}
// get user from session or basic auth
if ($user = $auth->user(null, $allowImpersonation)) {
if ($user->role()->permissions()->for('access', 'panel') === false) {
- throw new AuthException(['key' => 'access.panel']);
+ throw new AuthException(key: 'access.panel');
}
return $user;
}
- throw new AuthException('Unauthenticated');
+ throw new AuthException(message: 'Unauthenticated');
};
diff --git a/public/kirby/config/api/routes.php b/public/kirby/config/api/routes.php
index aca9e79..62ce665 100644
--- a/public/kirby/config/api/routes.php
+++ b/public/kirby/config/api/routes.php
@@ -4,25 +4,25 @@
* Api Routes Definitions
*/
return function ($kirby) {
- $routes = array_merge(
- include __DIR__ . '/routes/auth.php',
- include __DIR__ . '/routes/pages.php',
- include __DIR__ . '/routes/roles.php',
- include __DIR__ . '/routes/site.php',
- include __DIR__ . '/routes/users.php',
- include __DIR__ . '/routes/files.php',
- include __DIR__ . '/routes/lock.php',
- include __DIR__ . '/routes/system.php',
- include __DIR__ . '/routes/translations.php'
- );
+ $routes = [
+ ...include __DIR__ . '/routes/auth.php',
+ ...include __DIR__ . '/routes/changes.php',
+ ...include __DIR__ . '/routes/pages.php',
+ ...include __DIR__ . '/routes/roles.php',
+ ...include __DIR__ . '/routes/site.php',
+ ...include __DIR__ . '/routes/users.php',
+ ...include __DIR__ . '/routes/files.php',
+ ...include __DIR__ . '/routes/system.php',
+ ...include __DIR__ . '/routes/translations.php'
+ ];
// only add the language routes if the
// multi language setup is activated
if ($kirby->option('languages', false) !== false) {
- $routes = array_merge(
- $routes,
- include __DIR__ . '/routes/languages.php'
- );
+ $routes = [
+ ...$routes,
+ ...include __DIR__ . '/routes/languages.php'
+ ];
}
return $routes;
diff --git a/public/kirby/config/api/routes/auth.php b/public/kirby/config/api/routes/auth.php
index 97b81a1..ef79996 100644
--- a/public/kirby/config/api/routes/auth.php
+++ b/public/kirby/config/api/routes/auth.php
@@ -15,7 +15,9 @@ return [
return $this->resolve($user)->view('auth');
}
- throw new NotFoundException('The user cannot be found');
+ throw new NotFoundException(
+ message: 'The user cannot be found'
+ );
}
],
[
@@ -27,7 +29,9 @@ return [
// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
- throw new InvalidArgumentException('Invalid CSRF token');
+ throw new InvalidArgumentException(
+ message: 'Invalid CSRF token'
+ );
}
$user = $auth->verifyChallenge($this->requestBody('code'));
@@ -49,7 +53,9 @@ return [
// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
- throw new InvalidArgumentException('Invalid CSRF token');
+ throw new InvalidArgumentException(
+ message: 'Invalid CSRF token'
+ );
}
$email = $this->requestBody('email');
@@ -58,7 +64,9 @@ return [
if ($password) {
if (isset($methods['password']) !== true) {
- throw new InvalidArgumentException('Login with password is not enabled');
+ throw new InvalidArgumentException(
+ message: 'Login with password is not enabled'
+ );
}
if (
@@ -73,7 +81,9 @@ return [
$mode = match (true) {
isset($methods['code']) => 'login',
isset($methods['password-reset']) => 'password-reset',
- default => throw new InvalidArgumentException('Login without password is not enabled')
+ default => throw new InvalidArgumentException(
+ message: 'Login without password is not enabled'
+ )
};
$status = $auth->createChallenge($email, $long, $mode);
diff --git a/public/kirby/config/api/routes/changes.php b/public/kirby/config/api/routes/changes.php
new file mode 100644
index 0000000..2e35754
--- /dev/null
+++ b/public/kirby/config/api/routes/changes.php
@@ -0,0 +1,37 @@
+ '(:all)/changes/discard',
+ 'method' => 'POST',
+ 'action' => function (string $path) {
+ return Changes::discard(
+ model: Find::parent($path),
+ );
+ }
+ ],
+ [
+ 'pattern' => '(:all)/changes/publish',
+ 'method' => 'POST',
+ 'action' => function (string $path) {
+ return Changes::publish(
+ model: Find::parent($path),
+ input: App::instance()->request()->get()
+ );
+ }
+ ],
+ [
+ 'pattern' => '(:all)/changes/save',
+ 'method' => 'POST',
+ 'action' => function (string $path) {
+ return Changes::save(
+ model: Find::parent($path),
+ input: App::instance()->request()->get()
+ );
+ }
+ ],
+];
diff --git a/public/kirby/config/api/routes/files.php b/public/kirby/config/api/routes/files.php
index 23e1729..3b61a2c 100644
--- a/public/kirby/config/api/routes/files.php
+++ b/public/kirby/config/api/routes/files.php
@@ -47,7 +47,7 @@ return [
// move_uploaded_file() not working with unit test
// @codeCoverageIgnoreStart
return $this->upload(function ($source, $filename) use ($path) {
- // move the source file from the temp dir
+ // move the source file to the content folder
return $this->parent($path)->createFile([
'content' => [
'sort' => $this->requestBody('sort')
diff --git a/public/kirby/config/api/routes/lock.php b/public/kirby/config/api/routes/lock.php
deleted file mode 100644
index c7095d9..0000000
--- a/public/kirby/config/api/routes/lock.php
+++ /dev/null
@@ -1,56 +0,0 @@
- '(:all)/lock',
- 'method' => 'GET',
- 'action' => function (string $path) {
- return [
- 'lock' => $this->parent($path)->lock()?->toArray() ?? false
- ];
- }
- ],
- [
- 'pattern' => '(:all)/lock',
- 'method' => 'PATCH',
- 'action' => function (string $path) {
- return $this->parent($path)->lock()?->create();
- }
- ],
- [
- 'pattern' => '(:all)/lock',
- 'method' => 'DELETE',
- 'action' => function (string $path) {
- try {
- return $this->parent($path)->lock()?->remove();
- } catch (NotFoundException) {
- return true;
- }
- }
- ],
- [
- 'pattern' => '(:all)/unlock',
- 'method' => 'PATCH',
- 'action' => function (string $path) {
- return $this->parent($path)->lock()?->unlock();
- }
- ],
- [
- 'pattern' => '(:all)/unlock',
- 'method' => 'DELETE',
- 'action' => function (string $path) {
- try {
- return $this->parent($path)->lock()?->resolve();
- } catch (NotFoundException) {
- return true;
- }
- }
- ],
-];
diff --git a/public/kirby/config/api/routes/system.php b/public/kirby/config/api/routes/system.php
index e7e49b4..c810750 100644
--- a/public/kirby/config/api/routes/system.php
+++ b/public/kirby/config/api/routes/system.php
@@ -31,18 +31,6 @@ return [
];
}
],
- [
- 'pattern' => 'system/method-test',
- 'method' => 'PATCH',
- 'action' => function () {
- return [
- 'status' => match ($this->kirby()->request()->method()) {
- 'PATCH' => 'ok',
- default => 'fail'
- }
- ];
- }
- ],
[
'pattern' => 'system/register',
'method' => 'POST',
@@ -60,19 +48,27 @@ return [
// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
- throw new InvalidArgumentException('Invalid CSRF token');
+ throw new InvalidArgumentException(
+ message: 'Invalid CSRF token'
+ );
}
if ($system->isOk() === false) {
- throw new Exception('The server is not setup correctly');
+ throw new Exception(
+ message: 'The server is not setup correctly'
+ );
}
if ($system->isInstallable() === false) {
- throw new Exception('The Panel cannot be installed');
+ throw new Exception(
+ message: 'The Panel cannot be installed'
+ );
}
if ($system->isInstalled() === true) {
- throw new Exception('The Panel is already installed');
+ throw new Exception(
+ message: 'The Panel is already installed'
+ );
}
// create the first user
diff --git a/public/kirby/config/api/routes/users.php b/public/kirby/config/api/routes/users.php
index 203e2d2..e55a009 100644
--- a/public/kirby/config/api/routes/users.php
+++ b/public/kirby/config/api/routes/users.php
@@ -86,18 +86,18 @@ return [
function ($source, $filename) use ($id) {
$type = F::type($filename);
if ($type !== 'image') {
- throw new Exception([
- 'key' => 'file.type.invalid',
- 'data' => compact('type')
- ]);
+ throw new Exception(
+ key: 'file.type.invalid',
+ data: compact('type')
+ );
}
$mime = F::mime($source);
if (Str::startsWith($mime, 'image/') !== true) {
- throw new Exception([
- 'key' => 'file.mime.invalid',
- 'data' => compact('mime')
- ]);
+ throw new Exception(
+ key: 'file.mime.invalid',
+ data: compact('mime')
+ );
}
// delete the old avatar
@@ -184,7 +184,23 @@ return [
],
'method' => 'PATCH',
'action' => function (string $id) {
- return $this->user($id)->changePassword($this->requestBody('password'));
+ $user = $this->user($id);
+
+ // validate password of acting user unless they have logged in to reset it;
+ // always validate password of acting user when changing password of other users
+ if ($this->session()->get('kirby.resetPassword') !== true || $this->user()->is($user) !== true) {
+ $this->user()->validatePassword($this->requestBody('currentPassword'));
+ }
+
+ $result = $user->changePassword($this->requestBody('password'));
+
+ // if we changed the password of the current user…
+ if ($user->isLoggedIn() === true) {
+ // …don't allow additional resets (now the password is known again)
+ $this->session()->remove('kirby.resetPassword');
+ }
+
+ return $result;
}
],
[
diff --git a/public/kirby/config/areas/account.php b/public/kirby/config/areas/account.php
index e138cab..9f9fe73 100644
--- a/public/kirby/config/areas/account.php
+++ b/public/kirby/config/areas/account.php
@@ -7,6 +7,7 @@ return function () {
'icon' => 'account',
'label' => I18n::translate('view.account'),
'search' => 'users',
+ 'buttons' => require __DIR__ . '/account/buttons.php',
'dialogs' => require __DIR__ . '/account/dialogs.php',
'drawers' => require __DIR__ . '/account/drawers.php',
'dropdowns' => require __DIR__ . '/account/dropdowns.php',
diff --git a/public/kirby/config/areas/account/buttons.php b/public/kirby/config/areas/account/buttons.php
new file mode 100644
index 0000000..263ef36
--- /dev/null
+++ b/public/kirby/config/areas/account/buttons.php
@@ -0,0 +1,13 @@
+ function (App $kirby, User $user) {
+ if ($kirby->user()->is($user) === true) {
+ return new ViewButton(component: 'k-theme-view-button');
+ }
+ }
+];
diff --git a/public/kirby/config/areas/account/dialogs.php b/public/kirby/config/areas/account/dialogs.php
index 93635ce..eef1440 100644
--- a/public/kirby/config/areas/account/dialogs.php
+++ b/public/kirby/config/areas/account/dialogs.php
@@ -5,7 +5,6 @@ use Kirby\Panel\UserTotpEnableDialog;
$dialogs = require __DIR__ . '/../users/dialogs.php';
return [
-
// change email
'account.changeEmail' => [
'pattern' => '(account)/changeEmail',
diff --git a/public/kirby/config/areas/account/dropdowns.php b/public/kirby/config/areas/account/dropdowns.php
index d739971..6d115d5 100644
--- a/public/kirby/config/areas/account/dropdowns.php
+++ b/public/kirby/config/areas/account/dropdowns.php
@@ -7,8 +7,16 @@ return [
'pattern' => '(account)',
'options' => $dropdowns['user']['options']
],
+ 'account.languages' => [
+ 'pattern' => '(account)/languages',
+ 'options' => $dropdowns['user.languages']['options']
+ ],
'account.file' => [
'pattern' => '(account)/files/(:any)',
'options' => $dropdowns['user.file']['options']
],
+ 'account.file.languages' => [
+ 'pattern' => '(account)/files/(:any)/languages',
+ 'options' => $files['language']
+ ]
];
diff --git a/public/kirby/config/areas/account/views.php b/public/kirby/config/areas/account/views.php
index 933e0a4..33625a5 100644
--- a/public/kirby/config/areas/account/views.php
+++ b/public/kirby/config/areas/account/views.php
@@ -26,6 +26,9 @@ return [
[
'label' => I18n::translate('view.resetPassword')
]
+ ],
+ 'props' => [
+ 'requirePassword' => App::instance()->session()->get('kirby.resetPassword') !== true
]
]
]
diff --git a/public/kirby/config/areas/files/buttons.php b/public/kirby/config/areas/files/buttons.php
new file mode 100644
index 0000000..b2d5028
--- /dev/null
+++ b/public/kirby/config/areas/files/buttons.php
@@ -0,0 +1,14 @@
+ function (File $file) {
+ return new OpenButton(link: $file->previewUrl());
+ },
+ 'file.settings' => function (File $file) {
+ return new SettingsButton(model: $file);
+ }
+];
diff --git a/public/kirby/config/areas/files/dialogs.php b/public/kirby/config/areas/files/dialogs.php
index 8707bd0..400d949 100644
--- a/public/kirby/config/areas/files/dialogs.php
+++ b/public/kirby/config/areas/files/dialogs.php
@@ -45,13 +45,7 @@ return [
$oldUrl = $file->panel()->url(true);
$newUrl = $renamed->panel()->url(true);
$response = [
- 'event' => 'file.changeName',
- 'dispatch' => [
- 'content/move' => [
- $oldUrl,
- $newUrl
- ]
- ],
+ 'event' => 'file.changeName'
];
// check for a necessary redirect after the filename has changed
@@ -163,7 +157,6 @@ return [
return [
'event' => 'file.delete',
- 'dispatch' => ['content/remove' => [$url]],
'redirect' => $redirect
];
}
diff --git a/public/kirby/config/areas/files/dropdowns.php b/public/kirby/config/areas/files/dropdowns.php
index 8687a54..1b7e653 100644
--- a/public/kirby/config/areas/files/dropdowns.php
+++ b/public/kirby/config/areas/files/dropdowns.php
@@ -1,9 +1,14 @@
function (string $parent, string $filename) {
return Find::file($parent, $filename)->panel()->dropdown();
+ },
+ 'language' => function (string $parent, string $filename) {
+ $file = Find::file($parent, $filename);
+ return (new LanguagesDropdown($file))->options();
}
];
diff --git a/public/kirby/config/areas/lab/drawers.php b/public/kirby/config/areas/lab/drawers.php
index 9d16d81..d1e515b 100644
--- a/public/kirby/config/areas/lab/drawers.php
+++ b/public/kirby/config/areas/lab/drawers.php
@@ -1,12 +1,13 @@
[
'pattern' => 'lab/docs/(:any)',
'load' => function (string $component) {
- if (Docs::installed() === false) {
+ if (Docs::isInstalled() === false) {
return [
'component' => 'k-text-drawer',
'props' => [
@@ -15,14 +16,12 @@ return [
];
}
- $docs = new Docs($component);
-
return [
'component' => 'k-lab-docs-drawer',
'props' => [
'icon' => 'book',
'title' => $component,
- 'docs' => $docs->toArray()
+ 'docs' => Doc::factory($component)->toArray()
]
];
},
diff --git a/public/kirby/config/areas/lab/views.php b/public/kirby/config/areas/lab/views.php
index 1f6e879..4a1907a 100644
--- a/public/kirby/config/areas/lab/views.php
+++ b/public/kirby/config/areas/lab/views.php
@@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Panel\Lab\Category;
+use Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Docs;
return [
@@ -12,7 +13,7 @@ return [
'component' => 'k-lab-index-view',
'props' => [
'categories' => Category::all(),
- 'info' => Category::installed() ? null : 'The default Lab examples are not installed.',
+ 'info' => Category::isInstalled() ? null : 'The default Lab examples are not installed.',
'tab' => 'examples',
],
];
@@ -21,18 +22,7 @@ return [
'lab.docs' => [
'pattern' => 'lab/docs',
'action' => function () {
- $props = match (Docs::installed()) {
- true => [
- 'categories' => [['examples' => Docs::all()]],
- 'tab' => 'docs',
- ],
- false => [
- 'info' => 'The UI docs are not installed.',
- 'tab' => 'docs',
- ]
- };
-
- return [
+ $view = [
'component' => 'k-lab-index-view',
'title' => 'Docs',
'breadcrumb' => [
@@ -40,8 +30,28 @@ return [
'label' => 'Docs',
'link' => 'lab/docs'
]
+ ]
+ ];
+
+ // if docs are not installed, show info message
+ if (Docs::isInstalled() === false) {
+ return [
+ ...$view,
+ 'props' => [
+ 'info' => 'The UI docs are not installed.',
+ 'tab' => 'docs',
+ ],
+ ];
+ }
+
+ return [
+ ...$view,
+ 'props' => [
+ 'categories' => [
+ ['examples' => Docs::all()]
+ ],
+ 'tab' => 'docs',
],
- 'props' => $props,
];
}
],
@@ -59,7 +69,7 @@ return [
]
];
- if (Docs::installed() === false) {
+ if (Docs::isInstalled() === false) {
return [
'component' => 'k-lab-index-view',
'title' => $component,
@@ -71,16 +81,50 @@ return [
];
}
- $docs = new Docs($component);
+ $doc = Doc::factory($component);
+
+ if ($doc === null) {
+ return [
+ 'component' => 'k-lab-index-view',
+ 'title' => $component,
+ 'breadcrumb' => $crumbs,
+ 'props' => [
+ 'info' => 'No UI docs found for ' . $component . '.',
+ 'tab' => 'docs',
+ ],
+ ];
+ }
+
+ // header buttons
+ $buttons = [];
+
+ if ($lab = $doc->lab()) {
+ $buttons[] = [
+ 'props' => [
+ 'text' => 'Lab examples',
+ 'icon' => 'lab',
+ 'link' => '/lab/' . $lab
+ ]
+ ];
+ }
+
+ $buttons[] = [
+ 'props' => [
+ 'icon' => 'github',
+ 'link' => $doc->source(),
+ 'target' => '_blank'
+ ]
+ ];
return [
'component' => 'k-lab-docs-view',
'title' => $component,
'breadcrumb' => $crumbs,
'props' => [
+ 'buttons' => $buttons,
'component' => $component,
- 'docs' => $docs->toArray(),
- 'lab' => $docs->lab()
+ 'docs' => $doc->toArray(),
+ 'lab' => $lab
]
];
}
@@ -111,16 +155,39 @@ return [
$vue = $example->vue();
$compiler = App::instance()->option('panel.vue.compiler', true);
- if (Docs::installed() === true && $docs = $props['docs'] ?? null) {
- $docs = new Docs($docs);
+ if ($doc = $props['docs'] ?? null) {
+ $doc = Doc::factory($doc);
}
- $github = $docs?->github();
+ $github = $doc?->source();
if ($source = $props['source'] ?? null) {
$github ??= 'https://github.com/getkirby/kirby/tree/main/' . $source;
}
+ // header buttons
+ $buttons = [];
+
+ if ($doc) {
+ $buttons[] = [
+ 'props' => [
+ 'text' => $doc->name,
+ 'icon' => 'book',
+ 'drawer' => 'lab/docs/' . $doc->name
+ ]
+ ];
+ }
+
+ if ($github) {
+ $buttons[] = [
+ 'props' => [
+ 'icon' => 'github',
+ 'link' => $github,
+ 'target' => '_blank'
+ ]
+ ];
+ }
+
return [
'component' => 'k-lab-playground-view',
'breadcrumb' => [
@@ -133,8 +200,9 @@ return [
]
],
'props' => [
+ 'buttons' => $buttons,
'compiler' => $compiler,
- 'docs' => $docs?->name(),
+ 'docs' => $doc?->name,
'examples' => $vue['examples'],
'file' => $example->module(),
'github' => $github,
diff --git a/public/kirby/config/areas/languages.php b/public/kirby/config/areas/languages.php
index 263ffd7..00a98c2 100644
--- a/public/kirby/config/areas/languages.php
+++ b/public/kirby/config/areas/languages.php
@@ -7,6 +7,7 @@ return function ($kirby) {
'icon' => 'translate',
'label' => I18n::translate('view.languages'),
'menu' => true,
+ 'buttons' => require __DIR__ . '/languages/buttons.php',
'dialogs' => require __DIR__ . '/languages/dialogs.php',
'views' => require __DIR__ . '/languages/views.php'
];
diff --git a/public/kirby/config/areas/languages/buttons.php b/public/kirby/config/areas/languages/buttons.php
new file mode 100644
index 0000000..d0227e4
--- /dev/null
+++ b/public/kirby/config/areas/languages/buttons.php
@@ -0,0 +1,21 @@
+ fn () =>
+ new LanguageCreateButton(),
+ 'language.open' => fn (Language $language) =>
+ new OpenButton(link: $language->url()),
+ 'language.settings' => fn (Language $language) =>
+ new LanguageSettingsButton($language),
+ 'language.delete' => function (Language $language) {
+ if ($language->isDeletable() === true) {
+ return new LanguageDeleteButton($language);
+ }
+ }
+];
diff --git a/public/kirby/config/areas/languages/dialogs.php b/public/kirby/config/areas/languages/dialogs.php
index 8923f1f..5eb3aff 100644
--- a/public/kirby/config/areas/languages/dialogs.php
+++ b/public/kirby/config/areas/languages/dialogs.php
@@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
+use Kirby\Cms\Language;
use Kirby\Cms\LanguageVariable;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\A;
@@ -49,16 +50,34 @@ $translationDialogFields = [
'label' => I18n::translate('language.variable.key'),
'type' => 'text'
],
+ 'multiple' => [
+ 'label' => I18n::translate('language.variable.multiple'),
+ 'text' => I18n::translate('language.variable.multiple.text'),
+ 'help' => I18n::translate('language.variable.multiple.help'),
+ 'type' => 'toggle'
+ ],
'value' => [
'buttons' => false,
'counter' => false,
'label' => I18n::translate('language.variable.value'),
- 'type' => 'textarea'
+ 'type' => 'textarea',
+ 'when' => [
+ 'multiple' => false
+ ]
+ ],
+ 'entries' => [
+ 'field' => ['type' => 'text'],
+ 'label' => I18n::translate('language.variable.entries'),
+ 'help' => I18n::translate('language.variable.entries.help'),
+ 'type' => 'entries',
+ 'min' => 1,
+ 'when' => [
+ 'multiple' => true
+ ],
]
];
return [
-
// create language
'language.create' => [
'pattern' => 'languages/create',
@@ -184,6 +203,9 @@ return [
'props' => [
'fields' => $translationDialogFields,
'size' => 'large',
+ 'value' => [
+ 'multiple' => false,
+ ]
],
];
},
@@ -191,8 +213,13 @@ return [
$request = App::instance()->request();
$language = Find::language($languageCode);
- $key = $request->get('key', '');
- $value = $request->get('value', '');
+ $key = $request->get('key', '');
+ $multiple = $request->get('multiple', false);
+
+ $value = match ($multiple) {
+ true => $request->get('entries', []),
+ default => $request->get('value', '')
+ };
LanguageVariable::create($key, $value);
@@ -209,9 +236,9 @@ return [
$variable = Find::language($languageCode)->variable($translationKey, true);
if ($variable->exists() === false) {
- throw new NotFoundException([
- 'key' => 'language.variable.notFound'
- ]);
+ throw new NotFoundException(
+ key: 'language.variable.notFound'
+ );
}
return [
@@ -230,48 +257,65 @@ return [
'language.translation.update' => [
'pattern' => 'languages/(:any)/translations/(:any)/update',
'load' => function (string $languageCode, string $translationKey) use ($translationDialogFields) {
- $variable = Find::language($languageCode)->variable($translationKey, true);
+ $language = Find::language($languageCode);
+ $variable = $language->variable($translationKey, true);
if ($variable->exists() === false) {
- throw new NotFoundException([
- 'key' => 'language.variable.notFound'
- ]);
+ throw new NotFoundException(
+ key: 'language.variable.notFound'
+ );
}
$fields = $translationDialogFields;
- $fields['key']['disabled'] = true;
- $fields['value']['autofocus'] = true;
- // shows info text when variable is an array
- // TODO: 5.0: use entries field instead showing info text
- $isVariableArray = is_array($variable->value()) === true;
+ // the key field cannot be changed
+ // the multiple field is hidden
+ $fields['key']['disabled'] = true;
+ $fields['multiple']['type'] = 'hidden';
+ // check if the variable has multiple values;
+ // ensure to use the default language for this check because
+ // the variable might not exist in the current language but
+ // already be defined in the default language with multiple values
+ $isVariableArray = Language::ensure('default')->variable($translationKey, true)->hasMultipleValues();
+
+ // set the correct value field
+ // when value is string, set value for value field
+ // when value is array, set value for entries field
if ($isVariableArray === true) {
- $fields['value'] = [
- 'label' => I18n::translate('info'),
- 'type' => 'info',
- 'text' => 'You are using an array variable for this key. Please modify it in the language file in /site/languages',
+ $fields['entries']['autofocus'] = true;
+ $value = [
+ 'entries' => $variable->value(),
+ 'key' => $variable->key(),
+ 'multiple' => true
+ ];
+ } else {
+ $fields['value']['autofocus'] = true;
+ $value = [
+ 'key' => $variable->key(),
+ 'multiple' => false,
+ 'value' => $variable->value()
];
}
return [
'component' => 'k-form-dialog',
'props' => [
- 'cancelButton' => $isVariableArray === false,
- 'fields' => $fields,
- 'size' => 'large',
- 'submitButton' => $isVariableArray === false,
- 'value' => [
- 'key' => $variable->key(),
- 'value' => $variable->value()
- ]
- ],
+ 'fields' => $fields,
+ 'size' => 'large',
+ 'value' => $value
+ ]
];
},
'submit' => function (string $languageCode, string $translationKey) {
- Find::language($languageCode)->variable($translationKey, true)->update(
- App::instance()->request()->get('value', '')
- );
+ $request = App::instance()->request();
+ $multiple = $request->get('multiple', false);
+ $value = match ($multiple) {
+ true => $request->get('entries', []),
+ default => $request->get('value', '')
+ };
+
+ Find::language($languageCode)->variable($translationKey, true)->update($value);
return true;
}
diff --git a/public/kirby/config/areas/languages/views.php b/public/kirby/config/areas/languages/views.php
index ac2d6d4..955ca5b 100644
--- a/public/kirby/config/areas/languages/views.php
+++ b/public/kirby/config/areas/languages/views.php
@@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
+use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Toolkit\Escape;
use Kirby\Toolkit\I18n;
@@ -19,9 +20,9 @@ return [
$foundation = $kirby->defaultLanguage()->translations();
$translations = $language->translations();
- // TODO: update following line and adapt for update and delete options
- // when new `languageVariables.*` permissions available
- $canUpdate = $kirby->user()?->role()->permissions()->for('languages', 'update') === true;
+ // TODO: update following line and adapt for update and
+ // delete options when `languageVariables.*` permissions available
+ $canUpdate = $kirby->role()?->permissions()->for('languages', 'update') === true;
ksort($foundation);
@@ -73,6 +74,10 @@ return [
]
],
'props' => [
+ 'buttons' => fn () =>
+ ViewButtons::view('language', model: $language)
+ ->defaults('open', 'settings', 'delete')
+ ->render(),
'deletable' => $language->isDeletable(),
'code' => Escape::html($language->code()),
'default' => $language->isDefault(),
@@ -113,6 +118,10 @@ return [
return [
'component' => 'k-languages-view',
'props' => [
+ 'buttons' => fn () =>
+ ViewButtons::view('languages')
+ ->defaults('create')
+ ->render(),
'languages' => $kirby->languages()->values(fn ($language) => [
'deletable' => $language->isDeletable(),
'default' => $language->isDefault(),
diff --git a/public/kirby/config/areas/login.php b/public/kirby/config/areas/login.php
index 56f30cb..a72e4c8 100644
--- a/public/kirby/config/areas/login.php
+++ b/public/kirby/config/areas/login.php
@@ -36,7 +36,7 @@ return function ($kirby) {
* be used to redirect to that view again
*/
$kirby->session()->set('panel.path', $path);
- Panel::go('login');
+ Panel::go(url: 'login', refresh: 0);
}
]
]
diff --git a/public/kirby/config/areas/site.php b/public/kirby/config/areas/site.php
index 8995d12..57a2dde 100644
--- a/public/kirby/config/areas/site.php
+++ b/public/kirby/config/areas/site.php
@@ -12,6 +12,7 @@ return function ($kirby) {
'icon' => $blueprint->icon() ?? 'home',
'label' => $blueprint->title() ?? I18n::translate('view.site'),
'menu' => true,
+ 'buttons' => require __DIR__ . '/site/buttons.php',
'dialogs' => require __DIR__ . '/site/dialogs.php',
'drawers' => require __DIR__ . '/site/drawers.php',
'dropdowns' => require __DIR__ . '/site/dropdowns.php',
diff --git a/public/kirby/config/areas/site/buttons.php b/public/kirby/config/areas/site/buttons.php
new file mode 100644
index 0000000..816c441
--- /dev/null
+++ b/public/kirby/config/areas/site/buttons.php
@@ -0,0 +1,72 @@
+ function (Site $site, string $versionId = 'latest') {
+ $versionId = $versionId === 'compare' ? 'changes' : $versionId;
+ $link = $site->previewUrl($versionId);
+
+ if ($link !== null) {
+ return new OpenButton(
+ link: $link,
+ );
+ }
+ },
+ 'site.preview' => function (Site $site) {
+ if ($site->previewUrl() !== null) {
+ return new PreviewButton(
+ link: $site->panel()->url(true) . '/preview/changes',
+ );
+ }
+ },
+ 'site.versions' => function (Site $site, string $versionId = 'latest') {
+ return new VersionsButton(
+ model: $site,
+ versionId: $versionId
+ );
+ },
+ 'page.open' => function (Page $page, string $versionId = 'latest') {
+ $versionId = $versionId === 'compare' ? 'changes' : $versionId;
+ $link = $page->previewUrl($versionId);
+
+ if ($link !== null) {
+ return new OpenButton(
+ link: $link,
+ );
+ }
+ },
+ 'page.preview' => function (Page $page) {
+ if ($page->previewUrl() !== null) {
+ return new PreviewButton(
+ link: $page->panel()->url(true) . '/preview/changes',
+ );
+ }
+ },
+ 'page.versions' => function (Page $page, string $versionId = 'latest') {
+ return new VersionsButton(
+ model: $page,
+ versionId: $versionId
+ );
+ },
+ 'page.settings' => fn (Page $page) => new SettingsButton(model: $page),
+ 'page.status' => fn (Page $page) => new PageStatusButton($page),
+
+ // `languages` button needs to be in site area,
+ // as the languages might be not loaded even in
+ // multilang mode when the `languages` option is deactivated
+ // (but content languages to switch between still can exist)
+ 'languages' => fn (ModelWithContent $model) =>
+ new LanguagesDropdown($model),
+
+ // file buttons
+ ...require __DIR__ . '/../files/buttons.php'
+];
diff --git a/public/kirby/config/areas/site/dialogs.php b/public/kirby/config/areas/site/dialogs.php
index 1a2cc1d..6b2a881 100644
--- a/public/kirby/config/areas/site/dialogs.php
+++ b/public/kirby/config/areas/site/dialogs.php
@@ -28,12 +28,10 @@ return [
$page = Find::page($id);
if ($page->blueprint()->num() !== 'default') {
- throw new PermissionException([
- 'key' => 'page.sort.permission',
- 'data' => [
- 'slug' => $page->slug()
- ]
- ]);
+ throw new PermissionException(
+ key: 'page.sort.permission',
+ data: ['slug' => $page->slug()]
+ );
}
return [
@@ -150,12 +148,10 @@ return [
$blueprints = $page->blueprints();
if (count($blueprints) <= 1) {
- throw new Exception([
- 'key' => 'page.changeTemplate.invalid',
- 'data' => [
- 'slug' => $id
- ]
- ]);
+ throw new Exception(
+ key: 'page.changeTemplate.invalid',
+ data: ['slug' => $id]
+ );
}
return [
@@ -264,20 +260,17 @@ return [
// the page title changed
if ($page->title()->value() !== $title) {
- $page->changeTitle($title);
+ $page = $page->changeTitle($title);
$response['event'][] = 'page.changeTitle';
}
// the slug changed
if ($page->slug() !== $slug) {
- $newPage = $page->changeSlug($slug);
$response['event'][] = 'page.changeSlug';
- $response['dispatch'] = [
- 'content/move' => [
- $oldUrl = $page->panel()->url(true),
- $newUrl = $newPage->panel()->url(true)
- ]
- ];
+
+ $newPage = $page->changeSlug($slug);
+ $oldUrl = $page->panel()->url(true);
+ $newUrl = $newPage->panel()->url(true);
// check for a necessary redirect after the slug has changed
if (Panel::referrer() === $oldUrl && $oldUrl !== $newUrl) {
@@ -372,7 +365,9 @@ return [
$page->childrenAndDrafts()->count() > 0 &&
$request->get('check') !== $page->title()->value()
) {
- throw new InvalidArgumentException(['key' => 'page.delete.confirm']);
+ throw new InvalidArgumentException(
+ key: 'page.delete.confirm'
+ );
}
$page->delete(true);
@@ -385,7 +380,6 @@ return [
return [
'event' => 'page.delete',
- 'dispatch' => ['content/remove' => [$url]],
'redirect' => $redirect
];
}
@@ -416,19 +410,17 @@ return [
if ($hasFiles === true) {
$fields['files'] = [
- 'label' => I18n::translate('page.duplicate.files'),
- 'type' => 'toggle',
- 'required' => true,
- 'width' => $toggleWidth
+ 'label' => I18n::translate('page.duplicate.files'),
+ 'type' => 'toggle',
+ 'width' => $toggleWidth
];
}
if ($hasChildren === true) {
$fields['children'] = [
- 'label' => I18n::translate('page.duplicate.pages'),
- 'type' => 'toggle',
- 'required' => true,
- 'width' => $toggleWidth
+ 'label' => I18n::translate('page.duplicate.pages'),
+ 'type' => 'toggle',
+ 'width' => $toggleWidth
];
}
@@ -440,11 +432,11 @@ return [
$duplicateSlug = $page->slug() . '-' . $slugAppendix;
$siblingKeys = $page->parentModel()->childrenAndDrafts()->pluck('uid');
- if (in_array($duplicateSlug, $siblingKeys) === true) {
+ if (in_array($duplicateSlug, $siblingKeys, true) === true) {
$suffixCounter = 2;
$newSlug = $duplicateSlug . $suffixCounter;
- while (in_array($newSlug, $siblingKeys) === true) {
+ while (in_array($newSlug, $siblingKeys, true) === true) {
$newSlug = $duplicateSlug . ++$suffixCounter;
}
@@ -556,13 +548,7 @@ return [
return [
'event' => 'page.move',
- 'redirect' => $newPage->panel()->url(true),
- 'dispatch' => [
- 'content/move' => [
- $oldPage->panel()->url(true),
- $newPage->panel()->url(true)
- ]
- ],
+ 'redirect' => $newPage->panel()->url(true)
];
}
],
@@ -643,13 +629,7 @@ return [
'changes' => [
'pattern' => 'changes',
'load' => function () {
- $dialog = new ChangesDialog();
- return $dialog->load();
+ return (new ChangesDialog())->load();
},
- 'submit' => function () {
- $dialog = new ChangesDialog();
- $ids = App::instance()->request()->get('ids');
- return $dialog->submit($ids);
- }
],
];
diff --git a/public/kirby/config/areas/site/dropdowns.php b/public/kirby/config/areas/site/dropdowns.php
index b756cf4..207e028 100644
--- a/public/kirby/config/areas/site/dropdowns.php
+++ b/public/kirby/config/areas/site/dropdowns.php
@@ -1,5 +1,8 @@
panel()->dropdown();
}
],
+ 'page.languages' => [
+ 'pattern' => 'pages/(:any)/languages',
+ 'options' => function (string $path) {
+ $page = Find::page($path);
+ return (new LanguagesDropdown($page))->options();
+ }
+ ],
'page.file' => [
'pattern' => '(pages/.*?)/files/(:any)',
'options' => $files['file']
],
+ 'page.file.languages' => [
+ 'pattern' => '(pages/.*?)/files/(:any)/languages',
+ 'options' => $files['language']
+ ],
+ 'site.languages' => [
+ 'pattern' => 'site/languages',
+ 'options' => function () {
+ $site = App::instance()->site();
+ return (new LanguagesDropdown($site))->options();
+ }
+ ],
'site.file' => [
'pattern' => '(site)/files/(:any)',
'options' => $files['file']
+ ],
+ 'site.file.languages' => [
+ 'pattern' => '(site)/files/(:any)/languages',
+ 'options' => $files['language']
]
];
diff --git a/public/kirby/config/areas/site/requests.php b/public/kirby/config/areas/site/requests.php
index 74a0228..360df93 100644
--- a/public/kirby/config/areas/site/requests.php
+++ b/public/kirby/config/areas/site/requests.php
@@ -1,90 +1,25 @@
[
'pattern' => 'site/tree',
'action' => function () {
- $kirby = App::instance();
- $request = $kirby->request();
- $move = $request->get('move');
- $move = $move ? Find::parent($move) : null;
- $parent = $request->get('parent');
-
- if ($parent === null) {
- $site = $kirby->site();
- $panel = $site->panel();
- $uuid = $site->uuid()?->toString();
- $url = $site->url();
- $value = $uuid ?? '/';
-
- return [
- [
- 'children' => $panel->url(true),
- 'disabled' => $move?->isMovableTo($site) === false,
- 'hasChildren' => true,
- 'icon' => 'home',
- 'id' => '/',
- 'label' => I18n::translate('view.site'),
- 'open' => false,
- 'url' => $url,
- 'uuid' => $uuid,
- 'value' => $value
- ]
- ];
- }
-
- $parent = Find::parent($parent);
- $pages = [];
-
- foreach ($parent->childrenAndDrafts()->filterBy('isListable', true) as $child) {
- $panel = $child->panel();
- $uuid = $child->uuid()?->toString();
- $url = $child->url();
- $value = $uuid ?? $child->id();
-
- $pages[] = [
- 'children' => $panel->url(true),
- 'disabled' => $move?->isMovableTo($child) === false,
- 'hasChildren' => $child->hasChildren() === true || $child->hasDrafts() === true,
- 'icon' => $panel->image()['icon'] ?? null,
- 'id' => $child->id(),
- 'open' => false,
- 'label' => $child->title()->value(),
- 'url' => $url,
- 'uuid' => $uuid,
- 'value' => $value
- ];
- }
-
- return $pages;
+ return (new PageTree())->children(
+ parent: App::instance()->request()->get('parent'),
+ moving: App::instance()->request()->get('move')
+ );
}
],
'tree.parents' => [
'pattern' => 'site/tree/parents',
'action' => function () {
- $kirby = App::instance();
- $request = $kirby->request();
- $root = $request->get('root');
- $page = $kirby->page($request->get('page'));
- $parents = $page?->parents()->flip()->values(
- fn ($parent) => $parent->uuid()?->toString() ?? $parent->id()
- ) ?? [];
-
- // if root is included, add the site as top-level parent
- if ($root === 'true') {
- array_unshift($parents, $kirby->site()->uuid()?->toString() ?? '/');
- }
-
- return [
- 'data' => $parents
- ];
+ return (new PageTree())->parents(
+ page: App::instance()->request()->get('page'),
+ includeSite: App::instance()->request()->get('root') === 'true',
+ );
}
]
- // @codeCoverageIgnoreEnd
];
diff --git a/public/kirby/config/areas/site/searches.php b/public/kirby/config/areas/site/searches.php
index f40f218..7f20214 100644
--- a/public/kirby/config/areas/site/searches.php
+++ b/public/kirby/config/areas/site/searches.php
@@ -1,56 +1,17 @@
[
'label' => I18n::translate('pages'),
'icon' => 'page',
- 'query' => function (string|null $query, int $limit, int $page) {
- $kirby = App::instance();
- $pages = $kirby->site()
- ->index(true)
- ->search($query)
- ->filter('isListable', true)
- ->paginate($limit, $page);
-
- return [
- 'results' => $pages->values(fn ($page) => [
- 'image' => $page->panel()->image(),
- 'text' => Escape::html($page->title()->value()),
- 'link' => $page->panel()->url(true),
- 'info' => Escape::html($page->id()),
- 'uuid' => $page->uuid()?->toString(),
- ]),
- 'pagination' => $pages->pagination()->toArray()
- ];
- }
+ 'query' => fn (string|null $query, int $limit, int $page) => Search::pages($query, $limit, $page)
],
'files' => [
'label' => I18n::translate('files'),
'icon' => 'image',
- 'query' => function (string|null $query, int $limit, int $page) {
- $kirby = App::instance();
- $files = $kirby->site()
- ->index(true)
- ->filter('isListable', true)
- ->files()
- ->filter('isListable', true)
- ->search($query)
- ->paginate($limit, $page);
-
- return [
- 'results' => $files->values(fn ($file) => [
- 'image' => $file->panel()->image(),
- 'text' => Escape::html($file->filename()),
- 'link' => $file->panel()->url(true),
- 'info' => Escape::html($file->id()),
- 'uuid' => $file->uuid()->toString(),
- ]),
- 'pagination' => $files->pagination()->toArray()
- ];
- }
+ 'query' => fn (string|null $query, int $limit, int $page) => Search::files($query, $limit, $page)
]
];
diff --git a/public/kirby/config/areas/site/views.php b/public/kirby/config/areas/site/views.php
index 7465d2e..d7f9d03 100644
--- a/public/kirby/config/areas/site/views.php
+++ b/public/kirby/config/areas/site/views.php
@@ -2,6 +2,9 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
+use Kirby\Exception\PermissionException;
+use Kirby\Panel\Ui\Buttons\ViewButtons;
+use Kirby\Toolkit\I18n;
return [
'page' => [
@@ -14,6 +17,40 @@ return [
return Find::file('pages/' . $id, $filename)->panel()->view();
}
],
+ 'page.preview' => [
+ 'pattern' => 'pages/(:any)/preview/(changes|latest|compare)',
+ 'action' => function (string $path, string $versionId) {
+ $page = Find::page($path);
+ $view = $page->panel()->view();
+ $src = [
+ 'latest' => $page->previewUrl('latest'),
+ 'changes' => $page->previewUrl('changes'),
+ ];
+
+ if ($src['latest'] === null) {
+ throw new PermissionException('The preview is not available');
+ }
+
+ return [
+ 'component' => 'k-preview-view',
+ 'props' => [
+ ...$view['props'],
+ 'back' => $view['props']['link'],
+ 'buttons' => fn () =>
+ ViewButtons::view('page.preview', model: $page)
+ ->defaults(
+ 'page.versions',
+ 'languages',
+ )
+ ->bind(['versionId' => $versionId])
+ ->render(),
+ 'src' => $src,
+ 'versionId' => $versionId,
+ ],
+ 'title' => $view['props']['title'] . ' | ' . I18n::translate('preview'),
+ ];
+ }
+ ],
'site' => [
'pattern' => 'site',
'action' => fn () => App::instance()->site()->panel()->view()
@@ -24,4 +61,38 @@ return [
return Find::file('site', $filename)->panel()->view();
}
],
+ 'site.preview' => [
+ 'pattern' => 'site/preview/(changes|latest|compare)',
+ 'action' => function (string $versionId) {
+ $site = App::instance()->site();
+ $view = $site->panel()->view();
+ $src = [
+ 'latest' => $site->previewUrl('latest'),
+ 'changes' => $site->previewUrl('changes'),
+ ];
+
+ if ($src['latest'] === null) {
+ throw new PermissionException('The preview is not available');
+ }
+
+ return [
+ 'component' => 'k-preview-view',
+ 'props' => [
+ ...$view['props'],
+ 'back' => $view['props']['link'],
+ 'buttons' => fn () =>
+ ViewButtons::view('site.preview', model: $site)
+ ->defaults(
+ 'site.versions',
+ 'languages'
+ )
+ ->bind(['versionId' => $versionId])
+ ->render(),
+ 'src' => $src,
+ 'versionId' => $versionId
+ ],
+ 'title' => I18n::translate('view.site') . ' | ' . I18n::translate('preview'),
+ ];
+ }
+ ],
];
diff --git a/public/kirby/config/areas/system/dialogs.php b/public/kirby/config/areas/system/dialogs.php
index e8dd694..db8b7a5 100644
--- a/public/kirby/config/areas/system/dialogs.php
+++ b/public/kirby/config/areas/system/dialogs.php
@@ -53,7 +53,7 @@ return [
];
}
- throw new LogicException('The upgrade failed');
+ throw new LogicException(message: 'The upgrade failed');
// @codeCoverageIgnoreEnd
}
],
diff --git a/public/kirby/config/areas/system/views.php b/public/kirby/config/areas/system/views.php
index 87ad888..13626eb 100644
--- a/public/kirby/config/areas/system/views.php
+++ b/public/kirby/config/areas/system/views.php
@@ -1,6 +1,7 @@
empty($authors) ? '–' : $authors,
- 'license' => $plugin->license() ?? '–',
+ 'license' => $plugin->license()->toArray(),
'name' => [
'text' => $plugin->name() ?? '–',
'href' => $plugin->link(),
],
+ 'status' => $plugin->license()->status()->toArray(),
'version' => $version,
];
});
@@ -122,12 +124,14 @@ return [
return [
'component' => 'k-system-view',
'props' => [
+ 'buttons' => fn () =>
+ ViewButtons::view('system')->render(),
'environment' => $environment,
'exceptions' => $debugMode ? $exceptions : [],
'info' => $system->info(),
'plugins' => $plugins,
'security' => $security,
- 'urls' => $sensitive ?? null
+ 'urls' => $sensitive ?? []
]
];
}
diff --git a/public/kirby/config/areas/users.php b/public/kirby/config/areas/users.php
index dbccc5f..05bdad7 100644
--- a/public/kirby/config/areas/users.php
+++ b/public/kirby/config/areas/users.php
@@ -8,6 +8,7 @@ return function ($kirby) {
'label' => I18n::translate('view.users'),
'search' => 'users',
'menu' => true,
+ 'buttons' => require __DIR__ . '/users/buttons.php',
'dialogs' => require __DIR__ . '/users/dialogs.php',
'drawers' => require __DIR__ . '/users/drawers.php',
'dropdowns' => require __DIR__ . '/users/dropdowns.php',
diff --git a/public/kirby/config/areas/users/buttons.php b/public/kirby/config/areas/users/buttons.php
new file mode 100644
index 0000000..f6fa067
--- /dev/null
+++ b/public/kirby/config/areas/users/buttons.php
@@ -0,0 +1,20 @@
+ function (User $user, string|null $role = null) {
+ return new ViewButton(
+ dialog: 'users/create?role=' . $role,
+ disabled: $user->kirby()->roles()->canBeCreated()->count() < 1,
+ icon: 'add',
+ text: I18n::translate('user.create'),
+ );
+ },
+ 'user.settings' => function (User $user) {
+ return new SettingsButton(model: $user);
+ }
+];
diff --git a/public/kirby/config/areas/users/dialogs.php b/public/kirby/config/areas/users/dialogs.php
index 6c47bca..555d38d 100644
--- a/public/kirby/config/areas/users/dialogs.php
+++ b/public/kirby/config/areas/users/dialogs.php
@@ -57,7 +57,7 @@ return [
'email' => '',
'password' => '',
'translation' => $kirby->panelLanguage(),
- 'role' => $role ?? $roles['options'][0]['value'] ?? null
+ 'role' => $role ?: $roles['options'][0]['value'] ?? null
]
]
];
@@ -231,9 +231,9 @@ return [
// compare passwords
if ($password !== $passwordConfirmation) {
- throw new InvalidArgumentException([
- 'key' => 'user.password.notSame'
- ]);
+ throw new InvalidArgumentException(
+ key: 'user.password.notSame'
+ );
}
// change password if everything's fine
@@ -319,7 +319,6 @@ return [
return [
'event' => 'user.delete',
- 'dispatch' => ['content/remove' => [$url]],
'redirect' => $redirect
];
}
diff --git a/public/kirby/config/areas/users/dropdowns.php b/public/kirby/config/areas/users/dropdowns.php
index ec30a5f..d3d2569 100644
--- a/public/kirby/config/areas/users/dropdowns.php
+++ b/public/kirby/config/areas/users/dropdowns.php
@@ -1,18 +1,29 @@
[
'pattern' => 'users/(:any)',
+ 'options' => fn (string $id) =>
+ Find::user($id)->panel()->dropdown()
+ ],
+ 'user.languages' => [
+ 'pattern' => 'users/(:any)/languages',
'options' => function (string $id) {
- return Find::user($id)->panel()->dropdown();
+ $user = Find::user($id);
+ return (new LanguagesDropdown($user))->options();
}
],
'user.file' => [
'pattern' => '(users/.*?)/files/(:any)',
'options' => $files['file']
+ ],
+ 'user.file.languages' => [
+ 'pattern' => '(users/.*?)/files/(:any)/languages',
+ 'options' => $files['language']
]
];
diff --git a/public/kirby/config/areas/users/searches.php b/public/kirby/config/areas/users/searches.php
index b4e4a0b..2e6431e 100644
--- a/public/kirby/config/areas/users/searches.php
+++ b/public/kirby/config/areas/users/searches.php
@@ -1,29 +1,12 @@
[
'label' => I18n::translate('users'),
'icon' => 'users',
- 'query' => function (string|null $query, int $limit, int $page) {
- $kirby = App::instance();
- $users = $kirby->users()
- ->search($query)
- ->paginate($limit, $page);
-
- return [
- 'results' => $users->values(fn ($user) => [
- 'image' => $user->panel()->image(),
- 'text' => Escape::html($user->username()),
- 'link' => $user->panel()->url(true),
- 'info' => Escape::html($user->role()->title()),
- 'uuid' => $user->uuid()->toString(),
- ]),
- 'pagination' => $users->pagination()->toArray()
- ];
- }
+ 'query' => fn (string|null $query, int $limit, int $page) => Search::users($query, $limit, $page)
]
];
diff --git a/public/kirby/config/areas/users/views.php b/public/kirby/config/areas/users/views.php
index 60d9536..cf4cb7e 100644
--- a/public/kirby/config/areas/users/views.php
+++ b/public/kirby/config/areas/users/views.php
@@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
+use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Toolkit\Escape;
return [
@@ -18,7 +19,11 @@ return [
return [
'component' => 'k-users-view',
'props' => [
- 'canCreate' => $kirby->roles()->canBeCreated()->count() > 0,
+ 'buttons' => fn () =>
+ ViewButtons::view('users')
+ ->defaults('create')
+ ->bind(['role' => $role])
+ ->render(),
'role' => function () use ($roles, $role) {
if ($role) {
return $roles[$role] ?? null;
diff --git a/public/kirby/config/components.php b/public/kirby/config/components.php
index 7e5b9c7..f95ecfa 100644
--- a/public/kirby/config/components.php
+++ b/public/kirby/config/components.php
@@ -4,11 +4,15 @@ use Kirby\Cms\App;
use Kirby\Cms\Collection;
use Kirby\Cms\File;
use Kirby\Cms\FileVersion;
+use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\User;
+use Kirby\Content\PlainTextStorage;
+use Kirby\Content\Storage;
use Kirby\Data\Data;
use Kirby\Email\PHPMailer as Emailer;
use Kirby\Exception\NotFoundException;
+use Kirby\Filesystem\Asset;
use Kirby\Filesystem\F;
use Kirby\Filesystem\Filename;
use Kirby\Http\Uri;
@@ -59,22 +63,20 @@ return [
/**
* Adapt file characteristics
*
- * @param \Kirby\Cms\File|\Kirby\Filesystem\Asset $file The file object
* @param array $options All thumb options (width, height, crop, blur, grayscale)
- * @return \Kirby\Cms\File|\Kirby\Cms\FileVersion|\Kirby\Filesystem\Asset
*/
'file::version' => function (
App $kirby,
- $file,
+ File|Asset $file,
array $options = []
- ) {
+ ): File|Asset|FileVersion {
// if file is not resizable, return
if ($file->isResizable() === false) {
return $file;
}
// create url and root
- $mediaRoot = dirname($file->mediaRoot());
+ $mediaRoot = $file->mediaDir();
$template = $mediaRoot . '/{{ name }}{{ attributes }}.{{ extension }}';
$thumbRoot = (new Filename($file->root(), $template, $options))->toString();
$thumbName = basename($thumbRoot);
@@ -85,9 +87,10 @@ return [
$job = $mediaRoot . '/.jobs/' . $thumbName . '.json';
try {
- Data::write($job, array_merge($options, [
- 'filename' => $file->filename()
- ]));
+ Data::write(
+ $job,
+ [...$options, 'filename' => $file->filename()]
+ );
} catch (Throwable) {
// if thumb doesn't exist yet and job file cannot
// be created, return
@@ -99,7 +102,7 @@ return [
'modifications' => $options,
'original' => $file,
'root' => $thumbRoot,
- 'url' => dirname($file->mediaUrl()) . '/' . $thumbName,
+ 'url' => $file->mediaUrl($thumbName),
]);
},
@@ -150,17 +153,16 @@ return [
$params = ['fields' => Str::split($params, '|')];
}
- $defaults = [
+ $collection = clone $collection;
+ $query = trim($query ?? '');
+ $options = [
'fields' => [],
'minlength' => 2,
'score' => [],
'words' => false,
+ ...$params
];
- $collection = clone $collection;
- $options = array_merge($defaults, $params);
- $query = trim($query ?? '');
-
// empty or too short search query
if (Str::length($query) < $options['minlength']) {
return $collection->limit(0);
@@ -204,10 +206,11 @@ return [
$keys[] = 'role';
} elseif ($item instanceof Page) {
// apply the default score for pages
- $options['score'] = array_merge(
- ['id' => 64, 'title' => 64],
- $options['score']
- );
+ $options['score'] = [
+ 'id' => 64,
+ 'title' => 64,
+ ...$options['score']
+ ];
}
if (empty($options['fields']) === false) {
@@ -231,7 +234,7 @@ return [
$scoring['score'] += 16 * $score;
$scoring['hits'] += 1;
- // check for exact beginning matches
+ // check for exact beginning matches
} elseif (
$options['words'] === false &&
Str::startsWith($lowerValue, $query) === true
@@ -239,7 +242,7 @@ return [
$scoring['score'] += 8 * $score;
$scoring['hits'] += 1;
- // check for exact query matches
+ // check for exact query matches
} elseif ($matches = preg_match_all('!' . $exact . '!ui', $value, $r)) {
$scoring['score'] += 2 * $score;
$scoring['hits'] += $matches;
@@ -309,6 +312,16 @@ return [
return Snippet::factory($name, $data, $slots);
},
+ /**
+ * Create a new storage object for the given model
+ */
+ 'storage' => function (
+ App $kirby,
+ ModelWithContent $model
+ ): Storage {
+ return new PlainTextStorage(model: $model);
+ },
+
/**
* Add your own template engine
*
@@ -332,7 +345,6 @@ return [
* @param string $src Root of the original file
* @param string $dst Template string for the root to the desired destination
* @param array $options All thumb options that should be applied: `width`, `height`, `crop`, `blur`, `grayscale`
- * @return string
*/
'thumb' => function (
App $kirby,
@@ -401,7 +413,7 @@ return [
// keep relative urls
if (
$path !== null &&
- (substr($path, 0, 2) === './' || substr($path, 0, 3) === '../')
+ (str_starts_with($path, './') || str_starts_with($path, '../'))
) {
return $path;
}
@@ -417,7 +429,9 @@ return [
$model = Uuid::for($path)->model();
if ($model === null) {
- throw new NotFoundException('The model could not be found for "' . $path . '" uuid');
+ throw new NotFoundException(
+ message: 'The model could not be found for "' . $path . '" uuid'
+ );
}
$path = $model->url();
diff --git a/public/kirby/config/fields/color.php b/public/kirby/config/fields/color.php
index 8d473f1..7daa027 100644
--- a/public/kirby/config/fields/color.php
+++ b/public/kirby/config/fields/color.php
@@ -1,5 +1,6 @@
function (string $format = 'hex'): string {
- if (in_array($format, ['hex', 'hsl', 'rgb']) === false) {
- throw new InvalidArgumentException('Unsupported format for color field (supported: hex, rgb, hsl)');
+ if (in_array($format, ['hex', 'hsl', 'rgb'], true) === false) {
+ throw new InvalidArgumentException(
+ message: 'Unsupported format for color field (supported: hex, rgb, hsl)'
+ );
}
return $format;
@@ -35,8 +38,10 @@ return [
* show the `options` as toggles
*/
'mode' => function (string $mode = 'picker'): string {
- if (in_array($mode, ['picker', 'input', 'options']) === false) {
- throw new InvalidArgumentException('Unsupported mode for color field (supported: picker, input, options)');
+ if (in_array($mode, ['picker', 'input', 'options'], true) === false) {
+ throw new InvalidArgumentException(
+ message: 'Unsupported mode for color field (supported: picker, input, options)'
+ );
}
return $mode;
@@ -69,30 +74,33 @@ return [
return [];
}
- $options = match (true) {
- // simple array of values
- // or value=text (from Options class)
+ if (
is_numeric($options[0]['value']) ||
$options[0]['value'] === $options[0]['text']
- => A::map($options, fn ($option) => [
- 'value' => $option['text']
- ]),
+ ) {
+ // simple array of values
+ // or value=text (from Options class)
+ $options = A::map($options, fn ($option) => [
+ 'value' => $option['text']
+ ]);
- // deprecated: name => value, flipping
- // TODO: start throwing in warning in v5
- $this->isColor($options[0]['text'])
- => A::map($options, fn ($option) => [
- 'value' => $option['text'],
- // ensure that any HTML in the new text is escaped
- 'text' => Escape::html($option['value'])
- ]),
+ } elseif ($this->isColor($options[0]['text'])) {
+ // @deprecated 4.0.0
+ // TODO: Remove in Kirby 6
- default
- => A::map($options, fn ($option) => [
+ Helpers::deprecated('Color field "' . $this->name . '": the text => value notation for options has been deprecated and will be removed in Kirby 6. Please rewrite your options as value => text.');
+
+ $options = A::map($options, fn ($option) => [
+ 'value' => $option['text'],
+ // ensure that any HTML in the new text is escaped
+ 'text' => Escape::html($option['value'])
+ ]);
+ } else {
+ $options = A::map($options, fn ($option) => [
'value' => $option['value'],
'text' => $option['text']
- ]),
- };
+ ]);
+ }
return $options;
}
@@ -121,24 +129,24 @@ return [
}
if ($this->format === 'hex' && $this->isHex($value) === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.color',
- 'data' => ['format' => 'hex']
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.color',
+ data: ['format' => 'hex']
+ );
}
if ($this->format === 'rgb' && $this->isRgb($value) === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.color',
- 'data' => ['format' => 'rgb']
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.color',
+ data: ['format' => 'rgb']
+ );
}
if ($this->format === 'hsl' && $this->isHsl($value) === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.color',
- 'data' => ['format' => 'hsl']
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.color',
+ data: ['format' => 'hsl']
+ );
}
}
]
diff --git a/public/kirby/config/fields/date.php b/public/kirby/config/fields/date.php
index 34559db..845b7cc 100644
--- a/public/kirby/config/fields/date.php
+++ b/public/kirby/config/fields/date.php
@@ -125,27 +125,27 @@ return [
$format = $this->time === false ? 'd.m.Y' : 'd.m.Y H:i';
if ($min && $max && $value->isBetween($min, $max) === false) {
- throw new Exception([
- 'key' => 'validation.date.between',
- 'data' => [
+ throw new Exception(
+ key: 'validation.date.between',
+ data: [
'min' => $min->format($format),
'max' => $max->format($format)
]
- ]);
- } elseif ($min && $value->isMin($min) === false) {
- throw new Exception([
- 'key' => 'validation.date.after',
- 'data' => [
- 'date' => $min->format($format),
- ]
- ]);
- } elseif ($max && $value->isMax($max) === false) {
- throw new Exception([
- 'key' => 'validation.date.before',
- 'data' => [
- 'date' => $max->format($format),
- ]
- ]);
+ );
+ }
+
+ if ($min && $value->isMin($min) === false) {
+ throw new Exception(
+ key: 'validation.date.after',
+ data: ['date' => $min->format($format)]
+ );
+ }
+
+ if ($max && $value->isMax($max) === false) {
+ throw new Exception(
+ key: 'validation.date.before',
+ data: ['date' => $max->format($format)]
+ );
}
return true;
diff --git a/public/kirby/config/fields/link.php b/public/kirby/config/fields/link.php
index 9493c3e..7cab839 100644
--- a/public/kirby/config/fields/link.php
+++ b/public/kirby/config/fields/link.php
@@ -48,7 +48,7 @@ return [
'activeTypes' => function () {
return array_filter(
$this->availableTypes(),
- fn (string $type) => in_array($type, $this->props['options']),
+ fn (string $type) => in_array($type, $this->props['options'], true),
ARRAY_FILTER_USE_KEY
);
},
@@ -153,17 +153,17 @@ return [
$detected = true;
if ($options['validate']($link) === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.' . $type
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.' . $type
+ );
}
}
// none of the configured types has been detected
if ($detected === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.linkType'
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.linkType'
+ );
}
return true;
diff --git a/public/kirby/config/fields/mixins/layout.php b/public/kirby/config/fields/mixins/layout.php
index 4f94b0f..a3ee027 100644
--- a/public/kirby/config/fields/mixins/layout.php
+++ b/public/kirby/config/fields/mixins/layout.php
@@ -7,8 +7,11 @@ return [
* Available layouts: `list`, `cardlets`, `cards`
*/
'layout' => function (string $layout = 'list') {
- $layouts = ['list', 'cardlets', 'cards'];
- return in_array($layout, $layouts) ? $layout : 'list';
+ return match ($layout) {
+ 'cards' => 'cards',
+ 'cardlets' => 'cardlets',
+ default => 'list'
+ };
},
/**
diff --git a/public/kirby/config/fields/mixins/options.php b/public/kirby/config/fields/mixins/options.php
index a3625f3..0f99ea4 100644
--- a/public/kirby/config/fields/mixins/options.php
+++ b/public/kirby/config/fields/mixins/options.php
@@ -36,7 +36,7 @@ return [
},
'sanitizeOption' => function ($value) {
$options = array_column($this->options(), 'value');
- return in_array($value, $options) === true ? $value : null;
+ return in_array($value, $options) ? $value : null;
},
'sanitizeOptions' => function ($values) {
$options = array_column($this->options(), 'value');
diff --git a/public/kirby/config/fields/mixins/upload.php b/public/kirby/config/fields/mixins/upload.php
index ec136bd..7cfcf5d 100644
--- a/public/kirby/config/fields/mixins/upload.php
+++ b/public/kirby/config/fields/mixins/upload.php
@@ -34,7 +34,9 @@ return [
$parent = $this->uploadParent($uploads['parent'] ?? null);
if ($parent === null) {
- throw new InvalidArgumentException('"' . $uploads['parent'] . '" could not be resolved as a valid parent for the upload');
+ throw new InvalidArgumentException(
+ message: '"' . $uploads['parent'] . '" could not be resolved as a valid parent for the upload'
+ );
}
$file = new File([
@@ -52,7 +54,9 @@ return [
'methods' => [
'upload' => function (Api $api, $params, Closure $map) {
if ($params === false) {
- throw new Exception('Uploads are disabled for this field');
+ throw new Exception(
+ message: 'Uploads are disabled for this field'
+ );
}
$parent = $this->uploadParent($params['parent'] ?? null);
@@ -68,7 +72,9 @@ return [
$file = $parent->createFile($props, true);
if ($file instanceof File === false) {
- throw new Exception('The file could not be uploaded');
+ throw new Exception(
+ message: 'The file could not be uploaded'
+ );
}
return $map($file, $parent);
diff --git a/public/kirby/config/fields/number.php b/public/kirby/config/fields/number.php
index cc41887..128c733 100644
--- a/public/kirby/config/fields/number.php
+++ b/public/kirby/config/fields/number.php
@@ -24,17 +24,21 @@ return [
},
/**
* Allowed incremental steps between numbers (i.e `0.5`)
+ * Use `any` to allow any decimal value.
*/
- 'step' => function ($step = null) {
- return $this->toNumber($step) ?? '';
+ 'step' => function ($step = null): float|string {
+ return match ($step) {
+ 'any' => 'any',
+ default => $this->toNumber($step) ?? ''
+ };
},
'value' => function ($value = null) {
return $this->toNumber($value) ?? '';
}
],
'methods' => [
- 'toNumber' => function ($value) {
- if ($this->isEmpty($value) === true) {
+ 'toNumber' => function ($value): float|null {
+ if ($this->isEmptyValue($value) === true) {
return null;
}
diff --git a/public/kirby/config/fields/object.php b/public/kirby/config/fields/object.php
index 57cd2e4..d795d28 100644
--- a/public/kirby/config/fields/object.php
+++ b/public/kirby/config/fields/object.php
@@ -91,13 +91,13 @@ return [
$name = array_key_first($errors);
$error = $errors[$name];
- throw new InvalidArgumentException([
- 'key' => 'object.validation',
- 'data' => [
+ throw new InvalidArgumentException(
+ key: 'object.validation',
+ data: [
'label' => $error['label'] ?? $name,
'message' => implode("\n", $error['message'])
]
- ]);
+ );
}
}
]
diff --git a/public/kirby/config/fields/radio.php b/public/kirby/config/fields/radio.php
index 4846053..9ec7c43 100644
--- a/public/kirby/config/fields/radio.php
+++ b/public/kirby/config/fields/radio.php
@@ -20,7 +20,8 @@ return [
],
'computed' => [
'default' => function () {
- return $this->sanitizeOption($this->default);
+ $default = $this->model()->toString($this->default);
+ return $this->sanitizeOption($default);
},
'value' => function () {
return $this->sanitizeOption($this->value) ?? '';
diff --git a/public/kirby/config/fields/select.php b/public/kirby/config/fields/select.php
index 2f09c2d..05a9c55 100644
--- a/public/kirby/config/fields/select.php
+++ b/public/kirby/config/fields/select.php
@@ -1,6 +1,7 @@
'radio',
@@ -17,10 +18,10 @@ return [
return $icon;
},
/**
- * Custom placeholder string for empty option.
+ * Text shown when no option is selected yet
*/
- 'placeholder' => function (string $placeholder = '—') {
- return $placeholder;
+ 'placeholder' => function (string|array $placeholder = '—') {
+ return I18n::translate($placeholder, $placeholder);
},
],
'methods' => [
diff --git a/public/kirby/config/fields/structure.php b/public/kirby/config/fields/structure.php
index 66b614c..3fb05d2 100644
--- a/public/kirby/config/fields/structure.php
+++ b/public/kirby/config/fields/structure.php
@@ -149,7 +149,7 @@ return [
// make the first column visible on mobile
// if no other mobile columns are defined
- if (in_array(true, array_column($columns, 'mobile')) === false) {
+ if (in_array(true, array_column($columns, 'mobile'), true) === false) {
$columns[array_key_first($columns)]['mobile'] = true;
}
@@ -166,24 +166,37 @@ return [
continue;
}
- $value[] = $this->form($row)->values();
+ $value[] = $this->form()->fill(input: $row, passthrough: true)->toFormValues();
}
return $value;
},
- 'form' => function (array $values = []) {
- return new Form([
- 'fields' => $this->attrs['fields'] ?? [],
- 'values' => $values,
- 'model' => $this->model
- ]);
- },
+ 'form' => function () {
+ $this->form ??= new Form(
+ fields: $this->attrs['fields'] ?? [],
+ model: $this->model,
+ language: 'current'
+ );
+
+ return $this->form->reset();
+ }
],
'save' => function ($value) {
- $data = [];
+ $data = [];
+ $form = $this->form();
+ $defaults = $form->defaults();
- foreach ($value as $row) {
- $row = $this->form($row)->content();
+ foreach ($value as $index => $row) {
+ $row = $form
+ ->reset()
+ ->fill(
+ input: $defaults,
+ )
+ ->submit(
+ input: $row,
+ passthrough: true
+ )
+ ->toStoredValues();
// remove frontend helper id
unset($row['_id']);
@@ -204,19 +217,20 @@ return [
$values = A::wrap($value);
foreach ($values as $index => $value) {
- $form = $this->form($value);
+ $form = $this->form();
+ $form->fill(input: $value);
foreach ($form->fields() as $field) {
$errors = $field->errors();
if (empty($errors) === false) {
- throw new InvalidArgumentException([
- 'key' => 'structure.validation',
- 'data' => [
+ throw new InvalidArgumentException(
+ key: 'structure.validation',
+ data: [
'field' => $field->label() ?? Str::ucfirst($field->name()),
'index' => $index + 1
]
- ]);
+ );
}
}
}
diff --git a/public/kirby/config/fields/text.php b/public/kirby/config/fields/text.php
index ac87d85..04f8c28 100644
--- a/public/kirby/config/fields/text.php
+++ b/public/kirby/config/fields/text.php
@@ -10,11 +10,14 @@ return [
* The field value will be converted with the selected converter before the value gets saved. Available converters: `lower`, `upper`, `ucfirst`, `slug`
*/
'converter' => function ($value = null) {
- if ($value !== null && array_key_exists($value, $this->converters()) === false) {
- throw new InvalidArgumentException([
- 'key' => 'field.converter.invalid',
- 'data' => ['converter' => $value]
- ]);
+ if (
+ $value !== null &&
+ array_key_exists($value, $this->converters()) === false
+ ) {
+ throw new InvalidArgumentException(
+ key: 'field.converter.invalid',
+ data: ['converter' => $value]
+ );
}
return $value;
diff --git a/public/kirby/config/fields/textarea.php b/public/kirby/config/fields/textarea.php
index e09d6c1..f977fa1 100644
--- a/public/kirby/config/fields/textarea.php
+++ b/public/kirby/config/fields/textarea.php
@@ -89,12 +89,11 @@ return [
[
'pattern' => 'files',
'action' => function () {
- $params = array_merge($this->field()->files(), [
+ return $this->field()->filepicker([
+ ...$this->field()->files(),
'page' => $this->requestQuery('page'),
'search' => $this->requestQuery('search')
]);
-
- return $this->field()->filepicker($params);
}
],
[
@@ -104,14 +103,12 @@ return [
$field = $this->field();
$uploads = $field->uploads();
- return $this->field()->upload($this, $uploads, function ($file, $parent) use ($field) {
- $absolute = $field->model()->is($parent) === false;
-
- return [
- 'filename' => $file->filename(),
- 'dragText' => $file->panel()->dragText('auto', $absolute),
- ];
- });
+ return $this->field()->upload($this, $uploads, fn ($file, $parent) => [
+ 'filename' => $file->filename(),
+ 'dragText' => $file->panel()->dragText(
+ absolute: $field->model()->is($parent) === false
+ ),
+ ]);
}
]
];
diff --git a/public/kirby/config/fields/time.php b/public/kirby/config/fields/time.php
index 413420b..5ad7388 100644
--- a/public/kirby/config/fields/time.php
+++ b/public/kirby/config/fields/time.php
@@ -1,6 +1,6 @@
isBetween($min, $max) === false) {
- throw new Exception([
- 'key' => 'validation.time.between',
- 'data' => [
+ throw new InvalidArgumentException(
+ key: 'validation.time.between',
+ data: [
'min' => $min->format($format),
'max' => $min->format($format)
]
- ]);
- } elseif ($min && $value->isMin($min) === false) {
- throw new Exception([
- 'key' => 'validation.time.after',
- 'data' => [
- 'time' => $min->format($format),
- ]
- ]);
- } elseif ($max && $value->isMax($max) === false) {
- throw new Exception([
- 'key' => 'validation.time.before',
- 'data' => [
- 'time' => $max->format($format),
- ]
- ]);
+ );
+ }
+
+ if ($min && $value->isMin($min) === false) {
+ throw new InvalidArgumentException(
+ key: 'validation.time.after',
+ data: ['time' => $min->format($format)]
+ );
+ }
+
+ if ($max && $value->isMax($max) === false) {
+ throw new InvalidArgumentException(
+ key: 'validation.time.before',
+ data: ['time' => $max->format($format)]
+ );
}
return true;
diff --git a/public/kirby/config/fields/toggle.php b/public/kirby/config/fields/toggle.php
index a9b7897..c3b89d0 100644
--- a/public/kirby/config/fields/toggle.php
+++ b/public/kirby/config/fields/toggle.php
@@ -65,8 +65,13 @@ return [
'validations' => [
'boolean',
'required' => function ($value) {
- if ($this->isRequired() && ($value === false || $this->isEmpty($value))) {
- throw new InvalidArgumentException(I18n::translate('field.required'));
+ if (
+ $this->isRequired() &&
+ ($value === false || $this->isEmptyValue($value))
+ ) {
+ throw new InvalidArgumentException(
+ message: I18n::translate('field.required')
+ );
}
},
]
diff --git a/public/kirby/config/fields/writer.php b/public/kirby/config/fields/writer.php
index 72440ff..43fdbcd 100644
--- a/public/kirby/config/fields/writer.php
+++ b/public/kirby/config/fields/writer.php
@@ -79,10 +79,10 @@ return [
$this->minlength &&
V::minLength(strip_tags($value), $this->minlength) === false
) {
- throw new InvalidArgumentException([
- 'key' => 'validation.minlength',
- 'data' => ['min' => $this->minlength]
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.minlength',
+ data: ['min' => $this->minlength]
+ );
}
},
'maxlength' => function ($value) {
@@ -90,10 +90,10 @@ return [
$this->maxlength &&
V::maxLength(strip_tags($value), $this->maxlength) === false
) {
- throw new InvalidArgumentException([
- 'key' => 'validation.maxlength',
- 'data' => ['max' => $this->maxlength]
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.maxlength',
+ data: ['max' => $this->maxlength]
+ );
}
},
]
diff --git a/public/kirby/config/helpers.php b/public/kirby/config/helpers.php
index 79e13dd..0cd027e 100644
--- a/public/kirby/config/helpers.php
+++ b/public/kirby/config/helpers.php
@@ -55,7 +55,7 @@ if (Helpers::hasOverride('collection') === false) { // @codeCoverageIgnore
* Returns the result of a collection by name
*
* @return \Kirby\Toolkit\Collection|null
- * @todo 5.0 Add return type declaration
+ * @todo 6.0 Add return type declaration
*/
function collection(string $name, array $options = [])
{
@@ -640,7 +640,7 @@ if (Helpers::hasOverride('uuid') === false) { // @codeCoverageIgnore
if (Helpers::hasOverride('video') === false) { // @codeCoverageIgnore
/**
- * Creates a video embed via iframe for Youtube or Vimeo
+ * Creates a video embed via iframe for YouTube or Vimeo
* videos. The embed Urls are automatically detected from
* the given Url.
*/
@@ -680,7 +680,7 @@ if (Helpers::hasOverride('widont') === false) { // @codeCoverageIgnore
if (Helpers::hasOverride('youtube') === false) { // @codeCoverageIgnore
/**
- * Embeds a Youtube video by URL in an iframe
+ * Embeds a YouTube video by URL in an iframe
*/
function youtube(
string $url,
diff --git a/public/kirby/config/methods.php b/public/kirby/config/methods.php
index 760ab24..bf8bccb 100644
--- a/public/kirby/config/methods.php
+++ b/public/kirby/config/methods.php
@@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Blocks;
+use Kirby\Cms\Collection;
use Kirby\Cms\File;
use Kirby\Cms\Files;
use Kirby\Cms\Html;
@@ -80,7 +81,9 @@ return function (App $app) {
$message .= ' on parent "' . $parent->title() . '"';
}
- throw new InvalidArgumentException($message);
+ throw new InvalidArgumentException(
+ message: $message
+ );
}
},
@@ -130,6 +133,18 @@ return function (App $app) {
return Str::date($time, $format);
},
+ /**
+ * Parse yaml entries data and convert it to a
+ * collection of field objects
+ */
+ 'toEntries' => function (Field $field): Collection {
+ $entries = new Collection(parent: $field->parent());
+ foreach ($field->yaml() as $index => $entry) {
+ $entries->append(new Field($field->parent(), $index, $entry));
+ }
+ return $entries;
+ },
+
/**
* Returns a file object from a filename in the field
*/
@@ -266,7 +281,9 @@ return function (App $app) {
$message .= ' on parent "' . $parent->id() . '"';
}
- throw new InvalidArgumentException($message);
+ throw new InvalidArgumentException(
+ message: $message
+ );
}
},
diff --git a/public/kirby/config/presets/pages.php b/public/kirby/config/presets/pages.php
index a2c7714..517f84d 100644
--- a/public/kirby/config/presets/pages.php
+++ b/public/kirby/config/presets/pages.php
@@ -40,19 +40,36 @@ return function (array $props) {
if ($drafts !== false) {
- $sections['drafts'] = $section(I18n::translate('pages.status.draft'), 'drafts', $drafts);
+ $sections['drafts'] = $section(
+ I18n::translate('pages.status.draft'),
+ 'drafts',
+ $drafts
+ );
}
if ($unlisted !== false) {
- $sections['unlisted'] = $section(I18n::translate('pages.status.unlisted'), 'unlisted', $unlisted);
+ $sections['unlisted'] = $section(
+ I18n::translate('pages.status.unlisted'),
+ 'unlisted',
+ $unlisted
+ );
}
if ($listed !== false) {
- $sections['listed'] = $section(I18n::translate('pages.status.listed'), 'listed', $listed);
+ $sections['listed'] = $section(
+ I18n::translate('pages.status.listed'),
+ 'listed',
+ $listed
+ );
}
// cleaning up
- unset($props['drafts'], $props['unlisted'], $props['listed'], $props['templates']);
+ unset(
+ $props['drafts'],
+ $props['unlisted'],
+ $props['listed'],
+ $props['templates']
+ );
- return array_merge($props, ['sections' => $sections]);
+ return [...$props, 'sections' => $sections];
};
diff --git a/public/kirby/config/routes.php b/public/kirby/config/routes.php
index 0699ef1..e03a187 100644
--- a/public/kirby/config/routes.php
+++ b/public/kirby/config/routes.php
@@ -3,9 +3,9 @@
use Kirby\Cms\App;
use Kirby\Cms\LanguageRoutes;
use Kirby\Cms\Media;
-use Kirby\Cms\PluginAssets;
use Kirby\Panel\Panel;
use Kirby\Panel\Plugins;
+use Kirby\Plugin\Assets;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Uuid;
@@ -71,7 +71,7 @@ return function (App $kirby) {
string $hash,
string $path
) {
- return PluginAssets::resolve(
+ return Assets::resolve(
$provider . '/' . $pluginName,
$hash,
$path
diff --git a/public/kirby/config/sections/fields.php b/public/kirby/config/sections/fields.php
index 7eb79ba..dd72579 100644
--- a/public/kirby/config/sections/fields.php
+++ b/public/kirby/config/sections/fields.php
@@ -1,7 +1,5 @@
[
'form' => function () {
- $fields = $this->fields;
- $disabled = $this->model->permissions()->update() === false;
- $lang = $this->model->kirby()->languageCode();
- $content = $this->model->content($lang)->toArray();
-
- if ($disabled === true) {
- foreach ($fields as $key => $props) {
- $fields[$key]['disabled'] = true;
- }
- }
-
- return new Form([
- 'fields' => $fields,
- 'values' => $content,
- 'model' => $this->model,
- 'strict' => true
- ]);
+ return new Form(
+ fields: $this->fields,
+ model: $this->model,
+ language: 'current'
+ );
},
'fields' => function () {
- $fields = $this->form->fields()->toArray();
-
- if (
- $this->model instanceof Page ||
- $this->model instanceof Site
- ) {
- // the title should never be updated directly via
- // fields section to avoid conflicts with the rename dialog
- unset($fields['title']);
- }
-
- foreach ($fields as $index => $props) {
- unset($fields[$index]['value']);
- }
-
- return $fields;
+ return $this->form->fields()->toProps();
}
],
'methods' => [
'errors' => function () {
+ $this->form->fill($this->model->content('current')->toArray());
return $this->form->errors();
}
],
diff --git a/public/kirby/config/sections/files.php b/public/kirby/config/sections/files.php
index 8bab322..254e684 100644
--- a/public/kirby/config/sections/files.php
+++ b/public/kirby/config/sections/files.php
@@ -6,6 +6,7 @@ use Kirby\Toolkit\I18n;
return [
'mixins' => [
+ 'batch',
'details',
'empty',
'headline',
@@ -90,14 +91,15 @@ return [
$files = $files->flip();
}
+ return $files;
+ },
+ 'modelsPaginated' => function () {
// apply the default pagination
- $files = $files->paginate([
+ return $this->models()->paginate([
'page' => $this->page,
'limit' => $this->limit,
'method' => 'none' // the page is manually provided
]);
-
- return $files;
},
'files' => function () {
return $this->models;
@@ -105,15 +107,16 @@ return [
'data' => function () {
$data = [];
- // the drag text needs to be absolute when the files come from
- // a different parent model
- $dragTextAbsolute = $this->model->is($this->parent) === false;
-
- foreach ($this->models as $file) {
- $panel = $file->panel();
+ foreach ($this->modelsPaginated() as $file) {
+ $panel = $file->panel();
+ $permissions = $file->permissions();
$item = [
- 'dragText' => $panel->dragText('auto', $dragTextAbsolute),
+ 'dragText' => $panel->dragText(
+ // the drag text needs to be absolute
+ // when the files come from a different parent model
+ absolute: $this->model->is($this->parent) === false
+ ),
'extension' => $file->extension(),
'filename' => $file->filename(),
'id' => $file->id(),
@@ -125,6 +128,10 @@ return [
'link' => $panel->url(true),
'mime' => $file->mime(),
'parent' => $file->parent()->panel()->path(),
+ 'permissions' => [
+ 'delete' => $permissions->can('delete'),
+ 'sort' => $permissions->can('sort'),
+ ],
'template' => $file->template(),
'text' => $file->toSafeString($this->text),
'url' => $file->url(),
@@ -140,7 +147,7 @@ return [
return $data;
},
'total' => function () {
- return $this->models->pagination()->total();
+ return $this->models()->count();
},
'errors' => function () {
$errors = [];
@@ -179,14 +186,8 @@ return [
}
// count all uploaded files
- $max = $this->max ? $this->max - $this->total : null;
-
- if ($this->max && $this->total === $this->max - 1) {
- $multiple = false;
- } else {
- $multiple = true;
- }
-
+ $max = $this->max ? $this->max - $this->total : null;
+ $multiple = !$max || $max > 1;
$template = $this->template === 'default' ? null : $this->template;
return [
@@ -220,6 +221,15 @@ return [
return true;
}
+ ],
+ [
+ 'pattern' => 'delete',
+ 'method' => 'DELETE',
+ 'action' => function () {
+ return $this->section()->deleteSelected(
+ ids: $this->requestBody('ids'),
+ );
+ }
]
];
},
@@ -231,6 +241,7 @@ return [
'options' => [
'accept' => $this->accept,
'apiUrl' => $this->parent->apiUrl(true) . '/sections/' . $this->name,
+ 'batch' => $this->batch,
'columns' => $this->columnsWithTypes(),
'empty' => $this->empty,
'headline' => $this->headline,
diff --git a/public/kirby/config/sections/mixins/batch.php b/public/kirby/config/sections/mixins/batch.php
new file mode 100644
index 0000000..25128f2
--- /dev/null
+++ b/public/kirby/config/sections/mixins/batch.php
@@ -0,0 +1,45 @@
+ [
+ /**
+ * Activates the batch delete option for the section
+ */
+ 'batch' => function (bool $batch = false) {
+ return $batch;
+ },
+ ],
+ 'methods' => [
+ 'deleteSelected' => function (array $ids): bool {
+ if ($ids === []) {
+ return true;
+ }
+
+ // check if batch deletion is allowed
+ if ($this->batch() === false) {
+ throw new PermissionException(
+ message: 'The section does not support batch actions'
+ );
+ }
+
+ $min = $this->min();
+
+ // check if the section has enough items after the deletion
+ if ($this->total() - count($ids) < $min) {
+ throw new Exception(
+ message: I18n::template('error.section.' . $this->type() . '.min.' . I18n::form($min), [
+ 'min' => $min,
+ 'section' => $this->headline()
+ ])
+ );
+ }
+
+ $this->models()->delete($ids);
+ return true;
+ }
+ ]
+];
diff --git a/public/kirby/config/sections/mixins/layout.php b/public/kirby/config/sections/mixins/layout.php
index b348176..1cd41bf 100644
--- a/public/kirby/config/sections/mixins/layout.php
+++ b/public/kirby/config/sections/mixins/layout.php
@@ -19,7 +19,7 @@ return [
*/
'layout' => function (string $layout = 'list') {
$layouts = ['list', 'cardlets', 'cards', 'table'];
- return in_array($layout, $layouts) ? $layout : 'list';
+ return in_array($layout, $layouts, true) ? $layout : 'list';
},
/**
* Whether the raw content file values should be used for the table column previews. Should not be used unless it eases performance issues in your setup introduced with Kirby 4.2
diff --git a/public/kirby/config/sections/mixins/parent.php b/public/kirby/config/sections/mixins/parent.php
index 1217411..463e643 100644
--- a/public/kirby/config/sections/mixins/parent.php
+++ b/public/kirby/config/sections/mixins/parent.php
@@ -24,7 +24,9 @@ return [
$parent = $this->model->query($query);
if (!$parent) {
- throw new Exception('The parent for the query "' . $query . '" cannot be found in the section "' . $this->name() . '"');
+ throw new Exception(
+ message: 'The parent for the query "' . $query . '" cannot be found in the section "' . $this->name() . '"'
+ );
}
if (
@@ -33,7 +35,9 @@ return [
$parent instanceof File === false &&
$parent instanceof User === false
) {
- throw new Exception('The parent for the section "' . $this->name() . '" has to be a page, site or user object');
+ throw new Exception(
+ message: 'The parent for the section "' . $this->name() . '" has to be a page, site or user object'
+ );
}
}
diff --git a/public/kirby/config/sections/mixins/sort.php b/public/kirby/config/sections/mixins/sort.php
index 118e03b..185cc6d 100644
--- a/public/kirby/config/sections/mixins/sort.php
+++ b/public/kirby/config/sections/mixins/sort.php
@@ -29,7 +29,7 @@ return [
if (
$this->type === 'pages' &&
- in_array($this->status, ['listed', 'published', 'all']) === false
+ in_array($this->status, ['listed', 'published', 'all'], true) === false
) {
return false;
}
diff --git a/public/kirby/config/sections/pages.php b/public/kirby/config/sections/pages.php
index 6ca1090..fe3d47c 100644
--- a/public/kirby/config/sections/pages.php
+++ b/public/kirby/config/sections/pages.php
@@ -10,6 +10,7 @@ use Kirby\Toolkit\I18n;
return [
'mixins' => [
+ 'batch',
'details',
'empty',
'headline',
@@ -44,7 +45,7 @@ return [
$status = 'draft';
}
- if (in_array($status, ['all', 'draft', 'published', 'listed', 'unlisted']) === false) {
+ if (in_array($status, ['all', 'draft', 'published', 'listed', 'unlisted'], true) === false) {
$status = 'all';
}
@@ -77,7 +78,9 @@ return [
$parent instanceof Site === false &&
$parent instanceof Page === false
) {
- throw new InvalidArgumentException('The parent is invalid. You must choose the site or a page as parent.');
+ throw new InvalidArgumentException(
+ message: 'The parent is invalid. You must choose the site or a page as parent.'
+ );
}
return $parent;
@@ -111,7 +114,7 @@ return [
// filter by all set templates
if (
$this->templates &&
- in_array($intendedTemplate, $this->templates) === false
+ in_array($intendedTemplate, $this->templates, true) === false
) {
return false;
}
@@ -119,7 +122,7 @@ return [
// exclude by all ignored templates
if (
$this->templatesIgnore &&
- in_array($intendedTemplate, $this->templatesIgnore) === true
+ in_array($intendedTemplate, $this->templatesIgnore, true) === true
) {
return false;
}
@@ -147,25 +150,26 @@ return [
$pages = $pages->flip();
}
+ return $pages;
+ },
+ 'modelsPaginated' => function () {
// pagination
- $pages = $pages->paginate([
+ return $this->models()->paginate([
'page' => $this->page,
'limit' => $this->limit,
'method' => 'none' // the page is manually provided
]);
-
- return $pages;
},
'pages' => function () {
return $this->models;
},
'total' => function () {
- return $this->models->pagination()->total();
+ return $this->models()->count();
},
'data' => function () {
$data = [];
- foreach ($this->models as $page) {
+ foreach ($this->modelsPaginated() as $page) {
$panel = $page->panel();
$permissions = $page->permissions();
@@ -180,10 +184,11 @@ return [
'link' => $panel->url(true),
'parent' => $page->parentId(),
'permissions' => [
- 'sort' => $permissions->can('sort'),
+ 'delete' => $permissions->can('delete'),
'changeSlug' => $permissions->can('changeSlug'),
'changeStatus' => $permissions->can('changeStatus'),
'changeTitle' => $permissions->can('changeTitle'),
+ 'sort' => $permissions->can('sort'),
],
'status' => $page->status(),
'template' => $page->intendedTemplate()->name(),
@@ -313,12 +318,28 @@ return [
return $blueprints;
},
],
+ // @codeCoverageIgnoreStart
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => 'delete',
+ 'method' => 'DELETE',
+ 'action' => function () {
+ return $this->section()->deleteSelected(
+ ids: $this->requestBody('ids'),
+ );
+ }
+ ]
+ ];
+ },
+ // @codeCoverageIgnoreEnd
'toArray' => function () {
return [
'data' => $this->data,
'errors' => $this->errors,
'options' => [
'add' => $this->add,
+ 'batch' => $this->batch,
'columns' => $this->columnsWithTypes(),
'empty' => $this->empty,
'headline' => $this->headline,
diff --git a/public/kirby/config/tags.php b/public/kirby/config/tags.php
index 240a365..9f98127 100644
--- a/public/kirby/config/tags.php
+++ b/public/kirby/config/tags.php
@@ -216,14 +216,18 @@ return [
// if url is empty, throw exception or link to the error page
if ($tag->value === null) {
if ($tag->kirby()->option('debug', false) === true) {
+ $error = 'The linked page cannot be found';
+
if (empty($tag->text) === false) {
- throw new NotFoundException('The linked page cannot be found for the link text "' . $tag->text . '"');
- } else {
- throw new NotFoundException('The linked page cannot be found');
+ $error .= ' for the link text "' . $tag->text . '"';
}
- } else {
- $tag->value = Url::to($tag->kirby()->site()->errorPageId());
+
+ throw new NotFoundException(
+ message: $error
+ );
}
+
+ $tag->value = Url::to($tag->kirby()->site()->errorPageId());
}
return Html::a($tag->value, $tag->text, [
diff --git a/public/kirby/i18n/translations/bg.json b/public/kirby/i18n/translations/bg.json
index 4a483a3..94c0d78 100644
--- a/public/kirby/i18n/translations/bg.json
+++ b/public/kirby/i18n/translations/bg.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/bs.json b/public/kirby/i18n/translations/bs.json
new file mode 100644
index 0000000..5d44fbe
--- /dev/null
+++ b/public/kirby/i18n/translations/bs.json
@@ -0,0 +1,799 @@
+{
+ "account.changeName": "Promijeni svoje ime",
+ "account.delete": "Obriši svoj račun",
+ "account.delete.confirm": "Da li stvarno želite obrisati svoj račun? Odmah ćete biti odjavljeni. Vaš račun neće biti moguće oporaviti.",
+
+ "activate": "Aktiviraj",
+ "add": "Dodaj",
+ "alpha": "Alpha",
+ "author": "Autor",
+ "avatar": "Profilna slika",
+ "back": "Natrag",
+ "cancel": "Odustani",
+ "change": "Promijeni",
+ "close": "Zatvori",
+ "changes": "Promjene",
+ "confirm": "Ok",
+ "collapse": "Skupi",
+ "collapse.all": "Skupi sve",
+ "color": "Boja",
+ "coordinates": "Koordinate",
+ "copy": "Kopiraj",
+ "copy.all": "Kopiraj sve",
+ "copy.success": "Kopirano",
+ "copy.success.multiple": "{count} kopirano!",
+ "copy.url": "Kopiraj URL",
+ "create": "Kreiraj",
+ "custom": "Prilagođeno",
+
+ "date": "Datum",
+ "date.select": "Odaberi datum",
+
+ "day": "Dan",
+ "days.fri": "Pet",
+ "days.mon": "Pon",
+ "days.sat": "Sub",
+ "days.sun": "Ned",
+ "days.thu": "Čet",
+ "days.tue": "Uto",
+ "days.wed": "Sri",
+
+ "debugging": "Debugiranje",
+
+ "delete": "Obriši",
+ "delete.all": "Obriši sve",
+
+ "dialog.fields.empty": "Ovaj dijalog nema polja",
+ "dialog.files.empty": "Nema datoteka za odabir",
+ "dialog.pages.empty": "Nema stranica za odabir",
+ "dialog.text.empty": "Ovaj dijalog ne definira nikakav tekst",
+ "dialog.users.empty": "Nema korisnika za odabir",
+
+ "dimensions": "Dimenzije",
+ "disable": "Onemogući",
+ "disabled": "Onemogućeno",
+ "discard": "Odbaci",
+
+ "drawer.fields.empty": "Ova ladica nema polja",
+
+ "domain": "Domena",
+ "download": "Download",
+ "duplicate": "Dupliciraj",
+
+ "edit": "Uredi",
+
+ "email": "Email",
+ "email.placeholder": "mail@example.com",
+
+ "enter": "Enter",
+ "entries": "Unosi",
+ "entry": "Unos",
+
+ "environment": "Okruženje",
+
+ "error": "Greška",
+ "error.access.code": "Nevažeći kod",
+ "error.access.login": "Nevažeći login",
+ "error.access.panel": "Pristup panelu nije dozvoljen",
+ "error.access.view": "Pristup ovom dijelu panela nije dozvoljen",
+
+ "error.avatar.create.fail": "Profilnu sliku nije moguće spremiti",
+ "error.avatar.delete.fail": "Profilna slika se ne može obrisati",
+ "error.avatar.dimensions.invalid": "Nevažeće dimenzije. Promijenite visinu i širinu profilne slike ispod 3000 piksela",
+ "error.avatar.mime.forbidden": "Profilna slika mora biti u JPEG ili PNG formatu",
+
+ "error.blueprint.notFound": "Blueprint \"{name}\" nije pronađen",
+
+ "error.blocks.max.plural": "Ne smijete dodati više od {max} blokova",
+ "error.blocks.max.singular": "Ne smijete dodati više od jednog bloka",
+ "error.blocks.min.plural": "Morate dodati najmanje {min} blokova",
+ "error.blocks.min.singular": "Morate dodati najmanje jedan blok",
+ "error.blocks.validation": "Postoji greška kod \"{field}\" polja, u bloku {index}, pri korištenju bloka tipa \"{fieldset}\" ",
+
+ "error.cache.type.invalid": "Nevažeći tip za cache \"{type}\"",
+
+ "error.content.lock.delete": "Ova verzija je zaključana i ne može se obrisati",
+ "error.content.lock.move": "Izvorna verzija je zaključana i ne može se premjestiti",
+ "error.content.lock.publish": "Ova verzija je već objavljena",
+ "error.content.lock.replace": "Ova verzija je zaključana i ne može se zamijeniti",
+ "error.content.lock.update": "Ova verzija je zaključana i ne može se urediti",
+
+ "error.entries.max.plural": "Ne smijete dodati više od {max} unosa",
+ "error.entries.max.singular": "Ne smijete dodati više od jednog unosa",
+ "error.entries.min.plural": "Morate dodati najmanje {min} unosa",
+ "error.entries.min.singular": "Morate dodati najmanje jedan unos",
+ "error.entries.supports": "\"{type}\" tip polja nije podržan za polje unosa",
+ "error.entries.validation": "Postoji greška na \"{field}\" polju u redu {index}",
+
+ "error.email.preset.notFound": "Email preset \"{name}\" nije pronađen",
+
+ "error.field.converter.invalid": "Nevažeći Converter \"{converter}\"",
+ "error.field.link.options": "Nevažeće opcije: {options}",
+ "error.field.type.missing": "Polje \"{name}\": Tip polja \"{type}\" ne postoji",
+
+ "error.file.changeName.empty": "Naziv ne smije biti prazan",
+ "error.file.changeName.permission": "Nemate dozvolu da promijenite naziv datoteke \"{filename}\"",
+ "error.file.changeTemplate.invalid": "Predložak za datoteku \"{id}\" se ne može promijeniti u \"{template}\" (dozvoljeno: \"{blueprints}\")",
+ "error.file.changeTemplate.permission": "Nemate dozvolu da promijenite predložak za datoteku \"{id}\"",
+
+ "error.file.delete.multiple": "Nije bilo moguće obrisati sve datoteke. Pokušajte ih obrisati pojedinačno da bi vidjeli specifičnu grešku koja sprječava brisanje. ",
+ "error.file.duplicate": "Datoteka sa imenom \"{filename}\" već postoji",
+ "error.file.extension.forbidden": "Ekstenzija \"{extension}\" nije dozvoljena",
+ "error.file.extension.invalid": "Nevažeća ekstenzija: {extension}",
+ "error.file.extension.missing": "Ekstenzija za datoteku \"{filename}\" nedostaje",
+ "error.file.maxheight": "Visina slike ne smije prelaziti {height} piksela",
+ "error.file.maxsize": "Datoteka je prevelika",
+ "error.file.maxwidth": "Širina slike ne smije prelaziti {width} piksela",
+ "error.file.mime.differs": "Uploadana datoteka mora biti istog mime tipa \"{mime}\"",
+ "error.file.mime.forbidden": "Tip medija \"{mime}\" nije dozvoljen",
+ "error.file.mime.invalid": "Nevažeći mime tip: {mime}",
+ "error.file.mime.missing": "Tip medija za \"{filename}\" se ne može očitati",
+ "error.file.minheight": "Visina slike mora biti najmanje {height} piksela",
+ "error.file.minsize": "Datoteka je premala",
+ "error.file.minwidth": "Širina slike mora biti najmanje {height} piksela",
+ "error.file.name.unique": "Naziv datoteke mora biti jedinstven",
+ "error.file.name.missing": "Naziv datoteke ne smije biti prazan",
+ "error.file.notFound": "Datoteka \"{filename}\" nije pronađena",
+ "error.file.orientation": "Orijentacija slike mora biti \"{orientation}\"",
+ "error.file.sort.permission": "Nemate dozvolu da promijenite sortiranje od \"{filename}\"",
+ "error.file.type.forbidden": "Nije dozvoljen upload {type} datoteka",
+ "error.file.type.invalid": "Nevažeći tip datoteke: {type}",
+ "error.file.undefined": "Datoteka nije pronađena",
+
+ "error.form.incomplete": "Popravi sve greške na formi...",
+ "error.form.notSaved": "Forma se ne može spremiti",
+
+ "error.language.code": "Unesi važeći kod za jezik",
+ "error.language.create.permission": "Nemate dozvolu da kreirate jezik",
+ "error.language.delete.permission": "Nemate dozvolu da obrišete jezik",
+ "error.language.duplicate": "Jezik već postoji",
+ "error.language.name": "Unesi važeći naziv za jezik",
+ "error.language.notFound": "Jezik nije pronađen",
+ "error.language.update.permission": "Nemate dozvolu da ažurirate jezik",
+
+ "error.layout.validation.block": "Postoji greška kod \"{field}\" polja, u bloku {blockIndex}, pri korištenju bloka tipa \"{fieldset}\" u rasporedu {layoutIndex}",
+ "error.layout.validation.settings": "Postoji greška u postavkama rasporeda {index}",
+
+ "error.license.domain": "Domena za licencu nedostaje",
+ "error.license.email": "Unesite važeću email adresu",
+ "error.license.format": "Unesite važeću licencu",
+ "error.license.verification": "Licenca se ne može verificirati",
+
+ "error.login.totp.confirm.invalid": "Nevažeći kod",
+ "error.login.totp.confirm.missing": "Unesi trenutni kod",
+
+ "error.object.validation": "Postoji greška u \"{label}\" polju:\n{message}",
+
+ "error.offline": "Panel je trenutno offline",
+
+ "error.page.changeSlug.permission": "Nemate dozvolu da promijenite URL nastavak za \"{slug}\"",
+ "error.page.changeSlug.reserved": "Putanja stranica najvišeg nivoa ne smije počinjati sa \"{path}\"",
+ "error.page.changeStatus.incomplete": "Stranica sadrži greške i ne može se objaviti",
+ "error.page.changeStatus.permission": "Status za ovu stranicu se ne može promijeniti",
+ "error.page.changeStatus.toDraft.invalid": "Stranica \"{slug}\" se ne može pretvoriti u skicu",
+ "error.page.changeTemplate.invalid": "Predložak za stranicu \"{slug}\" se ne može promijeniti",
+ "error.page.changeTemplate.permission": "Nemate dozvolu da promijenite predložak za \"{slug}\"",
+ "error.page.changeTitle.empty": "Naslov ne može biti prazan",
+ "error.page.changeTitle.permission": "Nemate dozvolu da promijenite naslov za \"{slug}\"",
+ "error.page.create.permission": "Nemate dozvolu da kreirate \"{slug}\"",
+ "error.page.delete": "Stranica \"{slug}\" se ne može obrisati",
+ "error.page.delete.confirm": "Unesite naslov stranice da potvrdite",
+ "error.page.delete.hasChildren": "Stranica sadrži podstranice i ne može se obrisati",
+ "error.page.delete.multiple": "Nije bilo moguće obrisati sve stranice. Pokušajte ih obrisati pojedinačno da bi vidjeli specifičnu grešku koja sprječava brisanje. ",
+ "error.page.delete.permission": "Nemate dozvolu da obrišete \"{slug}\"",
+ "error.page.draft.duplicate": "Skica stranice sa URL nastavkom \"{slug}\" već postoji",
+ "error.page.duplicate": "Stranica sa URL nastavkom \"{slug}\" već postoji",
+ "error.page.duplicate.permission": "Nemate dozvolu da duplicirate \"{slug}\"",
+ "error.page.move.ancestor": "Stranica se ne može premjestiti u samu sebe",
+ "error.page.move.directory": "Direktorij stranice se ne može premjestiti",
+ "error.page.move.duplicate": "Podstranica sa URL nastavkom \"{slug}\" već postoji",
+ "error.page.move.noSections": "Stranica \"{parent}\" ne može biti roditelj bilo koje podstranice jer ne sadrži sekciju \"pages\" u svom blueprintu",
+ "error.page.move.notFound": "Premještena stranica se ne može pronaći",
+ "error.page.move.permission": "Nemate dozvolu da premjestite \"{slug}\"",
+ "error.page.move.template": "Predložak \"{template}\" nije prihvaćen kao podstranica od \"{parent}\"",
+ "error.page.notFound": "Stranica \"{slug}\" nije pronađena",
+ "error.page.num.invalid": "Unesi važeći broj za sortiranje. Brojevi ne smiju biti negativni.",
+ "error.page.slug.invalid": "Unesi važeći URL prefiks",
+ "error.page.slug.maxlength": "Dužina URL nastavka mora biti manja od \"{length}\" znakova",
+ "error.page.sort.permission": "Stranica \"{slug}\" se ne može sortirati",
+ "error.page.status.invalid": "Odaberi važeći status stranice",
+ "error.page.undefined": "Stranica nije pronađena",
+ "error.page.update.permission": "Nemate dozvolu da uredite \"{slug}\"",
+
+ "error.section.files.max.plural": "Nije moguće dodati više od {max} datoteka u \"{section}\" sekciju",
+ "error.section.files.max.singular": "Nije moguće dodati više od jedne datoteke u \"{section}\" sekciju",
+ "error.section.files.min.plural": "Sekcija \"{section}\" zahtjeva najmanje {min} datoteka",
+ "error.section.files.min.singular": "Sekcija \"{section}\" zahtjeva najmanje jednu datoteku",
+
+ "error.section.pages.max.plural": "Nije moguće dodati više od {max} stranica u \"{section}\" sekciju",
+ "error.section.pages.max.singular": "Nije moguće dodati više od jedne stranice \"{section}\" sekciju",
+ "error.section.pages.min.plural": "Sekcija \"{section}\" zahtjeva najmanje {min} stranica",
+ "error.section.pages.min.singular": "Sekcija \"{section}\" zahtjeva najmanje jednu stranicu",
+
+ "error.section.notLoaded": "Sekcija \"{name}\" se nije mogla učitati",
+ "error.section.type.invalid": "Tip sekcije \"{type}\" nije važeći",
+
+ "error.site.changeTitle.empty": "The title must not be empty",
+ "error.site.changeTitle.permission": "Nemate dozvolu da promijenite naslov stranice",
+ "error.site.update.permission": "Nemate dozvolu da uredite stranicu",
+
+ "error.structure.validation": "Postoji greška na \"{field}\" polju u redu {index}",
+
+ "error.template.default.notFound": "Default predložak ne postoji",
+
+ "error.unexpected": "Dogodila se neočekivana greška! Omogućite debug modus za više informacija: https://getkirby.com/docs/reference/system/options/debug",
+
+ "error.user.changeEmail.permission": "Nemate dozvolu da promijenite email adresu korisnika \"{name}\"",
+ "error.user.changeLanguage.permission": "Nemate dozvolu da promijenite jezik korisnika \"{name}\"",
+ "error.user.changeName.permission": "Nemate dozvolu da promijenite ime korisnika \"{name}\"",
+ "error.user.changePassword.permission": "Nemate dozvolu da promijenite šifru korisnika \"{name}\"",
+ "error.user.changeRole.lastAdmin": "Uloga zadnjeg administratora se ne može promijeniti",
+ "error.user.changeRole.permission": "Nemate dozvolu da promijenite ulogu korisnika \"{name}\"",
+ "error.user.changeRole.toAdmin": "Nemate dozvolu da promijenite ulogu nekog korisnika u administratora",
+ "error.user.create.permission": "Nemate dozvolu da kreirate ovog korisnika",
+ "error.user.delete": "Korisnik \"{name}\" se ne može obrisati",
+ "error.user.delete.lastAdmin": "Zadnji administrator se ne može obrisati",
+ "error.user.delete.lastUser": "Zadnji korisnik se ne može obrisati",
+ "error.user.delete.permission": "Nemate dozvolu da obrišete korisnika \"{name}\"",
+ "error.user.duplicate": "Korisnik sa email adresom \"{email}\" već postoji",
+ "error.user.email.invalid": "Unesite važeću email adresu",
+ "error.user.language.invalid": "Unesi važeći jezik",
+ "error.user.notFound": "Korisnik \"{name}\" nije pronađen",
+ "error.user.password.excessive": "Unesite važeću šifru. Šifre ne smiju biti duže od 1000 znakova",
+ "error.user.password.invalid": "Unesi važeću šifru. Šifre moraju sadržavati najmanje 8 znakova.",
+ "error.user.password.notSame": "Šifre se ne podudaraju",
+ "error.user.password.undefined": "Korisnik nema šifru",
+ "error.user.password.wrong": "Kriva šifra",
+ "error.user.role.invalid": "Uloga je nevažeća",
+ "error.user.undefined": "Nije moguće pronaći korisnika",
+ "error.user.update.permission": "Nemate dozvolu da uredite korisnika \"{name}\"",
+
+ "error.validation.accepted": "Potvrdi",
+ "error.validation.alpha": "Koristi samo znakove u rasponu a-z",
+ "error.validation.alphanum": "Koristi samo znakove u rasponu a-z ili brojeve 0-9",
+ "error.validation.anchor": "Unesite ispravan anker linka",
+ "error.validation.between": "Unesi vrijednost između \"{min}\" i \"{max}\"",
+ "error.validation.boolean": "Potvrdi ili otkaži",
+ "error.validation.color": "Unesite važeću boju u {format} formatu",
+ "error.validation.contains": "Unesi vrijednost koja sadrži \"{needle}\"",
+ "error.validation.date": "Unesi važeći datum",
+ "error.validation.date.after": "Unesi datum nakon {date}",
+ "error.validation.date.before": "Unesi datum prije {date}",
+ "error.validation.date.between": "Unesi datum između {min} i {max}",
+ "error.validation.denied": "Otkaži",
+ "error.validation.different": "Vrijednost ne može biti \"{other}\"",
+ "error.validation.email": "Unesite važeću email adresu",
+ "error.validation.endswith": "Vrijednost mora završavati sa \"{end}\"",
+ "error.validation.filename": "Unesi važeći naziv datoteke",
+ "error.validation.in": "Unesi jedno od slijedećeg: ({in})",
+ "error.validation.integer": "Unesi važeći cijeli broj",
+ "error.validation.ip": "Unesi važeću IP adresu",
+ "error.validation.less": "Unesi vrijednost manju od {max}",
+ "error.validation.linkType": "Tip linka nije dozvoljen",
+ "error.validation.match": "Vrijednost ne odgovara očekivanom uzorku",
+ "error.validation.max": "Unesi vrijednost jednaku ili manju od {max}",
+ "error.validation.maxlength": "Unesi kraću vrijednost. (max. {max} znakova)",
+ "error.validation.maxwords": "Unesi najviše {max} riječ(i)",
+ "error.validation.min": "Unesi vrijednost jednaku ili veću od {min}",
+ "error.validation.minlength": "Unesi dužu vrijednost. (min. {min} znakova)",
+ "error.validation.minwords": "Unesi najmanje {min} riječ(i)",
+ "error.validation.more": "Unesi veću vrijednost od {min}",
+ "error.validation.notcontains": "Unesi vrijednost koja NE sadrži \"{needle}\"",
+ "error.validation.notin": "Nemojte unositi bilo šta od slijedećeg: ({notIn})",
+ "error.validation.option": "Odaberite važeću opciju",
+ "error.validation.num": "Odaberite važeći broj",
+ "error.validation.required": "Unesi nešto",
+ "error.validation.same": "Unesi \"{other}\"",
+ "error.validation.size": "Dužina unosa mora biti \"{size}\"",
+ "error.validation.startswith": "Unos mora počinjati sa \"{start}\"",
+ "error.validation.tel": "Unesite neformatirani broj telefona",
+ "error.validation.time": "Unesi važeće vrijeme",
+ "error.validation.time.after": "Unesite vrijeme poslije {time}",
+ "error.validation.time.before": "Unesite vrijeme prije {time}",
+ "error.validation.time.between": "Unesite vrijeme između {min} i {max}",
+ "error.validation.uuid": "Unesite važeći UUID",
+ "error.validation.url": "Unesi važeći URL",
+
+ "expand": "Proširi",
+ "expand.all": "Proširi sve",
+
+ "field.invalid": "Polje je nevažeće",
+ "field.required": "Polje je obavezno",
+ "field.blocks.changeType": "Promijena tipa",
+ "field.blocks.code.name": "Kod",
+ "field.blocks.code.language": "Jezik",
+ "field.blocks.code.placeholder": "Vaš kod ...",
+ "field.blocks.delete.confirm": "Da li stvarno želite obrisati ovaj blok?",
+ "field.blocks.delete.confirm.all": "Da li stvarno želite obrisati sve blokove?",
+ "field.blocks.delete.confirm.selected": "Da li stvarno želite obrisati odabrane blokove?",
+ "field.blocks.empty": "Još nema blokova",
+ "field.blocks.fieldsets.empty": "Još nema skupova polja",
+ "field.blocks.fieldsets.label": "Odaberite tip bloka ...",
+ "field.blocks.fieldsets.paste": "Pritisnite {{ shortcut }} da uvezete rasporede/blokove iz vašeg međuspremnika.Samo oni dozvoljeni u trenutnom polju će biti umetnuti.",
+ "field.blocks.gallery.name": "Galerija",
+ "field.blocks.gallery.images.empty": "Još nema slika",
+ "field.blocks.gallery.images.label": "Slike",
+ "field.blocks.heading.level": "Nivo",
+ "field.blocks.heading.name": "Naslov",
+ "field.blocks.heading.text": "Tekst",
+ "field.blocks.heading.placeholder": "Naslov ...",
+ "field.blocks.figure.back.plain": "Obično",
+ "field.blocks.figure.back.pattern.light": "Uzorak (svijetlo)",
+ "field.blocks.figure.back.pattern.dark": "Uzorak (tamno)",
+ "field.blocks.image.alt": "Alternativni tekst",
+ "field.blocks.image.caption": "Natpis",
+ "field.blocks.image.crop": "Odreži",
+ "field.blocks.image.link": "Link",
+ "field.blocks.image.location": "Lokacija",
+ "field.blocks.image.location.internal": "Ova web stranica",
+ "field.blocks.image.location.external": "Eksterni izvor",
+ "field.blocks.image.name": "Slika",
+ "field.blocks.image.placeholder": "Odaberi sliku",
+ "field.blocks.image.ratio": "Omjer",
+ "field.blocks.image.url": "URL slike",
+ "field.blocks.line.name": "Linija",
+ "field.blocks.list.name": "Lista",
+ "field.blocks.markdown.name": "Markdown",
+ "field.blocks.markdown.label": "Tekst",
+ "field.blocks.markdown.placeholder": "Markdown ...",
+ "field.blocks.quote.name": "Citat",
+ "field.blocks.quote.text.label": "Tekst",
+ "field.blocks.quote.text.placeholder": "Citat ...",
+ "field.blocks.quote.citation.label": "Citiranje",
+ "field.blocks.quote.citation.placeholder": "po ...",
+ "field.blocks.text.name": "Tekst",
+ "field.blocks.text.placeholder": "Tekst ...",
+ "field.blocks.video.autoplay": "Automatski reproduciraj",
+ "field.blocks.video.caption": "Natpis",
+ "field.blocks.video.controls": "Kontrole",
+ "field.blocks.video.location": "Lokacija",
+ "field.blocks.video.loop": "Ponavljaj",
+ "field.blocks.video.muted": "Utišano",
+ "field.blocks.video.name": "Video",
+ "field.blocks.video.placeholder": "Unesi video URL",
+ "field.blocks.video.poster": "Poster",
+ "field.blocks.video.preload": "Predučitavanje",
+ "field.blocks.video.url.label": "Video-URL",
+ "field.blocks.video.url.placeholder": "https://youtube.com/?v=",
+
+ "field.entries.delete.confirm.all": "Da li stvarno želite obrisati sve unose?",
+ "field.entries.empty": "Još nema unosa",
+
+ "field.files.empty": "Niti jedna datoteka još nije odabrana",
+ "field.files.empty.single": "Datoteka još nije odabrana",
+
+ "field.layout.change": "Promijeni raspored",
+ "field.layout.delete": "Obriši raspored",
+ "field.layout.delete.confirm": "Da li stvarno želite obrisati ovaj raspored?",
+ "field.layout.delete.confirm.all": "Da li stvarno želite obrisati sve rasporede?",
+ "field.layout.empty": "Još nema redova",
+ "field.layout.select": "Odaberi raspored",
+
+ "field.object.empty": "Još nema informacija",
+
+ "field.pages.empty": "Niti jedna stranica još nije odabrana",
+ "field.pages.empty.single": "Stranica još nije odabrana",
+
+ "field.structure.delete.confirm": "Da li stvarno želite obrisati ovaj red?",
+ "field.structure.delete.confirm.all": "Da li stvarno želite obrisati sve unose?",
+ "field.structure.empty": "Još nema unosa",
+
+ "field.users.empty": "Niti jedan korisnik još nije odabran",
+ "field.users.empty.single": "Korisnik još nije odabran",
+
+ "fields.empty": "Još nema polja",
+
+ "file": "Datoteka",
+ "file.blueprint": "Ovaj fajl još uvijek nema blueprint. Možete definirati postavke u /site/blueprints/files/{blueprint}.yml",
+ "file.changeTemplate": "Promijeni predložak",
+ "file.changeTemplate.notice": "Promjena predloška datoteke će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Ako novi predložak definira određena pravila, npr. dimenzije slike, ona će se također nepovratno primijeniti. Koristite s oprezom.",
+ "file.delete.confirm": "Da li stvarno želite obrisati
{filename}?",
+ "file.focus.placeholder": "Postavi fokalnu tačku",
+ "file.focus.reset": "Ukloni fokalnu tačku",
+ "file.focus.title": "Fokus",
+ "file.sort": "Promijeni poziciju",
+
+ "files": "Datoteke",
+ "files.delete.confirm.selected": "Da li stvarno želite da obrišete odabrane datoteke? Ova akcija se ne može poništiti.",
+ "files.empty": "Još nema datoteka...",
+
+ "filter": "Filter",
+
+ "form.discard": "Odbaci promjene",
+ "form.discard.confirm": "Da li stvarno želite odbaciti sve promjene?",
+ "form.locked": "Ovaj sadržaj je vama onemogućen jer ga trenutno uređuje drugi korisnik",
+ "form.unsaved": "Trenutne promjene još uvijek nisu spremljene",
+ "form.preview": "Pregledaj izmjene",
+ "form.preview.draft": "Pregledaj skicu",
+
+ "hide": "Sakrij",
+ "hour": "Sat",
+ "hue": "Nijansa",
+ "import": "Uvoz",
+ "info": "Info",
+ "insert": "Umetni",
+ "insert.after": "Umetni nakon",
+ "insert.before": "Umetni prije",
+ "install": "Instaliraj",
+
+ "installation": "Instalacija",
+ "installation.completed": "Panel je instaliran",
+ "installation.disabled": "Instaler za panel je onemogućen na javnim serverima po defaultu. Pokreni instaler na lokalnom okruženju ili omogući sa panel.install opcijom.",
+ "installation.issues.accounts": "Folder /site/accounts ne postoji ili nema dozvolu pisanja",
+ "installation.issues.content": "Folder /content ne postoji ili nema dozvolu pisanja",
+ "installation.issues.curl": "Ekstenzija CURL je neophodna",
+ "installation.issues.headline": "Panel se ne može instalirati",
+ "installation.issues.mbstring": "Ekstenzija MB String je neophodna",
+ "installation.issues.media": "Folder /media ne postoji ili nema dozvolu pisanja",
+ "installation.issues.php": "Obavezno koristite PHP 7+",
+ "installation.issues.sessions": "Folder /site/sessions ne postoji ili nema dozvolu pisanja",
+
+ "language": "Jezik",
+ "language.code": "Kod",
+ "language.convert": "Postavi kao zadani",
+ "language.convert.confirm": "
Da li stvarno želite konvertirati {name} u zadani jezik? Ova akcija se ne može poništiti.
Ukoliko {name} sadrži dijelove bez prijevoda, neće postojati važeći fallback što može prouzrokovati prazne dijelove na ovoj stranici.
", + "language.create": "Dodaj novi jezik", + "language.default": "Zadani jezik", + "language.delete.confirm": "Da li stvarno želite obrisati jezik {name} including all translations? This cannot be undone!", + "language.deleted": "Jezik je obrisan", + "language.direction": "Smjer čitanja", + "language.direction.ltr": "Sa lijeva na desno", + "language.direction.rtl": "Sa desna na lijevo", + "language.locale": "PHP lokalizacijski string", + "language.locale.warning": "Sistem koristi prilagođenu lokalizaciju. Promijeni postavke u jezičnoj datoteci u /site/languages", + "language.name": "Naziv", + "language.secondary": "Sekundarni jezik", + "language.settings": "Postavke jezika", + "language.updated": "Jezik je izmijenjen", + "language.variables": "Jezične varijable", + "language.variables.empty": "Još nema prijevoda", + + "language.variable.delete.confirm": "Da li stvarno želite da obrišete varijablu za {key}?", + "language.variable.entries": "Values", + "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
+ "language.variable.key": "Ključ",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
+ "language.variable.notFound": "Varijablu nije moguće pronaći",
+ "language.variable.value": "Vrijednost",
+
+ "languages": "Jezici",
+ "languages.default": "Zadani jezik",
+ "languages.empty": "Jezici još nisu definirani",
+ "languages.secondary": "Sekundarni jezici",
+ "languages.secondary.empty": "Sekundarni jezici još nisu definirani",
+
+ "license": "Licenca",
+ "license.activate": "Aktivirajte odmah",
+ "license.activate.label": "Aktivirajte vašu licencu",
+ "license.activate.domain": "Vaša licenca će biti aktivirana za {host}.",
+ "license.activate.local": "Upravo ćete aktivirati svoju Kirby licencu za vašu lokalnu domenu {host}. Ako će ova stranica biti postavljena na javnu domenu, molimo vas da je tamo aktivirate. Ako je {host} domena za koju želite koristiti svoju licencu, molimo vas da nastavite.",
+ "license.activated": "Aktivirano",
+ "license.buy": "Kupite licencu",
+ "license.code": "Kod",
+ "license.code.help": "Primili ste vašu licencu nakon kupovine putem e-maila. Molimo da je kopirate i zalijepite ovdje.",
+ "license.code.label": "Unesite vašu licencu",
+ "license.status.active.info": "Uključuje nove glavne verzije do {date}",
+ "license.status.active.label": "Važeća licenca",
+ "license.status.demo.info": "Ovo je demo instalacija",
+ "license.status.demo.label": "Demo",
+ "license.status.inactive.info": "Obnovi licencu da bi se ažuriralo na nove glavne verzije",
+ "license.status.inactive.label": "Nema novih glavnih verzija",
+ "license.status.legacy.bubble": "Spremni da obnovite vašu licencu?",
+ "license.status.legacy.info": "Vaša licenca ne pokriva ovu verziju",
+ "license.status.legacy.label": "Obnovite vašu licencu",
+ "license.status.missing.bubble": "Spremni da objavite vašu stranicu?",
+ "license.status.missing.info": "Ne postoji važeća licenca",
+ "license.status.missing.label": "Aktivirajte vašu licencu",
+ "license.status.unknown.info": "Status licence je nepoznat",
+ "license.status.unknown.label": "Nepoznato",
+ "license.manage": "Upravljanje vašim licencama",
+ "license.purchased": "Kupljeno",
+ "license.success": "Hvala što podržavate Kirby",
+ "license.unregistered.label": "Neregistrovano",
+
+ "link": "Link",
+ "link.text": "Tekst linka",
+
+ "loading": "Učitavanje",
+
+ "lock.unsaved": "Izmjene nisu spremljene",
+ "lock.unsaved.empty": "Ne postoje druge izmjene za spremanje",
+ "lock.unsaved.files": "Nespremljene datoteke",
+ "lock.unsaved.pages": "Nespremljene stranice",
+ "lock.unsaved.users": "Nespremljeni računi",
+ "lock.isLocked": "Izmjene od strane {email}",
+ "lock.unlock": "Otključaj",
+ "lock.unlock.submit": "Otključaj i prepiši preko nespremljenih promjena od {email}",
+ "lock.isUnlocked": "Vaše izmjene su zamijenjene sa izmjenama drugog korisnika. Dostupne su za download, te ih možete spojiti manuelno.",
+
+ "login": "Prijava",
+ "login.code.label.login": "Kod za prijavu",
+ "login.code.label.password-reset": "Kod za resetiranje šifre",
+ "login.code.placeholder.email": "000 000",
+ "login.code.placeholder.totp": "000000",
+ "login.code.text.email": "Ako je vaša e-mail adresa registrirana, traženi kod je poslan putem e-maila.",
+ "login.code.text.totp": "Unesite jednokratni kod iz vaše autentifikacijske aplikacije.",
+ "login.email.login.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za prijavu na Panel stranice {site}.\nSljedeći kod za prijavu važiće {timeout} minuta:\n\n{code}\n\nAko niste zatražili kod za prijavu, molimo vas da zanemarite ovaj e-mail ili kontaktirate svog administratora ako imate pitanja.\nIz sigurnosnih razloga, molimo vas da NE prosljeđujete ovaj e-mail.",
+ "login.email.login.subject": "Vaš kod za prijavu",
+ "login.email.password-reset.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za resetiranje šifre za Panel stranice {site}.\nSljedeći kod za resetiranje šifre važiće {timeout} minuta:\n\n{code}\n\nAko niste zatražili kod za resetiranje šifre, molimo vas da zanemarite ovaj e-mail ili kontaktirate svog administratora ako imate pitanja.\nIz sigurnosnih razloga, molimo vas da NE prosljeđujete ovaj e-mail.",
+ "login.email.password-reset.subject": "Kod za resetiranje šifre",
+ "login.remember": "Zapamti moju prijavu",
+ "login.reset": "Resetiraj šifru",
+ "login.toggleText.code.email": "Prijava putem emaila",
+ "login.toggleText.code.email-password": "Prijava sa šifrom",
+ "login.toggleText.password-reset.email": "Zaboravljena šifra?",
+ "login.toggleText.password-reset.email-password": "← Nazad na login",
+ "login.totp.enable.option": "Postavite jednokratne kodove",
+ "login.totp.enable.intro": "Autentifikacijske aplikacije mogu generirati jednokratne kodove koji se koriste kao drugi faktor prilikom prijavljivanja u vaš račun.",
+ "login.totp.enable.qr.label": "1. Skeniraj ovaj QR kod",
+ "login.totp.enable.qr.help": "Skeniranje nije moguće? Dodaj ključ za postavljanje {secret} ručno u aplikaciju za autentifikaciju.",
+ "login.totp.enable.confirm.headline": "2. Potvrdi sa generisanim kodom",
+ "login.totp.enable.confirm.text": "Vaša aplikacija generira novi jednokratni kod svakih 30 sekundi. Unesite trenutni kod da završite postavljanje:",
+ "login.totp.enable.confirm.label": "Trenutni kod",
+ "login.totp.enable.confirm.help": "Nakon ovih postavki, tražićemo od vas jednokratni kod prilikom svakog prijavljivanja.",
+ "login.totp.enable.success": "Jednokratni kodovi omogućeni",
+ "login.totp.disable.option": "Onemogući jednokratne kodove",
+ "login.totp.disable.label": "Unesite vaš password da bi onemogućili jednokratne kodove",
+ "login.totp.disable.help": "Ubuduće, drugačiji drugi faktor poput koda za prijavu dostavljenog putem e-maila će biti zatražen prilikom slijedeće prijave. Uvijek ponovo možete postaviti jednokratne kodove kasnije.",
+ "login.totp.disable.admin": "Ovo će onemogućiti jednokratne kodove za {user}.
Ubuduće, drugačiji drugi faktor poput koda za prijavu dostavljenog putem e-maila će biti zatražen prilikom prijave. {user} može postaviti jednokratne kodove nakon njihove slijedeće prijave.
", + "login.totp.disable.success": "Jednokratni kodovi onemogućeni", + + "logout": "Odjava", + + "merge": "Spoji", + "menu": "Izbornik", + "meridiem": "AM/PM", + "mime": "Tip medija", + "minutes": "Minute", + + "month": "Mjesec", + "months.april": "April", + "months.august": "August", + "months.december": "Decembar", + "months.february": "Feburar", + "months.january": "Januar", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "Mart", + "months.may": "Maj", + "months.november": "Novembar", + "months.october": "Oktobar", + "months.september": "Septembar", + + "more": "Više", + "move": "Pomjeri", + "name": "Ime", + "next": "Dalje", + "night": "Noć", + "no": "ne", + "off": "off", + "on": "on", + "open": "Otvori", + "open.newWindow": "Otvori u novom prozoru", + "option": "Opcija", + "options": "Opcije", + "options.none": "Nema opcija", + "options.all": "Prikaži svih {count} options", + + "orientation": "Orijentacija", + "orientation.landscape": "Pejzaž", + "orientation.portrait": "Portret", + "orientation.square": "Kvadrat", + + "page": "Strana", + "page.blueprint": "Ova stranica još uvijek nema blueprint. Možete definirati postavke u /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Promijeni URL", + "page.changeSlug.fromTitle": "Kreiraj iz naslova", + "page.changeStatus": "Promijeni status", + "page.changeStatus.position": "Odaberi poziciju", + "page.changeStatus.select": "Odaberi novi status", + "page.changeTemplate": "Promijeni predložak", + "page.changeTemplate.notice": "Promjena predloška stranice će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Koristite s oprezom.", + "page.create": "Kreiraj kao {status}", + "page.delete.confirm": "Da li stvarno želite obrisati {title}?", + "page.delete.confirm.subpages": "Ova stranica ima podstranice.0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/cs.json b/public/kirby/i18n/translations/cs.json
index f2564fb..0d9bc10 100644
--- a/public/kirby/i18n/translations/cs.json
+++ b/public/kirby/i18n/translations/cs.json
@@ -366,7 +366,7 @@
"field.layout.delete": "Smazat rozvržení",
"field.layout.delete.confirm": "Opravdu chcete smazat toto rozvržení?",
"field.layout.delete.confirm.all": "Opravdu chcete smazat všechna rozvržení?",
- "field.layout.empty": "Zatím žádné řádky",
+ "field.layout.empty": "Zatím žádné rozvržení",
"field.layout.select": "Vyberte rozvržení",
"field.object.empty": "Zatím žádná informace",
@@ -449,7 +449,12 @@
"language.variables.empty": "Zatím žádné překlady",
"language.variable.delete.confirm": "Doopravdy chcete smazat proměnou {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Klíč",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Proměnná nebyla nalezena",
"language.variable.value": "Hodnota",
diff --git a/public/kirby/i18n/translations/da.json b/public/kirby/i18n/translations/da.json
index 8c05064..d6d97c3 100644
--- a/public/kirby/i18n/translations/da.json
+++ b/public/kirby/i18n/translations/da.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/de.json b/public/kirby/i18n/translations/de.json
index f5f19ba..56aa459 100644
--- a/public/kirby/i18n/translations/de.json
+++ b/public/kirby/i18n/translations/de.json
@@ -307,7 +307,7 @@
"field.blocks.delete.confirm.all": "Willst du wirklich alle Blöcke löschen?",
"field.blocks.delete.confirm.selected": "Willst du wirklich die ausgewählten Blöcke löschen?",
"field.blocks.empty": "Keine Blöcke",
- "field.blocks.fieldsets.empty": "Keine Block Definition",
+ "field.blocks.fieldsets.empty": "Keine Blockdefinitionen",
"field.blocks.fieldsets.label": "Bitte wähle einen Blocktyp aus …",
"field.blocks.fieldsets.paste": "Drücke {{ shortcut }} um Layouts/Blocks von deinem Clipboard zu importieren. Nur die, die im aktuellen Feld erlaubt sind werden eingefügt.",
"field.blocks.gallery.name": "Galerie",
@@ -357,7 +357,7 @@
"field.blocks.video.url.placeholder": "https://youtube.com/?v=",
"field.entries.delete.confirm.all": "Möchtest du wirklich alle Einträge löschen?",
- "field.entries.empty": "Es bestehen keine Einträge.",
+ "field.entries.empty": "Keine Einträge",
"field.files.empty": "Keine Dateien ausgewählt",
"field.files.empty.single": "Keine Dateien ausgewählt",
@@ -449,7 +449,12 @@
"language.variables.empty": "Keine Übersetzung",
"language.variable.delete.confirm": "Willst du wirklich die Variable \"{key}\" entfernen?",
+ "language.variable.entries": "Werte",
+ "language.variable.entries.help": "Jeder Eintrag wird für die entsprechende Anzahl verwendet, z. B. werden drei Einträge in der Reihenfolge mit den Anzahlen 0, 1, 2 und mehr übereinstimmen. Verwende den Platzhalter {count}, um die tatsächliche Anzahl einzufügen.",
"language.variable.key": "Schlüssel",
+ "language.variable.multiple": "Zählbar?",
+ "language.variable.multiple.text": "Benutze unterschiedliche Übersetzungen, abhängig von der Anzahl",
+ "language.variable.multiple.help": "Du kannst verschiedene Werte anlegen, die von einer Anzahl abhängen. Diese übergibst du zusammen mit der Sprachvariablen. So kannst du dynamische Übersetzungen erstellen, zum Beispiel für Singular und Plural.",
"language.variable.notFound": "Die Variable konnte nicht gefunden werden",
"language.variable.value": "Wert",
diff --git a/public/kirby/i18n/translations/el.json b/public/kirby/i18n/translations/el.json
index 1679c6d..9659dbc 100644
--- a/public/kirby/i18n/translations/el.json
+++ b/public/kirby/i18n/translations/el.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/en.json b/public/kirby/i18n/translations/en.json
index f556c6f..7efbc30 100644
--- a/public/kirby/i18n/translations/en.json
+++ b/public/kirby/i18n/translations/en.json
@@ -20,7 +20,9 @@
"coordinates": "Coordinates",
"copy": "Copy",
"copy.all": "Copy all",
- "copy.success": "{count} copied!",
+ "copy.success": "Copied",
+ "copy.success.multiple": "{count} copied!",
+ "copy.url": "Copy URL",
"create": "Create",
"custom": "Custom",
@@ -90,6 +92,19 @@
"error.cache.type.invalid": "Invalid cache type \"{type}\"",
+ "error.content.lock.delete": "The version is locked and cannot be deleted",
+ "error.content.lock.move": "The source version is locked and cannot be moved",
+ "error.content.lock.publish": "This version is already published",
+ "error.content.lock.replace": "The version is locked and cannot be replaced",
+ "error.content.lock.update": "The version is locked and cannot be updated",
+
+ "error.entries.max.plural": "You must not add more than {max} entries",
+ "error.entries.max.singular": "You must not add more than one entry",
+ "error.entries.min.plural": "You must add at least {min} entries",
+ "error.entries.min.singular": "You must add at least one entry",
+ "error.entries.supports": "\"{type}\" field type is not supported for the entries field",
+ "error.entries.validation": "There's an error on the \"{field}\" field in row {index}",
+
"error.email.preset.notFound": "The email preset \"{name}\" cannot be found",
"error.field.converter.invalid": "Invalid converter \"{converter}\"",
@@ -101,6 +116,7 @@
"error.file.changeTemplate.invalid": "The template for the file \"{id}\" cannot be changed to \"{template}\" (valid: \"{blueprints}\")",
"error.file.changeTemplate.permission": "You are not allowed to change the template for the file \"{id}\"",
+ "error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
"error.file.duplicate": "A file with the name \"{filename}\" already exists",
"error.file.extension.forbidden": "The extension \"{extension}\" is not allowed",
"error.file.extension.invalid": "Invalid extension: {extension}",
@@ -119,6 +135,7 @@
"error.file.name.missing": "The filename must not be empty",
"error.file.notFound": "The file \"{filename}\" cannot be found",
"error.file.orientation": "The orientation of the image must be \"{orientation}\"",
+ "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"",
"error.file.type.forbidden": "You are not allowed to upload {type} files",
"error.file.type.invalid": "Invalid file type: {type}",
"error.file.undefined": "The file cannot be found",
@@ -162,6 +179,7 @@
"error.page.delete": "The page \"{slug}\" cannot be deleted",
"error.page.delete.confirm": "Please enter the page title to confirm",
"error.page.delete.hasChildren": "The page has subpages and cannot be deleted",
+ "error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
"error.page.delete.permission": "You are not allowed to delete \"{slug}\"",
"error.page.draft.duplicate": "A page draft with the URL appendix \"{slug}\" already exists",
"error.page.duplicate": "A page with the URL appendix \"{slug}\" already exists",
@@ -338,6 +356,9 @@
"field.blocks.video.url.label": "Video-URL",
"field.blocks.video.url.placeholder": "https://youtube.com/?v=",
+ "field.entries.delete.confirm.all": "Do you really want to delete all entries?",
+ "field.entries.empty": "No entries yet",
+
"field.files.empty": "No files selected yet",
"field.files.empty.single": "No file selected yet",
@@ -373,10 +394,18 @@
"file.sort": "Change position",
"files": "Files",
+ "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
"files.empty": "No files yet",
"filter": "Filter",
+ "form.discard": "Discard changes",
+ "form.discard.confirm": "Do you really want to discard all your changes?",
+ "form.locked": "This content is disabled for you as it is currently edited by another user",
+ "form.unsaved": "The current changes have not yet been saved",
+ "form.preview": "Preview changes",
+ "form.preview.draft": "Preview draft",
+
"hide": "Hide",
"hour": "Hour",
"hue": "Hue",
@@ -420,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
@@ -452,6 +486,8 @@
"license.status.missing.bubble": "Ready to launch your site?",
"license.status.missing.info": "No valid license",
"license.status.missing.label": "Please activate your license",
+ "license.status.unknown.info": "The license status is unknown",
+ "license.status.unknown.label": "Unknown",
"license.manage": "Manage your licenses",
"license.purchased": "Purchased",
"license.success": "Thank you for supporting Kirby",
@@ -463,7 +499,10 @@
"loading": "Loading",
"lock.unsaved": "Unsaved changes",
- "lock.unsaved.empty": "There are no more unsaved changes",
+ "lock.unsaved.empty": "There are no unsaved changes",
+ "lock.unsaved.files": "Unsaved files",
+ "lock.unsaved.pages": "Unsaved pages",
+ "lock.unsaved.users": "Unsaved accounts",
"lock.isLocked": "Unsaved changes by {email}",
"lock.unlock": "Unlock",
"lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}",
@@ -570,6 +609,7 @@
"page.status.unlisted.description": "The page is only accessible via URL",
"pages": "Pages",
+ "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
"pages.empty": "No pages yet",
"pages.status.draft": "Drafts",
"pages.status.listed": "Published",
@@ -586,6 +626,10 @@
"plugins": "Plugins",
"prev": "Previous",
"preview": "Preview",
+
+ "publish": "Publish",
+ "published": "Published",
+
"remove": "Remove",
"rename": "Rename",
"renew": "Renew",
@@ -605,6 +649,7 @@
"role.nobody.title": "Nobody",
"save": "Save",
+ "saved": "Saved",
"search": "Search",
"searching": "Searching",
"search.min": "Enter {min} characters to search",
@@ -631,7 +676,6 @@
"system.info.copy": "Copy info",
"system.info.copied": "System info copied",
- "system.issues.api.methods": "Your server does not support PATCH requests",
"system.issues.content": "The content folder seems to be exposed",
"system.issues.eol.kirby": "Your installed Kirby version has reached end-of-life and will not receive further security updates",
"system.issues.eol.plugin": "Your installed version of the { plugin } plugin is has reached end-of-life and will not receive further security updates",
@@ -658,6 +702,12 @@
"tel": "Phone",
"tel.placeholder": "+49123456789",
"template": "Template",
+
+ "theme": "Theme",
+ "theme.light": "Lights on",
+ "theme.dark": "Lights off",
+ "theme.automatic": "Match system default",
+
"title": "Title",
"today": "Today",
@@ -728,10 +778,13 @@
"users": "Users",
"version": "Version",
+ "version.changes": "Changed version",
+ "version.compare": "Compare versions",
"version.current": "Current version",
"version.latest": "Latest version",
"versionInformation": "Version information",
+ "view": "View",
"view.account": "Your account",
"view.installation": "Installation",
"view.languages": "Languages",
diff --git a/public/kirby/i18n/translations/eo.json b/public/kirby/i18n/translations/eo.json
index c4a8fd4..2262bcb 100644
--- a/public/kirby/i18n/translations/eo.json
+++ b/public/kirby/i18n/translations/eo.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/es_419.json b/public/kirby/i18n/translations/es_419.json
index bbfa123..8d55c34 100644
--- a/public/kirby/i18n/translations/es_419.json
+++ b/public/kirby/i18n/translations/es_419.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/es_ES.json b/public/kirby/i18n/translations/es_ES.json
index c572229..b1e521f 100644
--- a/public/kirby/i18n/translations/es_ES.json
+++ b/public/kirby/i18n/translations/es_ES.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/fa.json b/public/kirby/i18n/translations/fa.json
index 3009ca6..89c45f8 100644
--- a/public/kirby/i18n/translations/fa.json
+++ b/public/kirby/i18n/translations/fa.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/fi.json b/public/kirby/i18n/translations/fi.json
index 75a75f5..78a9cfc 100644
--- a/public/kirby/i18n/translations/fi.json
+++ b/public/kirby/i18n/translations/fi.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/fr.json b/public/kirby/i18n/translations/fr.json
index 769a184..a512c59 100644
--- a/public/kirby/i18n/translations/fr.json
+++ b/public/kirby/i18n/translations/fr.json
@@ -30,13 +30,13 @@
"date.select": "Choisir une date",
"day": "Jour",
- "days.fri": "Ven",
- "days.mon": "Lun",
- "days.sat": "Sam",
- "days.sun": "Dim",
- "days.thu": "Jeu",
- "days.tue": "Mar",
- "days.wed": "Mer",
+ "days.fri": "Ven.",
+ "days.mon": "Lun.",
+ "days.sat": "Sam.",
+ "days.sun": "Dim.",
+ "days.thu": "Jeu.",
+ "days.tue": "Mar.",
+ "days.wed": "Mer.",
"debugging": "Débogage",
@@ -80,12 +80,12 @@
"error.avatar.create.fail": "L’image du profil n’a pu être transférée",
"error.avatar.delete.fail": "L’image du profil n’a pu être supprimée",
"error.avatar.dimensions.invalid": "Veuillez choisir une image de profil de largeur et hauteur inférieures à 3000 pixels",
- "error.avatar.mime.forbidden": "L'image du profil utilisateur doit être un fichier JPEG ou PNG",
+ "error.avatar.mime.forbidden": "L’image du profil utilisateur doit être un fichier JPEG ou PNG",
"error.blueprint.notFound": "Le blueprint « {name} » n’a pu être chargé",
"error.blocks.max.plural": "Vous ne devez pas ajouter plus de {max} blocs",
- "error.blocks.max.singular": "Vous ne devez pas ajouter plus d'un bloc",
+ "error.blocks.max.singular": "Vous ne devez pas ajouter plus d’un bloc",
"error.blocks.min.plural": "Vous devez ajouter au moins {min} blocs",
"error.blocks.min.singular": "Vous devez ajouter au moins un bloc",
"error.blocks.validation": "Il y a une erreur sur le champ « {field} » du bloc {index} utilisant le type de bloc « {fieldset} »",
@@ -119,9 +119,9 @@
"error.file.delete.multiple": "Tous les fichiers n’ont pu être supprimés. Essayez avec chaque fichier restant individuellement pour voir quelle erreur empêche sa suppression.",
"error.file.duplicate": "Un fichier nommé « {filename} » existe déjà",
"error.file.extension.forbidden": "L’extension « {extension} » n’est pas autorisée",
- "error.file.extension.invalid": "Extension incorrecte : {extension}",
+ "error.file.extension.invalid": "Extension invalide : {extension}",
"error.file.extension.missing": "L’extension pour « {filename} » est manquante",
- "error.file.maxheight": "La hauteur de l'image ne doit pas excéder {height} pixels",
+ "error.file.maxheight": "La hauteur de l’image ne doit pas excéder {height} pixels",
"error.file.maxsize": "Le fichier est trop volumineux",
"error.file.maxwidth": "La largeur de l’image ne doit pas excéder {width} pixels",
"error.file.mime.differs": "Le fichier transféré doit être du même type de média « {mime} »",
@@ -143,11 +143,11 @@
"error.form.incomplete": "Veuillez corriger toutes les erreurs du formulaire…",
"error.form.notSaved": "Le formulaire n’a pu être enregistré",
- "error.language.code": "Veuillez saisir un code correct pour la langue",
+ "error.language.code": "Veuillez saisir un code valide pour la langue",
"error.language.create.permission": "Vous n’êtes pas autorisé à créer une langue",
"error.language.delete.permission": "Vous n’êtes pas autorisé à supprimer la langue",
"error.language.duplicate": "Cette langue existe déjà",
- "error.language.name": "Veuillez saisir un nom correct pour la langue",
+ "error.language.name": "Veuillez saisir un nom valide pour la langue",
"error.language.notFound": "La langue n’a pu être trouvée",
"error.language.update.permission": "Vous n’êtes pas autorisé à modifier la langue",
@@ -155,7 +155,7 @@
"error.layout.validation.settings": "Il y a une erreur dans les paramètres de la disposition {index}",
"error.license.domain": "Le domaine de la licence est manquant",
- "error.license.email": "Veuillez saisir un courriel correct",
+ "error.license.email": "Veuillez saisir un courriel valide",
"error.license.format": "Veuillez saisir un numéro de licence valide",
"error.license.verification": "La licence n’a pu être vérifiée",
@@ -168,7 +168,7 @@
"error.page.changeSlug.permission": "Vous n’êtes pas autorisé à modifier l’identifiant d’URL pour « {slug} »",
"error.page.changeSlug.reserved": "Le chemin des pages de premier niveau ne doit pas commencer par « {path} »",
- "error.page.changeStatus.incomplete": "La page comporte des erreurs et ne peut pas être publiée",
+ "error.page.changeStatus.incomplete": "La page comporte des erreurs et ne peut être publiée",
"error.page.changeStatus.permission": "Le statut de cette page ne peut être modifié",
"error.page.changeStatus.toDraft.invalid": "La page « {slug} » ne peut être convertie en brouillon",
"error.page.changeTemplate.invalid": "Le modèle de la page « {slug} » ne peut être changé",
@@ -192,11 +192,11 @@
"error.page.move.permission": "Vous n’êtes pas autorisé à déplacer « {slug} » ",
"error.page.move.template": "Le modèle « {template} » n’est pas accepté en tant que sous-page de « {parent} »",
"error.page.notFound": "La page « {slug} » n’a pu être trouvée",
- "error.page.num.invalid": "Veuillez saisir un numéro de position correct. Les numéros ne doivent pas être négatifs.",
- "error.page.slug.invalid": "Veuillez entrer un identifiant d’URL correct",
+ "error.page.num.invalid": "Veuillez saisir un numéro de position valide. Les numéros ne doivent pas être négatifs.",
+ "error.page.slug.invalid": "Veuillez entrer un identifiant d’URL valide",
"error.page.slug.maxlength": "L’identifiant d’URL doit faire moins de « {length} » caractères",
"error.page.sort.permission": "La page « {slug} » ne peut être réordonnée",
- "error.page.status.invalid": "Veuillez choisir un statut de page correct",
+ "error.page.status.invalid": "Veuillez choisir un statut de page valide",
"error.page.undefined": "La page n’a pu être trouvée",
"error.page.update.permission": "Vous n’êtes pas autorisé à modifier « {slug} »",
@@ -211,7 +211,7 @@
"error.section.pages.min.singular": "La section « {section} » requiert au moins une page",
"error.section.notLoaded": "La section « {name} » n’a pu être chargée",
- "error.section.type.invalid": "Le type de section « {type} » est incorrect",
+ "error.section.type.invalid": "Le type de section « {type} » est invalide",
"error.site.changeTitle.empty": "Le titre ne peut être vide",
"error.site.changeTitle.permission": "Vous n’êtes pas autorisé à modifier le titre du site",
@@ -236,38 +236,38 @@
"error.user.delete.lastUser": "Le dernier utilisateur ne peut être supprimé",
"error.user.delete.permission": "Vous n’êtes pas autorisé à supprimer l’utilisateur « {name} »",
"error.user.duplicate": "Un utilisateur avec le courriel « {email} » existe déjà",
- "error.user.email.invalid": "Veuillez saisir un courriel correct",
- "error.user.language.invalid": "Veuillez saisir une langue correcte",
+ "error.user.email.invalid": "Veuillez saisir un courriel valide",
+ "error.user.language.invalid": "Veuillez saisir une langue valide",
"error.user.notFound": "L’utilisateur « {name} » n’a pu être trouvé",
"error.user.password.excessive": "Veuillez entrer un mot de passe valide. Les mots de passe ne doivent pas dépasser 1000 caractères de long.",
"error.user.password.invalid": "Veuillez saisir un mot de passe valide. Les mots de passe doivent comporter au moins 8 caractères.",
"error.user.password.notSame": "Les mots de passe ne sont pas identiques",
"error.user.password.undefined": "Cet utilisateur n’a pas de mot de passe",
"error.user.password.wrong": "Mot de passe incorrect",
- "error.user.role.invalid": "Veuillez saisir un rôle correct",
+ "error.user.role.invalid": "Veuillez saisir un rôle valide",
"error.user.undefined": "L’utilisateur n’a pu être trouvé",
"error.user.update.permission": "Vous n’êtes pas autorisé à modifier l’utilisateur « {name} »",
"error.validation.accepted": "Veuillez confirmer",
"error.validation.alpha": "Veuillez saisir uniquement des caractères alphabétiques minuscules",
"error.validation.alphanum": "Veuillez ne saisir que des minuscules de a à z et des chiffres de 0 à 9",
- "error.validation.anchor": "Veuillez entrer un lien ancré correct",
+ "error.validation.anchor": "Veuillez entrer un lien d’ancrage correct",
"error.validation.between": "Veuillez saisir une valeur entre « {min} » et « {max} »",
"error.validation.boolean": "Veuillez confirmer ou refuser",
"error.validation.color": "Veuillez entrer une couleur valide dans le format {format}",
"error.validation.contains": "Veuillez saisir une valeur contenant « {needle} »",
- "error.validation.date": "Veuillez saisir une date correcte",
+ "error.validation.date": "Veuillez saisir une date valide",
"error.validation.date.after": "Veuillez saisir une date après {date}",
"error.validation.date.before": "Veuillez saisir une date avant {date}",
"error.validation.date.between": "Veuillez saisir une date entre {min} et {max}",
"error.validation.denied": "Veuillez refuser",
"error.validation.different": "La valeur ne doit pas être « {other} »",
- "error.validation.email": "Veuillez saisir un courriel correct",
+ "error.validation.email": "Veuillez saisir un courriel valide",
"error.validation.endswith": "La valeur doit se terminer par « {end} »",
- "error.validation.filename": "Veuillez saisir un nom de fichier correct",
+ "error.validation.filename": "Veuillez saisir un nom de fichier valide",
"error.validation.in": "Veuillez saisir l’un des éléments suivants: ({in})",
- "error.validation.integer": "Veuillez saisir un entier correct",
- "error.validation.ip": "Veuillez saisir une adresse IP correcte",
+ "error.validation.integer": "Veuillez saisir un entier valide",
+ "error.validation.ip": "Veuillez saisir une adresse IP valide",
"error.validation.less": "Veuillez saisir une valeur inférieure à {max}",
"error.validation.linkType": "Le type de lien n’est pas autorisé",
"error.validation.match": "La valeur ne correspond pas au modèle attendu",
@@ -280,19 +280,19 @@
"error.validation.more": "Veuillez saisir une valeur supérieure à {min}",
"error.validation.notcontains": "Veuillez saisir une valeur ne contenant pas « {needle} »",
"error.validation.notin": "Veuillez ne saisir aucun des éléments suivants: ({notIn})",
- "error.validation.option": "Veuillez sélectionner une option correcte",
- "error.validation.num": "Veuillez saisir un nombre correct",
+ "error.validation.option": "Veuillez sélectionner une option valide",
+ "error.validation.num": "Veuillez saisir un nombre valide",
"error.validation.required": "Veuillez saisir quelque chose",
"error.validation.same": "Veuillez saisir « {other} »",
"error.validation.size": "La grandeur de la valeur doit être « {size} »",
"error.validation.startswith": "La valeur doit commencer par « {start} »",
"error.validation.tel": "Veuillez saisir un numéro de téléphone non formaté",
- "error.validation.time": "Veuillez saisir une heure correcte",
+ "error.validation.time": "Veuillez saisir une heure valide",
"error.validation.time.after": "Veuillez saisir une heure après {time}",
"error.validation.time.before": "Veuillez saisir une heure avant {time}",
"error.validation.time.between": "Veuillez saisir une heure entre {min} et {max}",
"error.validation.uuid": "Veuillez saisir un UUID valide",
- "error.validation.url": "Veuillez saisir une URL correcte",
+ "error.validation.url": "Veuillez saisir une URL valide",
"expand": "Déplier",
"expand.all": "Tout déplier",
@@ -371,7 +371,7 @@
"field.object.empty": "Pas encore d‘information",
- "field.pages.empty": "Pas encore de page sélectionnée",
+ "field.pages.empty": "Pas encore de pages sélectionnées",
"field.pages.empty.single": "Pas encore de page sélectionnée",
"field.structure.delete.confirm": "Voulez-vous vraiment supprimer cette ligne ?",
@@ -401,7 +401,7 @@
"form.discard": "Annuler les modifications",
"form.discard.confirm": "Voulez-vous vraiment annuler toutes les modifications ?",
- "form.locked": "Ce contenu est désactivé pour vous car il est actuellement édité par un autre utilisateur.",
+ "form.locked": "Vous ne pouvez pas modifier ce contenu car il est en cours d'édition par un autre utilisateur.",
"form.unsaved": "Les modifications actuelles n’ont pas encore été enregistrées",
"form.preview": "Prévisualiser les modifications",
"form.preview.draft": "Prévisualiser le brouillon",
@@ -440,7 +440,7 @@
"language.direction.ltr": "De gauche à droite",
"language.direction.rtl": "De droite à gauche",
"language.locale": "Locales PHP",
- "language.locale.warning": "Vous utilisez une Locale PHP personnalisée. Veuillez la modifier dans le fichier de langue situé dans /site/languages",
+ "language.locale.warning": "Vous utilisez un identifiant régional personnalisée. Veuillez le modifier dans le fichier de langue situé dans /site/languages",
"language.name": "Nom",
"language.secondary": "Langue secondaire",
"language.settings": "Préférences de langue",
@@ -449,7 +449,12 @@
"language.variables.empty": "Pas encore de traductions",
"language.variable.delete.confirm": "Voulez-vous vraiment supprimer la variable pour {key} ?",
+ "language.variable.entries": "Valeurs",
+ "language.variable.entries.help": "Chaque chaîne sera utilisée pour son nombre d’éléments correspondant, par exemple trois chaînes correspondront dans l'ordre à 0, 1, 2 et plus éléments. Utilisez le jeton {count} pour insérer le nombre d’éléments réel.",
"language.variable.key": "Clé",
+ "language.variable.multiple": "Dénombrable ?",
+ "language.variable.multiple.text": "Utiliser des chaînes de traduction différentes",
+ "language.variable.multiple.help": "Vous pouvez utiliser des valeurs différentes en fonction d'un nombre d’éléments que vous passez avec la variable de langue, ce qui vous permet de créer des traductions dynamiques, par exemple au singulier et au pluriel.",
"language.variable.notFound": "La variable n’a pu être trouvée",
"language.variable.value": "Valeur",
@@ -463,7 +468,7 @@
"license.activate": "Activer maintenant",
"license.activate.label": "Veuillez activer votre licence",
"license.activate.domain": "Votre licence sera activée pour {host}.",
- "license.activate.local": "Vous êtes sur le point d‘activer votre licence de Kirby pour votre domaine local {host}. Si ce site doit être activé sur un domaine publique, veuillez plutôt l‘activer là-bas. Si {host} est bien le domaine pour lequel vous voulez activer votre licence, veuillez continuer.",
+ "license.activate.local": "Vous êtes sur le point d‘activer votre licence de Kirby pour votre domaine local {host}. Si ce site doit être activé sur un domaine publique, veuillez plutôt l’activer là-bas. Si {host} est bien le domaine pour lequel vous voulez activer votre licence, veuillez continuer.",
"license.activated": "Activée",
"license.buy": "Acheter une licence",
"license.code": "Code",
@@ -530,7 +535,7 @@
"login.totp.enable.confirm.help": "Après cette configuration, nous vous demanderons un code à usage unique à chaque connexion.",
"login.totp.enable.success": "Codes à usage unique activés",
"login.totp.disable.option": "Désactiver les codes à usage unique",
- "login.totp.disable.label": "Saisissez votre mot de passe pour désactiver les codes à usage unique.",
+ "login.totp.disable.label": "Saisissez votre mot de passe pour désactiver les codes à usage unique",
"login.totp.disable.help": "Un second facteur différent, par exemple un code de connexion envoyé par courriel, vous sera demandé à la connexion. Vous pourrez à nouveau configurer les codes à usage unique ultérieurement.",
"login.totp.disable.admin": "Cela désactivera les codes à usage unique pour {user}.
Un second facteur différent, par exemple un code de connexion envoyé par courriel lui sera demandé à la connexion. {user} pourra à nouveau configurer les codes à usage unique ultérieurement.
", "login.totp.disable.success": "Codes à usage unique désactivés", @@ -695,12 +700,12 @@ "system.updateStatus.upgrade": "Mise à jour { version } disponible", "tel": "Téléphone", - "tel.placeholder": "+33123456789", + "tel.placeholder": "+3312345678", "template": "Modèle", "theme": "Thème", - "theme.light": "Allumer", - "theme.dark": "Éteindre", + "theme.light": "Clair", + "theme.dark": "Sombre", "theme.automatic": "Suivre le réglage système", "title": "Titre", diff --git a/public/kirby/i18n/translations/hu.json b/public/kirby/i18n/translations/hu.json index 56424ac..442122e 100644 --- a/public/kirby/i18n/translations/hu.json +++ b/public/kirby/i18n/translations/hu.json @@ -449,7 +449,12 @@ "language.variables.empty": "No translations yet", "language.variable.delete.confirm": "Do you really want to delete the variable for {key}?", + "language.variable.entries": "Values", + "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/id.json b/public/kirby/i18n/translations/id.json
index b6f0e0f..a26de0e 100644
--- a/public/kirby/i18n/translations/id.json
+++ b/public/kirby/i18n/translations/id.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/is_IS.json b/public/kirby/i18n/translations/is_IS.json
index a8fff49..dec18ce 100644
--- a/public/kirby/i18n/translations/is_IS.json
+++ b/public/kirby/i18n/translations/is_IS.json
@@ -98,17 +98,17 @@
"error.content.lock.replace": "Þessi útfáfa er læst og það verður ekki skipt út",
"error.content.lock.update": "Þessi útgáfa er læst og hún verður ekki uppfærð",
- "error.entries.max.plural": "You must not add more than {max} entries",
- "error.entries.max.singular": "You must not add more than one entry",
- "error.entries.min.plural": "You must add at least {min} entries",
- "error.entries.min.singular": "You must add at least one entry",
- "error.entries.supports": "\"{type}\" field type is not supported for the entries field",
+ "error.entries.max.plural": "Ekki fleiri en {max} færslur",
+ "error.entries.max.singular": "Hámark ein færsla",
+ "error.entries.min.plural": "Að minnsta kosti {min} færslur",
+ "error.entries.min.singular": "Þú skalt setja inn a.m.k. eina færslu",
+ "error.entries.supports": "\"{type}\" sviðsgerðin er ekki studd fyrir færslu svið",
"error.entries.validation": "Það er villa í \"{field}\" sviðinu í röð {index}",
"error.email.preset.notFound": "Netfangstillingarnar: \"{name}\" fundust ekki",
"error.field.converter.invalid": "Ógildur umbreytari \"{converter}\"",
- "error.field.link.options": "Invalid options: {options}",
+ "error.field.link.options": "Ógildar stillingar: {options}",
"error.field.type.missing": "Sviðið \"{ name }\": Sviðgerðin er \"{type}\" er alls ekki til.",
"error.file.changeName.empty": "Nafn skal fylla út",
@@ -116,7 +116,7 @@
"error.file.changeTemplate.invalid": "Sniðmátinu fyrir skránna \"{id}\" er ekki hægt að breyta í \"{template}\" (gild: \"{blueprints}\")",
"error.file.changeTemplate.permission": "Þú mátt ekkert breyta sniðmátinu fyrir skránna \"{id}\"",
- "error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
+ "error.file.delete.multiple": "Ekki var unnt að eyða öllum skrám. Reyndu að eyða hverri og einni til þess að sjá hvort þessi villa hindri eyðingu.",
"error.file.duplicate": "Skrá með nafninu \"{filename}\" er nú þegar til",
"error.file.extension.forbidden": "Skrárendingin \"{extension}\" er ekki leyfð",
"error.file.extension.invalid": "Óleyfilegt skrársnið hér: {extension}",
@@ -179,7 +179,7 @@
"error.page.delete": "Síðunni \"{slug}\" er ekki hægt að eyða",
"error.page.delete.confirm": "Ritaðu titil síðunnar til að staðfesta",
"error.page.delete.hasChildren": "Síðan hefur undirsíður og er því ekki hægt að eyða",
- "error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
+ "error.page.delete.multiple": "Ekki var unnt að eyða öllum síðum. Reyndu að eyða hverri og einni til þess að sjá hvort þessi villa hindri eyðingu.",
"error.page.delete.permission": "Þú mátt ekkert eyða \"{slug}\"",
"error.page.draft.duplicate": "Uppkast með slóðinni \"{slug}\" er þegar til",
"error.page.duplicate": "Síða með slóðinni \"{slug}\" er þegar til",
@@ -394,13 +394,13 @@
"file.sort": "Breyta röðun",
"files": "Skrár",
- "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
+ "files.delete.confirm.selected": "Viltu virkilega eyða völdum skrám? Hér verður ekki aftur snúið.",
"files.empty": "Engar skrár enn",
"filter": "Sigta",
"form.discard": "Hunsa breytingar",
- "form.discard.confirm": "Ætlarðu virkilega að hunsa alla breytingar?",
+ "form.discard.confirm": "Ætlarðu virkilega að hunsa allar breytingar?",
"form.locked": "Efnið er þér ekki aðgengilegt þar sem annar notandi er nú þegar að vinna í því",
"form.unsaved": "Þessar breytingar hafa ekki verið vistaðar",
"form.preview": "Skoða breytingar",
@@ -449,7 +449,12 @@
"language.variables.empty": "Engar þýðingar enn",
"language.variable.delete.confirm": "Ertu viss um að þú viljir nú fjarlægja breytuna fyrir {key}?",
+ "language.variable.entries": "Gildi",
+ "language.variable.entries.help": "Hver strengur verður notaður fyrir samsvarandi fjölda, t.d. þrír strengir munu passa til að telja 0, 1, 2 og fleiri. Notaðu {count} staðgengilinn fyrir raunverulegan fjölda.",
"language.variable.key": "Lykill",
+ "language.variable.multiple": "Teljanlegt?",
+ "language.variable.multiple.text": "Notaðu annan textastreng fyrir þýðingu",
+ "language.variable.multiple.help": "Þú getur notað mismunandi gildi eftir því hvaða talningu þú sendir með tungumálabreytunni, sem gerir þér kleift að búa til breytilegar þýðingar, t.d. eintölu og fleirtölu.",
"language.variable.notFound": "Breytan fannst hreint ekki",
"language.variable.value": "Gildi",
@@ -482,7 +487,7 @@
"license.status.missing.info": "Ekkert gilt skráningarleyfi",
"license.status.missing.label": "Vinsamlegast virkjaðu leyfið þitt",
"license.status.unknown.info": "Staða leyfis fyrir hugbúnaðinn er óþekkt",
- "license.status.unknown.label": "Unknown",
+ "license.status.unknown.label": "Óþekkt",
"license.manage": "Sýslaðu með leyfin þín",
"license.purchased": "Verslað",
"license.success": "Þakka þér fyrir að velja Kirby",
@@ -604,7 +609,7 @@
"page.status.unlisted.description": "Síðan er útgefin en þó ólistuð.",
"pages": "Síður",
- "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
+ "pages.delete.confirm.selected": "Viltu virkilega eyða völdum síðum? Hér verður ekki aftur snúið.",
"pages.empty": "Engar síður enn",
"pages.status.draft": "Uppköst",
"pages.status.listed": "Útgefnar og listaðar",
@@ -679,9 +684,9 @@
"system.issues.git": ".git mappan virðist vera berskjölduð",
"system.issues.https": "Við mælum harðlega með því að þú notir HTTPS fyrir öll þín vefsvæði",
"system.issues.kirby": "Kirby mappan virðist vera berskjölduð",
- "system.issues.local": "The site is running locally with relaxed security checks",
+ "system.issues.local": "Vefsvæðið keyrir staðbundið (e. local) með ákaflega lágum öryggiskröfum.",
"system.issues.site": "Mappa vefsvæðisins virðist vera berskjölduð",
- "system.issues.vue.compiler": "The Vue template compiler is enabled",
+ "system.issues.vue.compiler": "Vue sniðmátsþýðandinn er virkur",
"system.issues.vulnerability.kirby": "Uppsetningin þín gæti verið berskjölduð gagnvart eftirfarandi veikleika: ({ severity } veikleikinn): { description }",
"system.issues.vulnerability.plugin": "Uppsetningin þín gæti verið berskjölduð gagnvart eftirfarandi veikleika í viðbótinni { plugin }: ({ severity } veikleikinn): { description }",
"system.updateStatus": "Uppfærslustaða",
@@ -761,7 +766,7 @@
"user.changeLanguage": "Breyta tungumáli",
"user.changeName": "Endurnefna þennan notanda",
"user.changePassword": "Breyta lykilorð",
- "user.changePassword.current": "Your current password",
+ "user.changePassword.current": "Þitt núverandi lykilorð",
"user.changePassword.new": "Nýtt lykilorð",
"user.changePassword.new.confirm": "Staðfestu nýtt lykilorð…",
"user.changeRole": "Breyta hlutverki",
diff --git a/public/kirby/i18n/translations/it.json b/public/kirby/i18n/translations/it.json
index 9e1c235..59bb729 100644
--- a/public/kirby/i18n/translations/it.json
+++ b/public/kirby/i18n/translations/it.json
@@ -20,9 +20,9 @@
"coordinates": "Coordinates",
"copy": "Copia",
"copy.all": "Copia tutto",
- "copy.success": "{count} copied!",
+ "copy.success": "Copiato",
"copy.success.multiple": "{count} copied!",
- "copy.url": "Copy URL",
+ "copy.url": "Copia URL",
"create": "Crea",
"custom": "Custom",
@@ -92,23 +92,23 @@
"error.cache.type.invalid": "Tipo di cache \"{type}\" non valido",
- "error.content.lock.delete": "The version is locked and cannot be deleted",
- "error.content.lock.move": "The source version is locked and cannot be moved",
- "error.content.lock.publish": "This version is already published",
- "error.content.lock.replace": "The version is locked and cannot be replaced",
- "error.content.lock.update": "The version is locked and cannot be updated",
+ "error.content.lock.delete": "La versione è bloccata e non può essere eliminata",
+ "error.content.lock.move": "La versione di origine è bloccata e non può essere spostata",
+ "error.content.lock.publish": "Questa versione è già pubblica",
+ "error.content.lock.replace": "La versione è bloccata e non può essere rimpiazzata",
+ "error.content.lock.update": "La versione è bloccata e non può essere aggiornata",
- "error.entries.max.plural": "You must not add more than {max} entries",
- "error.entries.max.singular": "You must not add more than one entry",
- "error.entries.min.plural": "You must add at least {min} entries",
- "error.entries.min.singular": "You must add at least one entry",
- "error.entries.supports": "\"{type}\" field type is not supported for the entries field",
+ "error.entries.max.plural": "Non puoi aggiungere più di {max} voci",
+ "error.entries.max.singular": "Non puoi aggiungere più di una voce ",
+ "error.entries.min.plural": "Devi aggiungere almeno {min} voci",
+ "error.entries.min.singular": "Devi aggiungere almeno una voce",
+ "error.entries.supports": "Il tipo di campo \"{type}\" non è supportato per il campo \"entries\"",
"error.entries.validation": "C'è un errore nel campo \"{field}\" nella riga {index}",
"error.email.preset.notFound": "Non è stato possibile trovare il preset email \"{name}\"",
"error.field.converter.invalid": "Convertitore \"{converter}\" non valido",
- "error.field.link.options": "Invalid options: {options}",
+ "error.field.link.options": "Opzioni non valide: {options}",
"error.field.type.missing": "Campo \"{ name }\": il tipo di campo \"{ type }\" non esiste",
"error.file.changeName.empty": "Il nome non dev'essere vuoto",
@@ -116,7 +116,7 @@
"error.file.changeTemplate.invalid": "The template for the file \"{id}\" cannot be changed to \"{template}\" (valid: \"{blueprints}\")",
"error.file.changeTemplate.permission": "You are not allowed to change the template for the file \"{id}\"",
- "error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
+ "error.file.delete.multiple": "Non tutti i file possono essere eliminati. Prova a rimuovere i file uno per uno per vedere l’errore specifico che ne impedisce l’eliminazione.",
"error.file.duplicate": "Un file con il nome \"{filename}\" esiste già",
"error.file.extension.forbidden": "L'estensione \"{extension}\" non è consentita",
"error.file.extension.invalid": "Estensione non valida: {extension}",
@@ -135,7 +135,7 @@
"error.file.name.missing": "Il nome del file non può essere vuoto",
"error.file.notFound": "Il file non \u00e8 stato trovato",
"error.file.orientation": "L'imaggine dev'essere orientata in \"{orientation}\"",
- "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"",
+ "error.file.sort.permission": "Non ti è permesso riordinare \"{filename}\"",
"error.file.type.forbidden": "Non ti è permesso caricare file {type}",
"error.file.type.invalid": "Tipo di file non valido: {type}",
"error.file.undefined": "Il file non \u00e8 stato trovato",
@@ -179,7 +179,7 @@
"error.page.delete": "La pagina \"{slug}\" non può essere eliminata",
"error.page.delete.confirm": "Inserisci il titolo della pagina per confermare",
"error.page.delete.hasChildren": "La pagina ha sottopagine e non può essere eliminata",
- "error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
+ "error.page.delete.multiple": "Non è stato possibile eliminare tutte le pagine. Prova ad eliminare le pagine restanti una ad una per vedere l'errore specifico che ne impedisce l'eliminazione. ",
"error.page.delete.permission": "Non ti è permesso eliminare \"{slug}\"",
"error.page.draft.duplicate": "Una bozza di pagina con l'URL \"{slug}\" esiste già",
"error.page.duplicate": "Una pagina con l'URL \"{slug}\" esiste già",
@@ -187,7 +187,7 @@
"error.page.move.ancestor": "The page cannot be moved into itself",
"error.page.move.directory": "The page directory cannot be moved",
"error.page.move.duplicate": "A sub page with the URL appendix \"{slug}\" already exists",
- "error.page.move.noSections": "The page \"{parent}\" cannot be a parent of any page because it lacks any pages sections in its blueprint",
+ "error.page.move.noSections": "La pagina \"{parent}\" non può contenere sottopagine perché il suo \"blueprint\" non definisce alcuna sezione di tipo \"pages\". ",
"error.page.move.notFound": "The moved page could not be found",
"error.page.move.permission": "You are not allowed to move \"{slug}\"",
"error.page.move.template": "The \"{template}\" template is not accepted as a subpage of \"{parent}\"",
@@ -317,9 +317,9 @@
"field.blocks.heading.name": "Titolo",
"field.blocks.heading.text": "Testo",
"field.blocks.heading.placeholder": "Titolo …",
- "field.blocks.figure.back.plain": "Plain",
- "field.blocks.figure.back.pattern.light": "Pattern (light)",
- "field.blocks.figure.back.pattern.dark": "Pattern (dark)",
+ "field.blocks.figure.back.plain": "Semplice",
+ "field.blocks.figure.back.pattern.light": "Pattern (chiaro)",
+ "field.blocks.figure.back.pattern.dark": "Pattern (scuro)",
"field.blocks.image.alt": "Testo alternativo",
"field.blocks.image.caption": "Didascalia",
"field.blocks.image.crop": "Ritaglio",
@@ -357,10 +357,10 @@
"field.blocks.video.url.placeholder": "https://youtube.com/?v=",
"field.entries.delete.confirm.all": "Vuoi davvero cancellare tutte le voci?",
- "field.entries.empty": "Non ci sono ancora elementi.",
+ "field.entries.empty": "Non contiene ancora voci",
"field.files.empty": "Nessun file selezionato",
- "field.files.empty.single": "No file selected yet",
+ "field.files.empty.single": "Nessun file selezionato",
"field.layout.change": "Change layout",
"field.layout.delete": "Elimina layout",
@@ -372,14 +372,14 @@
"field.object.empty": "Ancora nessuna informazione",
"field.pages.empty": "Nessuna pagina selezionata",
- "field.pages.empty.single": "No page selected yet",
+ "field.pages.empty.single": "Nessuna pagina selezionata",
"field.structure.delete.confirm": "Vuoi veramente eliminare questo elemento?",
"field.structure.delete.confirm.all": "Vuoi davvero cancellare tutte le voci?",
- "field.structure.empty": "Non ci sono ancora elementi.",
+ "field.structure.empty": "Non contiene ancora voci",
"field.users.empty": "Nessun utente selezionato",
- "field.users.empty.single": "No user selected yet",
+ "field.users.empty.single": "Nessun utente selezionato",
"fields.empty": "No fields yet",
@@ -394,17 +394,17 @@
"file.sort": "Cambia posizione",
"files": "Files",
- "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
+ "files.delete.confirm.selected": "Vuoi davvero eliminare i file selezionati? Questa azione è irreversibile.",
"files.empty": "Nessun file caricato",
"filter": "Filter",
- "form.discard": "Discard changes",
- "form.discard.confirm": "Do you really want to discard all your changes?",
- "form.locked": "This content is disabled for you as it is currently edited by another user",
- "form.unsaved": "The current changes have not yet been saved",
- "form.preview": "Preview changes",
- "form.preview.draft": "Preview draft",
+ "form.discard": "Annulla modifiche",
+ "form.discard.confirm": "Vuoi davvero scartare tutte le tue modifiche?",
+ "form.locked": "Questo contenuto è disabilitato perché è attualmente modificato da un altro utente",
+ "form.unsaved": "Le modifiche non sono ancora state salvate",
+ "form.preview": "Anteprima modifiche",
+ "form.preview.draft": "Anteprima della bozza",
"hide": "Nascondi",
"hour": "Ora",
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
@@ -481,8 +486,8 @@
"license.status.missing.bubble": "Pronto a lanciare il tuo sito?",
"license.status.missing.info": "Nessuna licenza valida",
"license.status.missing.label": "Attiva la tua licenza ora",
- "license.status.unknown.info": "The license status is unknown",
- "license.status.unknown.label": "Unknown",
+ "license.status.unknown.info": "Lo stato della licenza è sconosciuto",
+ "license.status.unknown.label": "Sconosciuto",
"license.manage": "Gestisci le tue licenze",
"license.purchased": "Acquistata",
"license.success": "Ti ringraziamo per aver supportato Kirby",
@@ -495,9 +500,9 @@
"lock.unsaved": "Modifiche non salvate",
"lock.unsaved.empty": "Non ci sono altre modifiche non salvate",
- "lock.unsaved.files": "Unsaved files",
- "lock.unsaved.pages": "Unsaved pages",
- "lock.unsaved.users": "Unsaved accounts",
+ "lock.unsaved.files": "File non salvati",
+ "lock.unsaved.pages": "Pagine non salvate",
+ "lock.unsaved.users": "Account non salvati",
"lock.isLocked": "Unsaved changes by {email}",
"lock.unlock": "Sblocca",
"lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}",
@@ -604,7 +609,7 @@
"page.status.unlisted.description": "La pagina è accessibile soltanto tramite URL",
"pages": "Pagine",
- "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
+ "pages.delete.confirm.selected": "Vuoi davvero eliminare le pagine selezionate? Questa azione è irreversibile.",
"pages.empty": "Nessuna pagina",
"pages.status.draft": "Bozza",
"pages.status.listed": "Pubblicato",
@@ -622,7 +627,7 @@
"prev": "Precedente",
"preview": "Anteprima",
- "publish": "Publish",
+ "publish": "Pubblica",
"published": "Pubblicato",
"remove": "Rimuovi",
@@ -644,9 +649,9 @@
"role.nobody.title": "Nessuno",
"save": "Salva",
- "saved": "Saved",
+ "saved": "Salvato",
"search": "Cerca",
- "searching": "Searching",
+ "searching": "Ricerca in corso",
"search.min": "Inserisci almeno {min} caratteri per la ricerca",
"search.all": "Mostra tutti i {count} risultati",
"search.results.none": "Nessun risultato",
@@ -679,9 +684,9 @@
"system.issues.git": "La cartella .git sembra essere esposta",
"system.issues.https": "Raccomandiamo l'utilizzo di HTTPS per tutti i siti",
"system.issues.kirby": "La cartella kirby sembra essere esposta",
- "system.issues.local": "The site is running locally with relaxed security checks",
+ "system.issues.local": "Il sito è in esecuzione in locale con controlli di sicurezza meno severi",
"system.issues.site": "La cartella site sembra essere esposta",
- "system.issues.vue.compiler": "The Vue template compiler is enabled",
+ "system.issues.vue.compiler": "Il compilatore di template di Vue è abilitato",
"system.issues.vulnerability.kirby": "La tua installazione potrebbe essere colpita dalla seguente vulnerabilità ({ severity } gravità): { description }",
"system.issues.vulnerability.plugin": "La tua installazione potrebbe essere colpita dalla seguente vulnerabilità nel plugin { plugin } ({ severity } gravità): { description }",
"system.updateStatus": "Aggiorna lo stato",
@@ -698,10 +703,10 @@
"tel.placeholder": "+49123456789",
"template": "Template",
- "theme": "Theme",
- "theme.light": "Lights on",
- "theme.dark": "Lights off",
- "theme.automatic": "Match system default",
+ "theme": "Tema",
+ "theme.light": "Luci accese",
+ "theme.dark": "Luci spente",
+ "theme.automatic": "Usa impostazione predefinita del sistema",
"title": "Titolo",
"today": "Oggi",
@@ -753,7 +758,7 @@
"upload.progress": "Caricamento...",
"url": "URL",
- "url.placeholder": "https://esempio.com",
+ "url.placeholder": "https://example.com",
"user": "Utente",
"user.blueprint": "Puoi definire ulteriori sezioni e campi del form aggiuntivi per questo ruolo in /site/blueprints/users/{blueprint}.yml",
@@ -761,7 +766,7 @@
"user.changeLanguage": "Cambia lingua",
"user.changeName": "Rinomina questo utente",
"user.changePassword": "Cambia password",
- "user.changePassword.current": "Your current password",
+ "user.changePassword.current": "La password attuale",
"user.changePassword.new": "Nuova password",
"user.changePassword.new.confirm": "Conferma la nuova password...",
"user.changeRole": "Cambia ruolo",
@@ -773,13 +778,13 @@
"users": "Utenti",
"version": "Versione di Kirby",
- "version.changes": "Changed version",
- "version.compare": "Compare versions",
+ "version.changes": "Versione modificata",
+ "version.compare": "Confronta le versioni",
"version.current": "Versione corrente",
"version.latest": "Ultima versione",
"versionInformation": "Informazioni sulla versione",
- "view": "View",
+ "view": "Visualizza",
"view.account": "Il tuo account",
"view.installation": "Installazione",
"view.languages": "Lingue",
diff --git a/public/kirby/i18n/translations/ko.json b/public/kirby/i18n/translations/ko.json
index 41f85a7..e29a4a6 100644
--- a/public/kirby/i18n/translations/ko.json
+++ b/public/kirby/i18n/translations/ko.json
@@ -449,7 +449,12 @@
"language.variables.empty": "번역이 없습니다.",
"language.variable.delete.confirm": "변수({key})를 삭제할까요?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "키",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "변수를 찾을 수 없습니다.",
"language.variable.value": "값",
diff --git a/public/kirby/i18n/translations/lt.json b/public/kirby/i18n/translations/lt.json
index a6706c4..fd04cdf 100644
--- a/public/kirby/i18n/translations/lt.json
+++ b/public/kirby/i18n/translations/lt.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/nb.json b/public/kirby/i18n/translations/nb.json
index 236e105..acd8d66 100644
--- a/public/kirby/i18n/translations/nb.json
+++ b/public/kirby/i18n/translations/nb.json
@@ -449,7 +449,12 @@
"language.variables.empty": "Ingen oversettelse enda",
"language.variable.delete.confirm": "Ønsker du virkelig å slette variablen for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Variablen kan ikke bli funnet",
"language.variable.value": "Verdi",
diff --git a/public/kirby/i18n/translations/nl.json b/public/kirby/i18n/translations/nl.json
index 899c397..1e04eb5 100644
--- a/public/kirby/i18n/translations/nl.json
+++ b/public/kirby/i18n/translations/nl.json
@@ -20,9 +20,9 @@
"coordinates": "Coördinaten ",
"copy": "Kopiëren",
"copy.all": "Kopieer alles",
- "copy.success": "{count} gekopieerd!",
+ "copy.success": "Gekopieerd",
"copy.success.multiple": "{count} gekopieerd!",
- "copy.url": "Copy URL",
+ "copy.url": "Kopieer URL",
"create": "Aanmaken",
"custom": "Custom",
@@ -92,23 +92,23 @@
"error.cache.type.invalid": "Ongeldig cache type \"{type}\"",
- "error.content.lock.delete": "The version is locked and cannot be deleted",
- "error.content.lock.move": "The source version is locked and cannot be moved",
- "error.content.lock.publish": "This version is already published",
- "error.content.lock.replace": "The version is locked and cannot be replaced",
- "error.content.lock.update": "The version is locked and cannot be updated",
+ "error.content.lock.delete": "Deze versie is vergrendeld en kan niet worden verwijderd",
+ "error.content.lock.move": "De bronversie is vergrendeld en kan niet worden verplaatst",
+ "error.content.lock.publish": "Deze versie is al gepubliceerd",
+ "error.content.lock.replace": "Deze versie is vergrendeld en kan niet worden vervangen",
+ "error.content.lock.update": "Deze versie is vergrendeld en kan niet worden geüpdatet",
- "error.entries.max.plural": "You must not add more than {max} entries",
- "error.entries.max.singular": "You must not add more than one entry",
- "error.entries.min.plural": "You must add at least {min} entries",
- "error.entries.min.singular": "You must add at least one entry",
- "error.entries.supports": "\"{type}\" field type is not supported for the entries field",
+ "error.entries.max.plural": "Je kunt niet meer dan {max} items toevoegen",
+ "error.entries.max.singular": "Je kunt niet meer dan één item toevoegen",
+ "error.entries.min.plural": "Je moet ten minste {min} items toevoegen",
+ "error.entries.min.singular": "Je moet ten minste één item toevoegen",
+ "error.entries.supports": "\"{type}\" veld type is niet ondersteund in het items veld",
"error.entries.validation": "Er is een fout opgetreden in veld \"{field}\" in rij {index}",
"error.email.preset.notFound": "De e-mailvoorinstelling \"{name}\" kan niet worden gevonden",
"error.field.converter.invalid": "Ongeldige converter \"{converter}\"",
- "error.field.link.options": "Invalid options: {options}",
+ "error.field.link.options": "Ongeldige opties: {options}",
"error.field.type.missing": "Veld \"{ name }\": Het veldtype \"{ type }\" bestaat niet",
"error.file.changeName.empty": "De naam mag niet leeg zijn",
@@ -116,7 +116,7 @@
"error.file.changeTemplate.invalid": "Het template voor het bestand \"{id}\" kan niet worden gewijzigd in \"{template}\" (geldig: \"{blueprints}\")",
"error.file.changeTemplate.permission": "Je hebt geen rechten om het template te wijzigen voor bestand \"{id}\"",
- "error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
+ "error.file.delete.multiple": "Niet alle bestanden kunnen worden verwijderd. Probeer de overgebleven bestanden individueel te verwijderen om het probleem te achterhalen.",
"error.file.duplicate": "Er bestaat al een bestand met de naam \"{filename}\"",
"error.file.extension.forbidden": "Bestandsextensie \"{extension}\" is niet toegestaan",
"error.file.extension.invalid": "Ongeldige extensie: {extension}",
@@ -135,7 +135,7 @@
"error.file.name.missing": "De bestandsnaam mag niet leeg zijn",
"error.file.notFound": "Het bestand kan niet worden gevonden",
"error.file.orientation": "De oriëntatie van de afbeelding moet \"{orientation}\" zijn",
- "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"",
+ "error.file.sort.permission": "Je hebt geen rechten om de sortering te wijzigen van \"{filename}\"",
"error.file.type.forbidden": "Je hebt geen rechten om {type} bestanden up te loaden",
"error.file.type.invalid": "Ongeldig bestands type: {type}",
"error.file.undefined": "Het bestand kan niet worden gevonden",
@@ -179,7 +179,7 @@
"error.page.delete": "De pagina \"{slug}\" kan niet worden verwijderd",
"error.page.delete.confirm": "Voer de paginatitel in om te bevestigen",
"error.page.delete.hasChildren": "Deze pagina heeft subpagina's en kan niet worden verwijderd",
- "error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
+ "error.page.delete.multiple": "Niet alle pagina's kunnen worden verwijderd. Probeer de overgebleven pagina's individueel te verwijderen om het probleem te achterhalen.",
"error.page.delete.permission": "Je hebt geen rechten om \"{slug}\" te verwijderen",
"error.page.draft.duplicate": "Er bestaat al een conceptpagina met de URL-appendix \"{slug}\"",
"error.page.duplicate": "Er bestaat al een pagina met de URL-appendix \"{slug}\"",
@@ -187,7 +187,7 @@
"error.page.move.ancestor": "De pagina kan niet in zichzelf worden verplaatst",
"error.page.move.directory": "De page map kan niet worden verplaatst",
"error.page.move.duplicate": "Er bestaat al een subpagina met de URL-appendix \"{slug}\"",
- "error.page.move.noSections": "The page \"{parent}\" cannot be a parent of any page because it lacks any pages sections in its blueprint",
+ "error.page.move.noSections": "De pagina \"{parent}\" kan geen bovenliggende pagina zijn omdat het een pages sectie mist in de blueprint.",
"error.page.move.notFound": "De verplaatste pagina kan niet gevonden worden",
"error.page.move.permission": "Je hebt geen rechten om \"{slug}\" te verplaatsen",
"error.page.move.template": "De \"{template}\" template is niet toegestaan als een subpagina van \"{parent}\"",
@@ -317,9 +317,9 @@
"field.blocks.heading.name": "Koptekst",
"field.blocks.heading.text": "Tekst",
"field.blocks.heading.placeholder": "Koptekst ...",
- "field.blocks.figure.back.plain": "Plain",
- "field.blocks.figure.back.pattern.light": "Pattern (light)",
- "field.blocks.figure.back.pattern.dark": "Pattern (dark)",
+ "field.blocks.figure.back.plain": "Neutraal",
+ "field.blocks.figure.back.pattern.light": "Patroon (licht)",
+ "field.blocks.figure.back.pattern.dark": "Patroon (donker)",
"field.blocks.image.alt": "Alternatieve tekst",
"field.blocks.image.caption": "Beschrijving",
"field.blocks.image.crop": "Uitsnede",
@@ -360,7 +360,7 @@
"field.entries.empty": "Nog geen items",
"field.files.empty": "Nog geen bestanden geselecteerd",
- "field.files.empty.single": "No file selected yet",
+ "field.files.empty.single": "Nog geen bestand geselecteerd",
"field.layout.change": "Verander layout",
"field.layout.delete": "Verwijder indeling",
@@ -372,14 +372,14 @@
"field.object.empty": "Nog geen informatie",
"field.pages.empty": "Nog geen pagina's geselecteerd",
- "field.pages.empty.single": "No page selected yet",
+ "field.pages.empty.single": "Nog geen pagina geselecteerd",
"field.structure.delete.confirm": "Wil je deze rij verwijderen?",
"field.structure.delete.confirm.all": "Weet je zeker dat je alle items wil verwijderen?",
"field.structure.empty": "Nog geen items",
"field.users.empty": "Nog geen gebruikers geselecteerd",
- "field.users.empty.single": "No user selected yet",
+ "field.users.empty.single": "Nog geen gebruiker geselecteerd",
"fields.empty": "Nog geen velden",
@@ -394,17 +394,17 @@
"file.sort": "Verander positie",
"files": "Bestanden",
- "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
+ "files.delete.confirm.selected": "Weet je zeker dat je de geselecteerde bestanden wil verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"files.empty": "Nog geen bestanden",
"filter": "Filter",
- "form.discard": "Discard changes",
- "form.discard.confirm": "Do you really want to discard all your changes?",
- "form.locked": "This content is disabled for you as it is currently edited by another user",
- "form.unsaved": "The current changes have not yet been saved",
- "form.preview": "Preview changes",
- "form.preview.draft": "Preview draft",
+ "form.discard": "Wijzigingen annuleren",
+ "form.discard.confirm": "Weet je zeker dat je alle wijzigingen ongedaan wil maken?",
+ "form.locked": "Deze inhoud is uitgeschakeld voor jou omdat deze momenteel door een andere gebruiker wordt bewerkt",
+ "form.unsaved": "De huidige wijzigingen zijn nog niet opgeslagen",
+ "form.preview": "Bekijk wijzigingen",
+ "form.preview.draft": "Bekijk concept",
"hide": "Verberg",
"hour": "Uur",
@@ -449,7 +449,12 @@
"language.variables.empty": "Nog geen vertalingen",
"language.variable.delete.confirm": "Weet je zeker dat je de variabele voor {key} wil verwijderen?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "De variabele kan niet gevonden worden",
"language.variable.value": "Waarde",
@@ -481,8 +486,8 @@
"license.status.missing.bubble": "Klaar om je website te lanceren?",
"license.status.missing.info": "Geen geldige licentie",
"license.status.missing.label": "Activeer je licentie",
- "license.status.unknown.info": "The license status is unknown",
- "license.status.unknown.label": "Unknown",
+ "license.status.unknown.info": "De licentiestatus is onbekend",
+ "license.status.unknown.label": "Onbekend",
"license.manage": "Beheer je licenties",
"license.purchased": "Gekocht",
"license.success": "Bedankt dat je Kirby ondersteunt",
@@ -495,9 +500,9 @@
"lock.unsaved": "Niet opgeslagen wijzigingen",
"lock.unsaved.empty": "Er zijn geen niet opgeslagen wijzigingen meer",
- "lock.unsaved.files": "Unsaved files",
- "lock.unsaved.pages": "Unsaved pages",
- "lock.unsaved.users": "Unsaved accounts",
+ "lock.unsaved.files": "Niet opgeslagen bestanden",
+ "lock.unsaved.pages": "Niet opgeslagen pagina's",
+ "lock.unsaved.users": "Niet opgeslagen accounts",
"lock.isLocked": "Niet opgeslagen wijzigingen door {email}",
"lock.unlock": "Ontgrendelen",
"lock.unlock.submit": "Niet-opgeslagen wijzigingen ontgrendelen en overschrijven met {email}",
@@ -589,7 +594,7 @@
"page.create": "Maak aan als {status}",
"page.delete.confirm": "Weet je zeker dat je pagina {title} wilt verwijderen?",
"page.delete.confirm.subpages": "Deze pagina heeft subpagina's. 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Klucz",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Nie udało się odnaleźć zmiennej",
"language.variable.value": "Wartość",
@@ -481,7 +486,7 @@
"license.status.missing.bubble": "Gotowy/-a do uruchomienia strony?",
"license.status.missing.info": "Brak ważnej licencji",
"license.status.missing.label": "Aktywuj swoją licencję",
- "license.status.unknown.info": "The license status is unknown",
+ "license.status.unknown.info": "Status licencji jest nieznany",
"license.status.unknown.label": "Unknown",
"license.manage": "Zarządzaj swoimi licencjami",
"license.purchased": "Zakupiona",
@@ -495,9 +500,9 @@
"lock.unsaved": "Niezapisane zmiany",
"lock.unsaved.empty": "Nie ma już żadnych niezapisanych zmian",
- "lock.unsaved.files": "Unsaved files",
- "lock.unsaved.pages": "Unsaved pages",
- "lock.unsaved.users": "Unsaved accounts",
+ "lock.unsaved.files": "Niezapisane pliki",
+ "lock.unsaved.pages": "Niezapisane strony",
+ "lock.unsaved.users": "Niezapisane konta",
"lock.isLocked": "Niezapisane zmiany autorstwa {email}",
"lock.unlock": "Odblokuj",
"lock.unlock.submit": "Odblokuj i nadpisz niezapisane zmiany autorstwa {email}",
@@ -622,7 +627,7 @@
"prev": "Poprzednie",
"preview": "Podgląd",
- "publish": "Publish",
+ "publish": "Opublikuj",
"published": "Opublikowane",
"remove": "Usuń",
@@ -644,7 +649,7 @@
"role.nobody.title": "Nikt",
"save": "Zapisz",
- "saved": "Saved",
+ "saved": "Zapisane",
"search": "Szukaj",
"searching": "Searching",
"search.min": "Aby wyszukać, wprowadź co najmniej {min} znaków",
@@ -698,10 +703,10 @@
"tel.placeholder": "+48123456789",
"template": "Szablon",
- "theme": "Theme",
- "theme.light": "Lights on",
- "theme.dark": "Lights off",
- "theme.automatic": "Match system default",
+ "theme": "Wygląd",
+ "theme.light": "Jasny",
+ "theme.dark": "Ciemny",
+ "theme.automatic": "Zgodny z ustawieniami systemu",
"title": "Tytuł",
"today": "Dzisiaj",
@@ -761,7 +766,7 @@
"user.changeLanguage": "Zmień język",
"user.changeName": "Zmień nazwę tego użytkownika",
"user.changePassword": "Zmień hasło",
- "user.changePassword.current": "Your current password",
+ "user.changePassword.current": "Twoje aktualne hasło",
"user.changePassword.new": "Nowe hasło",
"user.changePassword.new.confirm": "Potwierdź nowe hasło…",
"user.changeRole": "Zmień rolę",
@@ -773,13 +778,13 @@
"users": "Użytkownicy",
"version": "Wersja",
- "version.changes": "Changed version",
- "version.compare": "Compare versions",
+ "version.changes": "Zmieniona wersja",
+ "version.compare": "Porównaj wersje",
"version.current": "Obecna wersja",
"version.latest": "Ostatnia wersja",
"versionInformation": "Informacje o wersji",
- "view": "View",
+ "view": "Pokaż",
"view.account": "Twoje konto",
"view.installation": "Instalacja",
"view.languages": "Języki",
diff --git a/public/kirby/i18n/translations/pt_BR.json b/public/kirby/i18n/translations/pt_BR.json
index aba221d..36fae5f 100644
--- a/public/kirby/i18n/translations/pt_BR.json
+++ b/public/kirby/i18n/translations/pt_BR.json
@@ -449,7 +449,12 @@
"language.variables.empty": "Nenhuma tradução ainda",
"language.variable.delete.confirm": "Tem a certeza que pretende eliminar a variável {key}?",
+ "language.variable.entries": "Valores",
+ "language.variable.entries.help": "Cada string será usada pela ordem correspondente à contagem. Isto é, três strings serão usadas, respetivamente, para as contagens 0, 1 e 2 ou mais. Utilize o marcador {count} para inserir o valor da contagem.",
"language.variable.key": "Chave",
+ "language.variable.multiple": "Contável?",
+ "language.variable.multiple.text": "Utilize strings de tradução diferentes",
+ "language.variable.multiple.help": "É possível utilizar valores diferentes consoante a contagem associada à variável de idioma, permitindo assim a criação de traduções dinâmicas, como, por exemplo, para formas singulares e plurais.",
"language.variable.notFound": "A variável não foi encontrada",
"language.variable.value": "Valor",
diff --git a/public/kirby/i18n/translations/pt_PT.json b/public/kirby/i18n/translations/pt_PT.json
index a9fc33b..2e3a8c4 100644
--- a/public/kirby/i18n/translations/pt_PT.json
+++ b/public/kirby/i18n/translations/pt_PT.json
@@ -20,7 +20,7 @@
"coordinates": "Coordenadas",
"copy": "Copiar",
"copy.all": "Copiar todos",
- "copy.success": "{count} copiados!",
+ "copy.success": "Copiado",
"copy.success.multiple": "{count} copiados!",
"copy.url": "Copiar URL",
"create": "Criar",
@@ -38,7 +38,7 @@
"days.tue": "Ter",
"days.wed": "Qua",
- "debugging": "Depuração ",
+ "debugging": "Debugging ",
"delete": "Eliminar",
"delete.all": "Eliminar todos",
@@ -449,7 +449,12 @@
"language.variables.empty": "Nenhuma tradução ainda",
"language.variable.delete.confirm": "Tem a certeza que pretende eliminar a variável {key}?",
+ "language.variable.entries": "Valores",
+ "language.variable.entries.help": "Cada string será utilizada pela ordem correspondente à contagem. Isto é, três strings serão utilizadas, respetivamente, para as contagens 0, 1 e 2 ou mais. Utilize o marcador {count} para inserir o valor real da contagem.",
"language.variable.key": "Chave",
+ "language.variable.multiple": "Contável?",
+ "language.variable.multiple.text": "Utilize strings de tradução diferentes",
+ "language.variable.multiple.help": "É possível utilizar valores diferentes consoante a contagem associada à variável de idioma, permitindo assim a criação de traduções dinâmicas. Isto é, para formas singulares e plurais.",
"language.variable.notFound": "Não foi possível encontrar a variável",
"language.variable.value": "Valor",
diff --git a/public/kirby/i18n/translations/ro.json b/public/kirby/i18n/translations/ro.json
index 120852a..59cb6e9 100644
--- a/public/kirby/i18n/translations/ro.json
+++ b/public/kirby/i18n/translations/ro.json
@@ -449,7 +449,12 @@
"language.variables.empty": "Nicio traducere deocamdată",
"language.variable.delete.confirm": "Chiar vrei să ștergi variabila pentru {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Cheie",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Variabila nu a fost găsită",
"language.variable.value": "Valoare",
diff --git a/public/kirby/i18n/translations/ru.json b/public/kirby/i18n/translations/ru.json
index dc10016..b943793 100644
--- a/public/kirby/i18n/translations/ru.json
+++ b/public/kirby/i18n/translations/ru.json
@@ -449,7 +449,12 @@
"language.variables.empty": "Пока нет переводов",
"language.variable.delete.confirm": "Вы действительно хотите удалить переменную для {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Ключ",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Переменная не найдена",
"language.variable.value": "Значение",
diff --git a/public/kirby/i18n/translations/sk.json b/public/kirby/i18n/translations/sk.json
index 5b341aa..1650067 100644
--- a/public/kirby/i18n/translations/sk.json
+++ b/public/kirby/i18n/translations/sk.json
@@ -449,7 +449,12 @@
"language.variables.empty": "No translations yet",
"language.variable.delete.confirm": "Do you really want to delete the variable for {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Key",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "The variable could not be found",
"language.variable.value": "Value",
diff --git a/public/kirby/i18n/translations/sr@latin.json b/public/kirby/i18n/translations/sr@latin.json
new file mode 100644
index 0000000..8f27407
--- /dev/null
+++ b/public/kirby/i18n/translations/sr@latin.json
@@ -0,0 +1,799 @@
+{
+ "account.changeName": "Promenite vaše ime",
+ "account.delete": "Izbrišite vaš nalog",
+ "account.delete.confirm": "Da li zaista želite da izbrišete vaš nalog? Bićete odjavljeni odmah, i vaš nalog ne može biti povraćen.",
+
+ "activate": "Aktivirati",
+ "add": "Dodaj",
+ "alpha": "Alfa",
+ "author": "Autor",
+ "avatar": "Profilna slika",
+ "back": "Nazad",
+ "cancel": "Otkažite",
+ "change": "Promenite",
+ "close": "Zatvorite",
+ "changes": "Promene",
+ "confirm": "Ok",
+ "collapse": "Skupi",
+ "collapse.all": "Skupi sve",
+ "color": "Boja",
+ "coordinates": "Koordinate",
+ "copy": "Kopiraj",
+ "copy.all": "Kopiraj sve",
+ "copy.success": "Copied",
+ "copy.success.multiple": "{count} kopirano!",
+ "copy.url": "Copy URL",
+ "create": "Kreiraj",
+ "custom": "Običaj",
+
+ "date": "Datum",
+ "date.select": "Izaberite datum",
+
+ "day": "Dan",
+ "days.fri": "Pet",
+ "days.mon": "Pon",
+ "days.sat": "Sub",
+ "days.sun": "Ned",
+ "days.thu": "Čet",
+ "days.tue": "Uto",
+ "days.wed": "Sre",
+
+ "debugging": "Otklanjanje grešaka",
+
+ "delete": "Obriši",
+ "delete.all": "Obriši sve",
+
+ "dialog.fields.empty": "Ovaj dijalog nema polja",
+ "dialog.files.empty": "Nema fajlova koji se mogu izabrati",
+ "dialog.pages.empty": "Nema stranica koje se mogu izabrati",
+ "dialog.text.empty": "Ovaj dijalog ne definiše nikakav tekst",
+ "dialog.users.empty": "Nema korisnika koji se mogu izabrati",
+
+ "dimensions": "Dimenzije",
+ "disable": "Onemogućiti",
+ "disabled": "Onemogućeno",
+ "discard": "Odbaci",
+
+ "drawer.fields.empty": "Ova fioka nema polja",
+
+ "domain": "Domen",
+ "download": "Preuzmi",
+ "duplicate": "Kopiraj",
+
+ "edit": "Uredi",
+
+ "email": "Email",
+ "email.placeholder": "mail@example.com",
+
+ "enter": "Uneti",
+ "entries": "Unosi",
+ "entry": "Unos",
+
+ "environment": "Okruženje",
+
+ "error": "Greška",
+ "error.access.code": "Neispravan kod",
+ "error.access.login": "Neispravna prijava",
+ "error.access.panel": "Niste ovlašćeni da uđete u administrativni panel",
+ "error.access.view": "Niste ovlašćeni da pristupite ovom delu panela",
+
+ "error.avatar.create.fail": "Profilna slika nije mogla biti otpremljena",
+ "error.avatar.delete.fail": "Profilna slika nije mogla biti obrisana",
+ "error.avatar.dimensions.invalid": "Molimo neka visina i širina Vaše profilne slike budu ispod 3000 piksela",
+ "error.avatar.mime.forbidden": "Profilna slika mora biti JPEG ili PNG fajl",
+
+ "error.blueprint.notFound": "Blueprint \"{name}\" nije mogao biti učitan",
+
+ "error.blocks.max.plural": "Ne smete da dodajete više od {max} blokova",
+ "error.blocks.max.singular": "Ne smete da dodate više od jednog bloka",
+ "error.blocks.min.plural": "Morate dodati najmanje {min} blokova",
+ "error.blocks.min.singular": "Morate dodati najmanje jedan blok",
+ "error.blocks.validation": "Postoji greška u \"{field}\" polju u bloku {index} koji koristi \"{fieldset}\" tip bloka ",
+
+ "error.cache.type.invalid": "Neispravan tip keša \"{type}\"",
+
+ "error.content.lock.delete": "The version is locked and cannot be deleted",
+ "error.content.lock.move": "The source version is locked and cannot be moved",
+ "error.content.lock.publish": "This version is already published",
+ "error.content.lock.replace": "The version is locked and cannot be replaced",
+ "error.content.lock.update": "The version is locked and cannot be updated",
+
+ "error.entries.max.plural": "You must not add more than {max} entries",
+ "error.entries.max.singular": "You must not add more than one entry",
+ "error.entries.min.plural": "You must add at least {min} entries",
+ "error.entries.min.singular": "You must add at least one entry",
+ "error.entries.supports": "\"{type}\" field type is not supported for the entries field",
+ "error.entries.validation": "Postoji greška u redu \"{field}\" na ovom polju {index}",
+
+ "error.email.preset.notFound": "Email preset \"{name}\" nije pronađen",
+
+ "error.field.converter.invalid": "Neispravan converter \"{converter}\"",
+ "error.field.link.options": "Invalid options: {options}",
+ "error.field.type.missing": "Polje \"{ name }\": Tip polja \"{ type }\" ne postoji",
+
+ "error.file.changeName.empty": "Naziv ne sme biti prazan",
+ "error.file.changeName.permission": "Niste ovlašćeni da promenite naziv \"{filename}\"",
+ "error.file.changeTemplate.invalid": "Šablon za datoteku \"{id}\" se ne može promeniti u \"{template}\" (valid: \"{blueprints}\")",
+ "error.file.changeTemplate.permission": "Nije vam dozvoljeno da menjate šablon za datoteku \"{id}\"",
+
+ "error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
+ "error.file.duplicate": "Fajl sa nazivom \"{filename}\" već postoji",
+ "error.file.extension.forbidden": "Extension \"{extension}\" nije dozvoljena",
+ "error.file.extension.invalid": "Nevažeći dodatak: {extension}",
+ "error.file.extension.missing": "Ekstenzije za \"{filename}\" nedostaju",
+ "error.file.maxheight": "Visina slike ne sme biti veća od {height} piksela",
+ "error.file.maxsize": "Datoteka je prevelika",
+ "error.file.maxwidth": "Širina slike ne sme biti veća od {width} piksela",
+ "error.file.mime.differs": "Otpremljeni fajl mora biti istog mime tipa \"{mime}\"",
+ "error.file.mime.forbidden": "Tip medija \"{mime}\" nije dozvoljen",
+ "error.file.mime.invalid": "Neispravan mime tip: {mime}",
+ "error.file.mime.missing": "Tip medija za \"{filename}\" nije bilo moguće detektovati",
+ "error.file.minheight": "Visina slike mora biti najmanje {height} piksela",
+ "error.file.minsize": "Datoteka je premala",
+ "error.file.minwidth": "Širina slike mora biti najmanje {width} piksela",
+ "error.file.name.unique": "Ime datoteke mora biti jedinstveno",
+ "error.file.name.missing": "Ime fajla ne može biti prazno",
+ "error.file.notFound": "Fajl \"{filename}\" nije mogao biti pronadjen",
+ "error.file.orientation": "Orijentacija slike mora biti \"{orientation}\"",
+ "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"",
+ "error.file.type.forbidden": "Niste ovlašćeni da otpremate {type} fajlove",
+ "error.file.type.invalid": "Nevažeći tip datoteke: {type}",
+ "error.file.undefined": "Fajl nije mogao biti pronadjen",
+
+ "error.form.incomplete": "Molimo popravite sve greške u formularu...",
+ "error.form.notSaved": "Formular nije mogao biti sačuvan",
+
+ "error.language.code": "Molimo ukucajte validan kod za jezik",
+ "error.language.create.permission": "You are not allowed to create a language",
+ "error.language.delete.permission": "You are not allowed to delete the language",
+ "error.language.duplicate": "Jezik već postoji",
+ "error.language.name": "Molimo upišite validno ime za jezik",
+ "error.language.notFound": "Jezik nije mogao biti pronađen",
+ "error.language.update.permission": "You are not allowed to update the language",
+
+ "error.layout.validation.block": "Postoji greška u \"{field}\" polju u bloku {blockIndex} koji koristi \"{fieldset}\" tip bloka u rasporedu {layoutIndex}",
+ "error.layout.validation.settings": "Došlo je do greške u {index} podešavanjima ",
+
+ "error.license.domain": "Nedostaje domen za licencu",
+ "error.license.email": "Molimo unesite ispravnu email adresu",
+ "error.license.format": "Molimo vas unesite važeći kod licence",
+ "error.license.verification": "Licenca nije mogla biti verifikovana",
+
+ "error.login.totp.confirm.invalid": "Neispravan kod",
+ "error.login.totp.confirm.missing": "Molimo vas unesite trenutni kod",
+
+ "error.object.validation": "Postoji greška u \"{label}\" polju: {message}",
+
+ "error.offline": "Panel je trenutno van mreže",
+
+ "error.page.changeSlug.permission": "Nije Vam dozvoljeno da promenite URL appendix za \"{slug}\"",
+ "error.page.changeSlug.reserved": "Putanja stranica najvišeg nivoa ne sme da počinje sa \"{path}\"",
+ "error.page.changeStatus.incomplete": "Ova stranica ima greške i ne može biti objavljena",
+ "error.page.changeStatus.permission": "Status ove stranice ne može biti promenjen",
+ "error.page.changeStatus.toDraft.invalid": "Stranica \"{slug}\" ne može biti prebačena u draft",
+ "error.page.changeTemplate.invalid": "Template za stranicu \"{slug}\" ne može biti promenjen",
+ "error.page.changeTemplate.permission": "Nije Vam dozvoljeno da promenite template za \"{slug}\"",
+ "error.page.changeTitle.empty": "Naslov ne može biti prazan",
+ "error.page.changeTitle.permission": "Nije Vam dozvoljeno da pormenite naslov za \"{slug}\"",
+ "error.page.create.permission": "Nije Vam dozvoljeno da kreirate \"{slug}\"",
+ "error.page.delete": "Stranica \"{slug}\" ne može biti obrisana",
+ "error.page.delete.confirm": "Molimo ukucajte naslov stranice da potvrdite",
+ "error.page.delete.hasChildren": "Stranica ima podstranice i ne može biti obrisana",
+ "error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
+ "error.page.delete.permission": "Nemate ovlašćenja da obrišete \"{slug}\"",
+ "error.page.draft.duplicate": "Draft stranice sa URL appendix \"{slug}\" već postoji",
+ "error.page.duplicate": "Stranica sa URL appendix-om \"{slug}\" već postoji",
+ "error.page.duplicate.permission": "Nije vam dozvoljeno da kopirate \"{slug}\"",
+ "error.page.move.ancestor": "Stranica se ne može premestiti u sebe",
+ "error.page.move.directory": "Direktorijum stranice ne može da se premesti",
+ "error.page.move.duplicate": "Podstranica sa dodatkom URL \"{slug}\" već postoji",
+ "error.page.move.noSections": "The page \"{parent}\" cannot be a parent of any page because it lacks any pages sections in its blueprint",
+ "error.page.move.notFound": "Premeštena stranica nije pronađena",
+ "error.page.move.permission": "Nije vam dozvoljeno da se krećete \"{slug}\"",
+ "error.page.move.template": "Šablon \"{template}\" nije prihvaćen kao podstranica \"{parent}\"",
+ "error.page.notFound": "Stranica \"{slug}\" ne može biti pronadjena",
+ "error.page.num.invalid": "Molimo ukucajte ispravan broj za sortiranje. Brojevi ne mogu biti negativni. ",
+ "error.page.slug.invalid": "Molimo vas unesite važeći URL dodatak",
+ "error.page.slug.maxlength": "Dužina poluge mora biti manja od \"{length}\" karaktera ",
+ "error.page.sort.permission": "Stranica \"{slug}\" ne može biti sortirana",
+ "error.page.status.invalid": "Molimo podesite ispravan status stranice",
+ "error.page.undefined": "Stranica ne može biti pronađena",
+ "error.page.update.permission": "Nije Vam dozvoljeno da ažurirate \"{slug}\"",
+
+ "error.section.files.max.plural": "Ne možete dodati više od {max} fajlova u \"{section}\" sekciju",
+ "error.section.files.max.singular": "Ne možete dodati više od jednog fajla u \"{section}\" sekciju",
+ "error.section.files.min.plural": "\"{section}\" sekcija zahteva najmanje {min} fajlova",
+ "error.section.files.min.singular": "\"{section}\" sekcija zahteva najmanje jedan fajl",
+
+ "error.section.pages.max.plural": "Ne možete dodati više od {max} stranica u \"{section}\" sekciju",
+ "error.section.pages.max.singular": "Ne možete dodati više od jedne stranice u \"{section}\" sekciju",
+ "error.section.pages.min.plural": "\"{section}\" sekcija zahteva najmanje {min} stranica",
+ "error.section.pages.min.singular": "\"{section}\" sekcija zahteva najmanje jednu stranicu",
+
+ "error.section.notLoaded": "Sekcija \"{name}\" nije mogla biti učitana",
+ "error.section.type.invalid": "Tip sekcije \"{type}\" nije ispravan",
+
+ "error.site.changeTitle.empty": "Naslov ne može biti prazan",
+ "error.site.changeTitle.permission": "Nije Vam dozvoljeno da promenite naziv sajta",
+ "error.site.update.permission": "Nije Vam dozvoljeno da ažurirate sajt",
+
+ "error.structure.validation": "Postoji greška u redu \"{field}\" na ovom polju {index}",
+
+ "error.template.default.notFound": "Podrazumevani template ne postoji",
+
+ "error.unexpected": "Došlo je do neočekivane greške! Omogućite režim za otklanjanje grešaka za više informacija: https://getkirby.com/docs/reference/system/options/debug",
+
+ "error.user.changeEmail.permission": "Nije Vam dozvoljeno da promenite email za korisnika \"{name}\"",
+ "error.user.changeLanguage.permission": "Nije Vam dozvoljeno da promenite jezik za korisnika \"{name}\"",
+ "error.user.changeName.permission": "Nije Vam dozvoljeno da promenite ima za korisnika \"{name}\"",
+ "error.user.changePassword.permission": "Nije Vam dozvoljeno da promenite lozinku za korisnika \"{name}\"",
+ "error.user.changeRole.lastAdmin": "Rolu poslednjeg admina nije moguće promeniti",
+ "error.user.changeRole.permission": "Nije Vam dozvoljeno da promenite rolu korisnika \"{name}\"",
+ "error.user.changeRole.toAdmin": "Nije vam dozvoljeno da unapredite nekoga u ulogu administratora",
+ "error.user.create.permission": "Nije Vam dozvoljeno da kreirate ovog korisnika",
+ "error.user.delete": "Korisnik \"{name}\" ne može biti obrisan",
+ "error.user.delete.lastAdmin": "Poslednji admin ne može biti obrisan",
+ "error.user.delete.lastUser": "Poslednji korisnik ne može biti obrisan",
+ "error.user.delete.permission": "Nije Vam dozvoljeno da obrišete korisnika \"{name}\"",
+ "error.user.duplicate": "Korisnik sa email adresom \"{email}\" već postoji",
+ "error.user.email.invalid": "Molimo unesite ispravnu email adresu",
+ "error.user.language.invalid": "Molimo unesite ispravan jezik",
+ "error.user.notFound": "Korisnik \"{name}\" ne može biti pronadjen",
+ "error.user.password.excessive": "Molimo vas, unesite ispravnu šifru. Šifra ne sme biti duža od 1000 karaktera.",
+ "error.user.password.invalid": "Molimo unesite ispravnu lozinku. Lozinke moraju biti barem 8 karaktera dugačke. ",
+ "error.user.password.notSame": "Lozinke se ne poklapaju",
+ "error.user.password.undefined": "Ovaj korisnik nema lozinku",
+ "error.user.password.wrong": "Pogrešna lozinka",
+ "error.user.role.invalid": "Molimo unesite ispravnu rolu",
+ "error.user.undefined": "Korisnik nije mogao biti pronadjen",
+ "error.user.update.permission": "Nije Vam dozvoljeno da ažurirate korisnika \"{name}\"",
+
+ "error.validation.accepted": "Molimo potvrdite",
+ "error.validation.alpha": "Molimo unesite karaktere izmedju a-z",
+ "error.validation.alphanum": "Molimo unesite samo karaktere izmedju a-z ili brojeve 0-9",
+ "error.validation.anchor": "Molimo vas unesite ispravan link",
+ "error.validation.between": "Molimo unesite vrednost izmedju \"{min}\" i \"{max}\"",
+ "error.validation.boolean": "Molimo potvrdite ili odbijte",
+ "error.validation.color": "Molimo vas unesite važeću boju u {format} format",
+ "error.validation.contains": "Molimo unesite vrednost koja sadrži \"{needle}\"",
+ "error.validation.date": "Molimo unesite ispravan datum",
+ "error.validation.date.after": "Molimo upišite natum nakon {date}",
+ "error.validation.date.before": "Molimo upišite datum pre {date}",
+ "error.validation.date.between": "Molimo dodajte datum između {min} i {max}",
+ "error.validation.denied": "Molimo odbijte",
+ "error.validation.different": "Vrednost ne može biti \"{other}\"",
+ "error.validation.email": "Molimo unesite ispravnu email adresu",
+ "error.validation.endswith": "Vrednost se mora završiti sa \"{end}\"",
+ "error.validation.filename": "Molimo unesite ispravno ime fajla",
+ "error.validation.in": "Molimo unesite nešto od sledećeg: ({in})",
+ "error.validation.integer": "Molimo unesite ispravan ceo broj",
+ "error.validation.ip": "Molimo unesite ispravnu IP adresu",
+ "error.validation.less": "Molimo unesite vrednost manju od {max}",
+ "error.validation.linkType": "Tip veze nije dozvoljen",
+ "error.validation.match": "Vrednost se ne uklapa u očekivani šablon",
+ "error.validation.max": "Molimo unesite vrednost jednsaku ili manju od {max}",
+ "error.validation.maxlength": "Molimo unestite kražu vrednost. (maks. {max} karaktera)",
+ "error.validation.maxwords": "Molimo unesite ne više od {max} reč(i)",
+ "error.validation.min": "Molimo unesite vrednost jednaku ili veću od {min}",
+ "error.validation.minlength": "Molimo unesite dužu vrednost. (min. {min} karaktera)",
+ "error.validation.minwords": "Molimo unesite minimun {min} reč(i)",
+ "error.validation.more": "Molimo vas unesite vrednost veću od {min}",
+ "error.validation.notcontains": "Molimo vas unesite vrednost koja ne sadrži \"{needle}\"",
+ "error.validation.notin": "Molimo vas nemojte unositi ništa od sledećeg: ({notIn})",
+ "error.validation.option": "Molimo izaberite važeću opciju",
+ "error.validation.num": "Molimo Vas da unesete važeći broj",
+ "error.validation.required": "Molimo vas unesite nešto",
+ "error.validation.same": "Molimo vas unesite \"{other}\"",
+ "error.validation.size": "Veličina vrednosti mora biti \"{size}\"",
+ "error.validation.startswith": "Vrednost mora početi sa \"{start}\"",
+ "error.validation.tel": "Molimo vas unesite neformatirani broj telefona",
+ "error.validation.time": "Molimo vas unesite važeće vreme",
+ "error.validation.time.after": "Molimo vas unesite vreme posle {time}",
+ "error.validation.time.before": "Molimo vas unesite vreme pre {time}",
+ "error.validation.time.between": "Molimo vas unesite vreme između {min} i {max}",
+ "error.validation.uuid": "Molimo vas unesite važeći UUID",
+ "error.validation.url": "Molimo vas da unesete važeći URL",
+
+ "expand": "Proširite",
+ "expand.all": "Proširite sve",
+
+ "field.invalid": "Polje je nevažeće",
+ "field.required": "Polje je obavezno",
+ "field.blocks.changeType": "Promenite tip",
+ "field.blocks.code.name": "Kod",
+ "field.blocks.code.language": "Jezik",
+ "field.blocks.code.placeholder": "Vaš kod…",
+ "field.blocks.delete.confirm": "Da li zaista želite da izbrišete ovaj blok?",
+ "field.blocks.delete.confirm.all": "Da li zaista želite da izbrišete sve blokove?",
+ "field.blocks.delete.confirm.selected": "Da li zaista želite da izbrišete izabrane blokove?",
+ "field.blocks.empty": "Još nema blokova",
+ "field.blocks.fieldsets.empty": "Još nema skupova polja",
+ "field.blocks.fieldsets.label": "Molimo izaberite tip bloka ...",
+ "field.blocks.fieldsets.paste": "Pritisnite{{ shortcut }} da biste uvezli rasporede/blokove iz međuspremnika. Biće umetnuti samo oni koji su dozvoljeni u trenutnom polju.",
+ "field.blocks.gallery.name": "Galerija",
+ "field.blocks.gallery.images.empty": "Još nema slika",
+ "field.blocks.gallery.images.label": "Slike",
+ "field.blocks.heading.level": "Nivo",
+ "field.blocks.heading.name": "Naslov",
+ "field.blocks.heading.text": "Tekst",
+ "field.blocks.heading.placeholder": "Naslov ...",
+ "field.blocks.figure.back.plain": "Plain",
+ "field.blocks.figure.back.pattern.light": "Pattern (light)",
+ "field.blocks.figure.back.pattern.dark": "Pattern (dark)",
+ "field.blocks.image.alt": "Alternativni tekst",
+ "field.blocks.image.caption": "Natpis",
+ "field.blocks.image.crop": "Isecite",
+ "field.blocks.image.link": "Link",
+ "field.blocks.image.location": "Lokacija",
+ "field.blocks.image.location.internal": "Ova veb lokacija\n \n \n",
+ "field.blocks.image.location.external": "Eksterni izvor",
+ "field.blocks.image.name": "Slika",
+ "field.blocks.image.placeholder": "Odaberi sliku",
+ "field.blocks.image.ratio": "Odnos",
+ "field.blocks.image.url": "URL slike",
+ "field.blocks.line.name": "Linija",
+ "field.blocks.list.name": "Lista",
+ "field.blocks.markdown.name": "Markdown",
+ "field.blocks.markdown.label": "Tekst",
+ "field.blocks.markdown.placeholder": "Markdown …",
+ "field.blocks.quote.name": "Citat",
+ "field.blocks.quote.text.label": "Tekst",
+ "field.blocks.quote.text.placeholder": "Citat ...",
+ "field.blocks.quote.citation.label": "Citat",
+ "field.blocks.quote.citation.placeholder": "od …",
+ "field.blocks.text.name": "Tekst",
+ "field.blocks.text.placeholder": "Tekst ...",
+ "field.blocks.video.autoplay": "Autoplay",
+ "field.blocks.video.caption": "Natpis",
+ "field.blocks.video.controls": "Controls",
+ "field.blocks.video.location": "Lokacija",
+ "field.blocks.video.loop": "Loop",
+ "field.blocks.video.muted": "Muted",
+ "field.blocks.video.name": "Video",
+ "field.blocks.video.placeholder": "Unesite URL video snimka",
+ "field.blocks.video.poster": "Poster",
+ "field.blocks.video.preload": "Preload",
+ "field.blocks.video.url.label": "Video-URL",
+ "field.blocks.video.url.placeholder": "https://youtube.com/?v=",
+
+ "field.entries.delete.confirm.all": "Da li zaista želite da izbrišete sve unose?",
+ "field.entries.empty": "Još nema unosa",
+
+ "field.files.empty": "Još nijedna datoteka nije izabrana",
+ "field.files.empty.single": "No file selected yet",
+
+ "field.layout.change": "Promenite izgled",
+ "field.layout.delete": "Brisanje rasporeda",
+ "field.layout.delete.confirm": "Da li zaista želite da obrišete ovaj raspored",
+ "field.layout.delete.confirm.all": "Da li zaista želite da izbrišete sve rasporede?",
+ "field.layout.empty": "Još nema redova",
+ "field.layout.select": "Izaberite raspored",
+
+ "field.object.empty": "Još nema informacija",
+
+ "field.pages.empty": "Još nijedna stranica nije izabrana",
+ "field.pages.empty.single": "No page selected yet",
+
+ "field.structure.delete.confirm": "Da li zaista želite da izbrišete ovaj red?",
+ "field.structure.delete.confirm.all": "Da li zaista želite da izbrišete sve unose?",
+ "field.structure.empty": "Još nema unosa",
+
+ "field.users.empty": "Još nije izabran nijedan korisnik",
+ "field.users.empty.single": "No user selected yet",
+
+ "fields.empty": "Još uvek nema polja",
+
+ "file": "Datoteka",
+ "file.blueprint": "Ova datoteka još uvek nema nacrt. Možete definisati podešavanje u /site/blueprints/files/{blueprint}.yml",
+ "file.changeTemplate": "Promenite šablon",
+ "file.changeTemplate.notice": "Promena šablona datoteke će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Ako novi šablon definiše određena pravila, npr. dimenzije slike, one će se takođe nepovratno primenjivati. Koristite sa oprezom.",
+ "file.delete.confirm": "Da li zaista želite da izbrišete panel.install opcije",
+ "installation.issues.accounts": "Fascikla /site/accounts ne postoji ili u nju nije moguće pisati",
+ "installation.issues.content": "Fascikla /content ne postoji ili u nju nije moguće pisati",
+ "installation.issues.curl": "Proširenje CURL je potrebno",
+ "installation.issues.headline": "Panel se ne može instalirati",
+ "installation.issues.mbstring": "Proširenje MB String je potrebno",
+ "installation.issues.media": "Fascikla /media ne postoji ili u nju nije moguće pisati",
+ "installation.issues.php": "Obavezno koristite PHP 8+",
+ "installation.issues.sessions": "Fascikla /site/sessions ne postoji ili u nju nije moguće pisati",
+
+ "language": "Jezik",
+ "language.code": "Kod",
+ "language.convert": "Postavi kao podrazumevano",
+ "language.convert.confirm": "Da li zaista želite da konvertujete {name} na podrazumevani jezik? Ovo se ne može poništiti.
Ako{name} ima neprevedenog sadržaja, više neće postojati važeći rezervni deo i delovi vašeg sajta mogu biti prazni.
", + "language.create": "Dodajte novi jezik", + "language.default": "Podrazumevani jezik", + "language.delete.confirm": "Da li zaista želite da izbrišete jezik {name} uključujući sve prevode? Ovo se ne može poništiti!", + "language.deleted": "Jezik je obrisan", + "language.direction": "Smer čitanja", + "language.direction.ltr": "S leva nadesno", + "language.direction.rtl": "S desna nalevo", + "language.locale": "PHP locale string", + "language.locale.warning": "Koristite prilagođeni lokal. Molimo vas izmenite ga u jezičkoj datoteci u /site/languages", + "language.name": "Ime", + "language.secondary": "Sekundarni jezik", + "language.settings": "Podešavanja jezika", + "language.updated": "Jezik je ažuriran", + "language.variables": "Jezičke varijable", + "language.variables.empty": "Još uvek nema prevoda", + + "language.variable.delete.confirm": "Da li zaista želite da izbrišete promenljivu za {key}?", + "language.variable.entries": "Values", + "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
+ "language.variable.key": "Ključ",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
+ "language.variable.notFound": "Promenljiva nije pronađena\n \n \n ",
+ "language.variable.value": "Vrednost",
+
+ "languages": "Jezici",
+ "languages.default": "Podrazumevani jezik",
+ "languages.empty": "Još nema jezika",
+ "languages.secondary": "Sekundarni jezik",
+ "languages.secondary.empty": "Još nema sekundarnog jezika",
+
+ "license": "Licenca",
+ "license.activate": "Aktivirajte ga sada",
+ "license.activate.label": "Molimo vas aktivirajte svoju licencu",
+ "license.activate.domain": "Vaša licenca će biti aktivirana za {host}.",
+ "license.activate.local": "Upravo ćete aktivirati svoju Kirby licencu za vaš lokalni domen {host}.Ako će ovaj sajt biti postavljen na javnom domenu, molimo vas aktivirajte ga tamo. Ako je {host} domen za koji želite da koristite svoju licencu, molimo vas nastavite.",
+ "license.activated": "Aktiviran",
+ "license.buy": "Kupite licencu",
+ "license.code": "Kod",
+ "license.code.help": "Nakon kupovine dobili ste svoju šifru licence putem email. Molimo vas da je kopirate i nalepite ovde.",
+ "license.code.label": "Molimo vas unesite šifru licence",
+ "license.status.active.info": "Uključuje nove glavne verzije do {date}",
+ "license.status.active.label": "Validna licenca",
+ "license.status.demo.info": "Ovo je demo instalacija",
+ "license.status.demo.label": "Demo",
+ "license.status.inactive.info": "Obnovite licencu da biste ažurirali na nove glavne verzije",
+ "license.status.inactive.label": "Nema novih glavnih verzija",
+ "license.status.legacy.bubble": "Da li ste spremni da obnovite svoju licencu?",
+ "license.status.legacy.info": "Vaša licenca ne pokriva ovu verziju",
+ "license.status.legacy.label": "Molimo vas obnovite vašu licencu",
+ "license.status.missing.bubble": "Da li ste spremni da pokrenete svoj sajt?",
+ "license.status.missing.info": "Nema važeće licence",
+ "license.status.missing.label": "Molimo vas aktivirajte svoju licencu",
+ "license.status.unknown.info": "The license status is unknown",
+ "license.status.unknown.label": "Unknown",
+ "license.manage": "Upravljajte svojom licencom",
+ "license.purchased": "Kupljeno",
+ "license.success": "Hvala vam što podržavate Kirby",
+ "license.unregistered.label": "Neregistrovan",
+
+ "link": "Link",
+ "link.text": "Tekst veze",
+
+ "loading": "Učitavanje",
+
+ "lock.unsaved": "Nesačuvane promene",
+ "lock.unsaved.empty": "There are no unsaved changes",
+ "lock.unsaved.files": "Unsaved files",
+ "lock.unsaved.pages": "Unsaved pages",
+ "lock.unsaved.users": "Unsaved accounts",
+ "lock.isLocked": "Nesačuvane promene {email}",
+ "lock.unlock": "Otključati",
+ "lock.unlock.submit": "Otključajte i zamenite nesačuvane promene {email}",
+ "lock.isUnlocked": "Otključao ga je drugi korisnik",
+
+ "login": "Prijavi se",
+ "login.code.label.login": "Kod za prijavu",
+ "login.code.label.password-reset": "Kod za resetovanje lozinke",
+ "login.code.placeholder.email": "000 000",
+ "login.code.placeholder.totp": "000000",
+ "login.code.text.email": "Ako je vaša adresa e-pošte registrovana, traženi kod je poslat putem e-pošte.",
+ "login.code.text.totp": "Molimo vas unesite jednokratni kod iz vaše aplikacije za autentifikaciju.",
+ "login.email.login.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za prijavu na panel {site}.\nSledeći kod za prijavu će biti važećii {timeout} minuta:\n\n{code}\n\nAko niste zahtevali kod za prijavu, zanemarite ovu e-poštu ili kontaktirajte svog administratora ako imate pitanja.\nIz bezbednosnih razloga, NEMOJTE prosleđivati ovu e-poštu.",
+ "login.email.login.subject": "Vaš kod za prijavu",
+ "login.email.password-reset.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za resetovanje lozinke za panel {site}.\nSledeći kod za resetovanje lozinke će biti važeći {timeout} minuta:\n\n{code}\n\nAko niste zahtevali kod za resetovanje lozinke, zanemarite ovu e-poštu ili kontaktirajte svog administratora ako imate pitanja.\nIz bezbednosnih razloga, NEMOJTE prosleđivati ovu e-poštu.",
+ "login.email.password-reset.subject": "Vaš kod za resetovanje lozinke",
+ "login.remember": "Ostavi me prijavljenog",
+ "login.reset": "Resetujte šifru",
+ "login.toggleText.code.email": "Prijavite se putem e-pošte",
+ "login.toggleText.code.email-password": "Prijavite se sa lozinkom",
+ "login.toggleText.password-reset.email": "Zaboravili ste lozinku?",
+ "login.toggleText.password-reset.email-password": "← Nazad na prijavu",
+ "login.totp.enable.option": "Podesite jednokratne kodove",
+ "login.totp.enable.intro": "Aplikacije autentifikacije mogu da generišu jednokratne kodove koji se koriste kao drugi faktor prilikom prijavljivanja na nalog.",
+ "login.totp.enable.qr.label": "1. Skenirajte ovaj QR kod",
+ "login.totp.enable.qr.help": "Ne možete da skenirate? Dodajte ključ za podešavanje{secret} manuelno u aplikaciji za autentifikaciju.",
+ "login.totp.enable.confirm.headline": "2. Potvrdite generisanim kodom",
+ "login.totp.enable.confirm.text": "Vaša aplikacija generiše novi jednokratni kod svakih 30 sekundi. Unesite trenutni kod da biste završili podešavanje:",
+ "login.totp.enable.confirm.label": "Trenutni kod",
+ "login.totp.enable.confirm.help": "Nakon ovog podešavanja, svaki put kada se prijavite tražićemo od vas jednokratni kod.",
+ "login.totp.enable.success": "Jednokratni kodovi su omogućeni",
+ "login.totp.disable.option": "Onemogućite jednokratne kodove",
+ "login.totp.disable.label": "Unesite lozinku da biste onemogućili jednokratne kodove",
+ "login.totp.disable.help": "U budućnosti, drugi faktor kao što je kod za prijavu poslat putem e-pošte biće zahtevan kada se prijavite. Jednokratne kodove uvek možete ponovo da podesite kasnije.",
+ "login.totp.disable.admin": "Ovo će onemogućiti jednokratne kodove za{user}.
U budućnosti, drugi faktor kao što je kod za prijavu poslat putem e-pošte biće zahtevan kada se prijave. {user} može ponovo da podesi jednokratne kodove nakon sledećeg prijavljivanja",
+ "login.totp.disable.success": "Jednokratni kodovi su onemogućeni",
+
+ "logout": "Odjavi se",
+
+ "merge": "Spojite",
+ "menu": "Meni",
+ "meridiem": "AM/PM",
+ "mime": "Vrsta medija",
+ "minutes": "Minuti",
+
+ "month": "Mesec",
+ "months.april": "April",
+ "months.august": "Avgust",
+ "months.december": "Decembar",
+ "months.february": "Februar",
+ "months.january": "Januar",
+ "months.july": "Jul",
+ "months.june": "Jun",
+ "months.march": "Mart",
+ "months.may": "Maj",
+ "months.november": "Novembar",
+ "months.october": "Oktobar",
+ "months.september": "Septembar",
+
+ "more": "Više",
+ "move": "Pomerite",
+ "name": "Ime",
+ "next": "Sledeći",
+ "night": "Noć",
+ "no": "ne",
+ "off": "Isključeno",
+ "on": "Uključeno",
+ "open": "Otvorite",
+ "open.newWindow": "Otvorite u novom prozoru",
+ "option": "Opcija",
+ "options": "Opcije",
+ "options.none": "Nema opcija",
+ "options.all": "Prikaži sve {count} opcije",
+
+ "orientation": "Orijentacija",
+ "orientation.landscape": "Predeo",
+ "orientation.portrait": "Portret",
+ "orientation.square": "Kvadrat",
+
+ "page": "Stranica",
+ "page.blueprint": "Ova stranica još uvek nema blueprint. Možete definisati podešavanje u /site/blueprints/pages/{blueprint}.yml",
+ "page.changeSlug": "Promeni URL",
+ "page.changeSlug.fromTitle": "Napravi od naslova",
+ "page.changeStatus": "Promenite status",
+ "page.changeStatus.position": "Molimo izaberite poziciju",
+ "page.changeStatus.select": "Odaberite novi status",
+ "page.changeTemplate": "Promenite šablon",
+ "page.changeTemplate.notice": "Promena šablona stranice će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Koristite sa oprezom.",
+ "page.create": "Kreirajte kao {status}",
+ "page.delete.confirm": "Da li zaista želite da izbrišete {title}?",
+ "page.delete.confirm.subpages": "Ova stranica ima podstranice.
Sve podstranice će takođe biti izbrisane.",
+ "page.delete.confirm.title": "Unesite naslov stranice da biste potvrdili",
+ "page.duplicate.appendix": "Kopiraj",
+ "page.duplicate.files": "Kopirajte fajlove",
+ "page.duplicate.pages": "Kopirajte stranice",
+ "page.move": "Premestite stranicu",
+ "page.sort": "Promena pozicije",
+ "page.status": "Status",
+ "page.status.draft": "Nacrt",
+ "page.status.draft.description": "Stranica je u radnom režimu i vidljiva je samo prijavljenim urednicima ili putem tajne veze",
+ "page.status.listed": "Javno",
+ "page.status.listed.description": "Stranica je javna za svakoga",
+ "page.status.unlisted": "Nenavedeno",
+ "page.status.unlisted.description": "Stranica je dostupna samo preko URL-a",
+
+ "pages": "Stranice",
+ "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
+ "pages.empty": "Još nema stranica",
+ "pages.status.draft": "Nacrti",
+ "pages.status.listed": "Objavljeno",
+ "pages.status.unlisted": "Neizlistano",
+
+ "pagination.page": "Stranica",
+
+ "password": "Lozinka",
+ "paste": "Zalepite",
+ "paste.after": "Zalepite posle",
+ "paste.success": "{count} zalepljen!",
+ "pixel": "Piksel",
+ "plugin": "Dodatak",
+ "plugins": "Dodaci",
+ "prev": "Prethodna",
+ "preview": "Pregled",
+
+ "publish": "Publish",
+ "published": "Objavljeno",
+
+ "remove": "Ukloniti",
+ "rename": "Preimenovati",
+ "renew": "Obnovite",
+ "replace": "Zameniti",
+ "replace.with": "Zamenite sa",
+ "retry": "Probajte ponovo",
+ "revert": "Vratiti se",
+ "revert.confirm": "Da li zaista želite da izbrišete sve nesačuvane promene?",
+
+ "role": "Uloga",
+ "role.admin.description": "Administrator ima sva prava",
+ "role.admin.title": "Administrator",
+ "role.all": "Sve",
+ "role.empty": "Nema korisnika sa ovom ulogom",
+ "role.description.placeholder": "Nema opisa",
+ "role.nobody.description": "Ovo je rezervna uloga bez ikakvih dozvola",
+ "role.nobody.title": "Niko",
+
+ "save": "Sačuvaj",
+ "saved": "Saved",
+ "search": "Pretraga",
+ "searching": "Searching",
+ "search.min": "Unesite {min} karakter za pretragu",
+ "search.all": "Prikaži sve {count} rezultate ",
+ "search.results.none": "Nema rezultata",
+
+ "section.invalid": "Odeljak je nevažeći",
+ "section.required": "Odeljak je obavezan",
+
+ "security": "Bezbednost",
+ "select": "Izaberite",
+ "server": "Server",
+ "settings": "Podešavanja",
+ "show": "Prikaži",
+ "site.blueprint": "Sajt još nema plan. Možete definisati podešavanje u /site/blueprints/site.yml",
+ "size": "Veličina",
+ "slug": "URL dodatak",
+ "sort": "Vrsta",
+ "sort.drag": "Prevucite da biste sortirali…",
+ "split": "Razdeliti",
+
+ "stats.empty": "Nema izveštaja",
+ "status": "Status",
+
+ "system.info.copy": "Copy info",
+ "system.info.copied": "System info copied",
+ "system.issues.content": "Čini se da je fascikla sa sadržajem izložena",
+ "system.issues.eol.kirby": "Vaša instalirana Kirby verzija je stigla do kraja svog životnog veka i neće dobijati dalja bezbednosna ažuriranja",
+ "system.issues.eol.plugin": "Vaša instalirana verzija { plugin } dodatka je stigla do kraja svog životnog veka i neće dobijati dalja bezbednosna ažuriranja",
+ "system.issues.eol.php": "Vaše instalirano PHP izdanje { release } je dostiglo kraj svog životnog veka i neće dobijati dalja bezbednosna ažuriranja",
+ "system.issues.debug": "Otklanjanje grešaka mora biti isključeno u proizvodnji",
+ "system.issues.git": "Čini se da je .git fascikla izložena",
+ "system.issues.https": "Preporučujemo HTTPS za sve vaše sajtove",
+ "system.issues.kirby": "Čini se da je Kirby fascikla izložena",
+ "system.issues.local": "The site is running locally with relaxed security checks",
+ "system.issues.site": "Čini se da je fascikla sajta izložena",
+ "system.issues.vue.compiler": "The Vue template compiler is enabled",
+ "system.issues.vulnerability.kirby": "Na vašu instalaciju može uticati sledeća ranjivost ({ severity } severity): { description }",
+ "system.issues.vulnerability.plugin": "Na vašu instalaciju može uticati sledeća ranjivost u { plugin } dodatku ({ severity } severity): { description }",
+ "system.updateStatus": "Ažuriraj status",
+ "system.updateStatus.error": "Provera ažuriranja nije uspela",
+ "system.updateStatus.not-vulnerable": "Nema poznatih ranjivosti",
+ "system.updateStatus.security-update": "Dostupna besplatna bezbednosna { version } nadogradnja",
+ "system.updateStatus.security-upgrade": "Nadogradnja { version } sa dostupnim bezbednosnim ispravkama",
+ "system.updateStatus.unreleased": "Neobjavljena verzija",
+ "system.updateStatus.up-to-date": "Do datuma",
+ "system.updateStatus.update": "Dostupno besplatno { version } ažuriranje",
+ "system.updateStatus.upgrade": "Nadogradnja je { version } dostupna",
+
+ "tel": "Telefon",
+ "tel.placeholder": "+49123456789",
+ "template": "Šablon",
+
+ "theme": "Theme",
+ "theme.light": "Lights on",
+ "theme.dark": "Lights off",
+ "theme.automatic": "Match system default",
+
+ "title": "Naslov",
+ "today": "Danas",
+
+ "toolbar.button.clear": "Očisti formatiranje",
+ "toolbar.button.code": "Kod",
+ "toolbar.button.bold": "Bold",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Zaglavlje",
+ "toolbar.button.heading.1": "Zaglavlje 1",
+ "toolbar.button.heading.2": "Zaglavlje 2",
+ "toolbar.button.heading.3": "Zaglavlje 3",
+ "toolbar.button.heading.4": "Zaglavlje 4",
+ "toolbar.button.heading.5": "Zaglavlje 5",
+ "toolbar.button.heading.6": "Zaglavlje 6",
+ "toolbar.button.italic": "Italic",
+ "toolbar.button.file": "Datoteka",
+ "toolbar.button.file.select": "Izaberite datoteku",
+ "toolbar.button.file.upload": "Otpremite datoteku",
+ "toolbar.button.link": "Link",
+ "toolbar.button.paragraph": "Paragraf",
+ "toolbar.button.strike": "Precrtano",
+ "toolbar.button.sub": "Subscript",
+ "toolbar.button.sup": "Superscript",
+ "toolbar.button.ol": "Naručena lista",
+ "toolbar.button.underline": "Podvući",
+ "toolbar.button.ul": "Bullet list",
+
+ "translation.author": "Branko Matić",
+ "translation.direction": "ltr",
+ "translation.name": "Srpski",
+ "translation.locale": "sr_RS@latin",
+
+ "type": "Tip",
+
+ "upload": "Otpremi",
+ "upload.error.cantMove": "Otpremljena datoteka nije mogla da se premesti",
+ "upload.error.cantWrite": "Neuspešno prebacivanje datoteka na disk",
+ "upload.error.default": "Nije moguće otpremiti datoteku",
+ "upload.error.extension": "Otpremanje datoteke je zaustavljeno ekstenzijom",
+ "upload.error.formSize": "Otpremljena datoteka premašuje MAX_FILE_SIZE direktivu koja je navedena u obrascu",
+ "upload.error.iniPostSize": "Otpremljena datoteka premašuje post_max_size direktivu u php.ini",
+ "upload.error.iniSize": "Otpremljena datoteka premašuje upload_max_filesize direktivu u php.ini",
+ "upload.error.noFile": "Nijedna datoteka nije otpremljena",
+ "upload.error.noFiles": "Nijedna datoteka nije otpremljena",
+ "upload.error.partial": "Otpremljena datoteka je samo delimično otpremljena",
+ "upload.error.tmpDir": "Nedostaje privremena fascikla",
+ "upload.errors": "Greška",
+ "upload.progress": "Otpremanje…",
+
+ "url": "Url",
+ "url.placeholder": "https://example.com",
+
+ "user": "Korisnik",
+ "user.blueprint": "Možete definisati dodatne odeljke i polja obrasca za ovu korisničku ulogu usite/blueprints/users/{blueprint}.yml",
+ "user.changeEmail": "Promenite E-mail",
+ "user.changeLanguage": "Promenite jezik",
+ "user.changeName": "Preimenujte ovog korisnika",
+ "user.changePassword": "Promenite lozinku",
+ "user.changePassword.current": "Your current password",
+ "user.changePassword.new": "Nova lozinka",
+ "user.changePassword.new.confirm": "Potvrdite novu lozinku…",
+ "user.changeRole": "Promenite ulogu",
+ "user.changeRole.select": "Izaberite novu ulogu",
+ "user.create": "Dodajte novog korisnika",
+ "user.delete": "Izbrišite ovog korisnika",
+ "user.delete.confirm": "Da li zaista želite da izbrišete
{email}?",
+
+ "users": "Korisnici",
+
+ "version": "Verzija",
+ "version.changes": "Changed version",
+ "version.compare": "Compare versions",
+ "version.current": "Trenutna verzija",
+ "version.latest": "Najnovija verzija",
+ "versionInformation": "Informacije o verziji",
+
+ "view": "View",
+ "view.account": "Tvoj nalog",
+ "view.installation": "Instalacija",
+ "view.languages": "Jezici",
+ "view.resetPassword": "Resetujte šifru",
+ "view.site": "Sajt",
+ "view.system": "Sistem",
+ "view.users": "Korisnici",
+
+ "welcome": "Dobrodošli",
+ "year": "Godina",
+ "yes": "Da"
+}
diff --git a/public/kirby/i18n/translations/sv_SE.json b/public/kirby/i18n/translations/sv_SE.json
index 91b0971..53ac863 100644
--- a/public/kirby/i18n/translations/sv_SE.json
+++ b/public/kirby/i18n/translations/sv_SE.json
@@ -98,17 +98,17 @@
"error.content.lock.replace": "Versionen är låst och kan inte bytas ut",
"error.content.lock.update": "Versionen är låst och kan inte uppdateras",
- "error.entries.max.plural": "You must not add more than {max} entries",
- "error.entries.max.singular": "You must not add more than one entry",
- "error.entries.min.plural": "You must add at least {min} entries",
- "error.entries.min.singular": "You must add at least one entry",
- "error.entries.supports": "\"{type}\" field type is not supported for the entries field",
+ "error.entries.max.plural": "Du får inte lägga till fler än {max} poster",
+ "error.entries.max.singular": "Du får inte lägga till mer än en post",
+ "error.entries.min.plural": "Du måste lägga till minst {min} poster",
+ "error.entries.min.singular": "Du måste lägga till minst en post",
+ "error.entries.supports": "Fälttypen \"{type}\" stöds inte för fältet \"entries\"",
"error.entries.validation": "Det finns ett fel i fältet \"{field}\" i rad {index}",
"error.email.preset.notFound": "E-postförinställningen \"{name}\" kan inte hittas",
"error.field.converter.invalid": "Ogiltig omvandlare \"{converter}\"",
- "error.field.link.options": "Invalid options: {options}",
+ "error.field.link.options": "Ogiltiga alternativ: {options}",
"error.field.type.missing": "Fältet \"{ name }\": Fälttypen \"{ type }\" finns inte",
"error.file.changeName.empty": "Namnet får inte vara tomt",
@@ -116,7 +116,7 @@
"error.file.changeTemplate.invalid": "Mallen för filen \"{id}\" kan inte ändras till \"{template}\" (giltiga mallar: \"{blueprints}\")",
"error.file.changeTemplate.permission": "Du saknar behörighet för att ändra mallen för filen \"{id}\"",
- "error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
+ "error.file.delete.multiple": "Alla filer kunde inte raderas. Prova varje återstående fil individuellt för att se det specifika felet som förhindrar radering.",
"error.file.duplicate": "En fil med namnet \"{filename}\" existerar redan",
"error.file.extension.forbidden": "Filändelsen \"{extension}\" är inte tillåten",
"error.file.extension.invalid": "Ogiltig filändelse: {extension}",
@@ -179,7 +179,7 @@
"error.page.delete": "Sidan \"{slug}\" kan inte raderas",
"error.page.delete.confirm": "Fyll i sidans titel för att bekräfta",
"error.page.delete.hasChildren": "Sidan har undersidor och kan inte raderas",
- "error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
+ "error.page.delete.multiple": "Alla sidor kunde inte raderas. Prova varje återstående sida individuellt för att se det specifika felet som förhindrar radering.",
"error.page.delete.permission": "Du har inte behörighet att radera \"{slug}\"",
"error.page.draft.duplicate": "Ett utkast med URL-appendixen \"{slug}\" existerar redan",
"error.page.duplicate": "En sida med URL-appendixen \"{slug}\" existerar redan",
@@ -394,7 +394,7 @@
"file.sort": "Ändra position",
"files": "Filer",
- "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
+ "files.delete.confirm.selected": "Vill du verkligen ta bort de markerade filerna? Denna åtgärd kan inte ångras.",
"files.empty": "Inga filer än",
"filter": "Filter",
@@ -449,7 +449,12 @@
"language.variables.empty": "Inga översättningar ännu",
"language.variable.delete.confirm": "Vill du verkligen ta bort variabeln för {key}?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
"language.variable.key": "Nyckel",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Variabeln kunde inte hittas",
"language.variable.value": "Värde",
@@ -482,7 +487,7 @@
"license.status.missing.info": "Ingen giltig licens",
"license.status.missing.label": "Vänligen aktivera din licens",
"license.status.unknown.info": "Licensstatusen är okänd",
- "license.status.unknown.label": "Unknown",
+ "license.status.unknown.label": "Okänd",
"license.manage": "Hantera dina licenser",
"license.purchased": "Köpt",
"license.success": "Tack för att du stödjer Kirby",
@@ -604,7 +609,7 @@
"page.status.unlisted.description": "Sidan är endast åtkomlig via URL",
"pages": "Sidor",
- "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
+ "pages.delete.confirm.selected": "Vill du verkligen ta bort de valda sidorna? Denna åtgärd kan inte ångras.",
"pages.empty": "Inga sidor än",
"pages.status.draft": "Utkast",
"pages.status.listed": "Publicerade",
@@ -679,9 +684,9 @@
"system.issues.git": "Mappen .git verkar vara exponerad",
"system.issues.https": "Vi rekommenderar HTTPS för alla dina webbplatser",
"system.issues.kirby": "Mappen kirby verkar vara exponerad",
- "system.issues.local": "The site is running locally with relaxed security checks",
+ "system.issues.local": "Sajten drivs lokalt med förenklade säkerhetskontroller",
"system.issues.site": "Mappen site verkar vara exponerad",
- "system.issues.vue.compiler": "The Vue template compiler is enabled",
+ "system.issues.vue.compiler": "Mallkompilatorn för Vue är aktiverad",
"system.issues.vulnerability.kirby": "Din installation kan vara påverkad av följande sårbarhet ({ severity } allvarlighetsgrad): { description }",
"system.issues.vulnerability.plugin": "Din installation kan vara påverkad av följande sårbarhet i tillägget { plugin } ({ severity } allvarlighetsgrad): { description }",
"system.updateStatus": "Uppdateringsstatus",
@@ -761,7 +766,7 @@
"user.changeLanguage": "Ändra språk",
"user.changeName": "Byt namn på denna användare",
"user.changePassword": "Ändra lösenord",
- "user.changePassword.current": "Your current password",
+ "user.changePassword.current": "Ditt nuvarande lösenord",
"user.changePassword.new": "Nytt lösenord",
"user.changePassword.new.confirm": "Bekräfta det nya lösenordet...",
"user.changeRole": "Ändra roll",
diff --git a/public/kirby/i18n/translations/tr.json b/public/kirby/i18n/translations/tr.json
index 9684444..2838b7d 100644
--- a/public/kirby/i18n/translations/tr.json
+++ b/public/kirby/i18n/translations/tr.json
@@ -449,7 +449,12 @@
"language.variables.empty": "Henüz çeviri yok",
"language.variable.delete.confirm": "Gerçekten {key} değişkenini silmek istiyor musunuz?",
+ "language.variable.entries": "Değerler",
+ "language.variable.entries.help": "Her dize, eşleşen sayısı için kullanılacaktır, örneğin üç dize 0, 1, 2 ve daha fazla sayım için eşleşecektir. Gerçek sayıyı eklemek için {count} yer tutucusunu kullanın.",
"language.variable.key": "Anahtar",
+ "language.variable.multiple": "Sayılabilir mi?",
+ "language.variable.multiple.text": "Farklı çeviri dizeleri kullanın",
+ "language.variable.multiple.help": "Dil değişkeniyle birlikte ilettiğiniz sayıma bağlı olarak farklı değerler kullanabilir, böylece tekil ve çoğul gibi dinamik çeviriler oluşturabilirsiniz.",
"language.variable.notFound": "Değişken bulunamadı",
"language.variable.value": "Değer",
diff --git a/public/kirby/i18n/translations/zh_TW.json b/public/kirby/i18n/translations/zh_TW.json
new file mode 100644
index 0000000..675c501
--- /dev/null
+++ b/public/kirby/i18n/translations/zh_TW.json
@@ -0,0 +1,799 @@
+{
+ "account.changeName": "變更帳號名稱",
+ "account.delete": "刪除帳號",
+ "account.delete.confirm": "你確定要刪除這個帳號嗎?",
+
+ "activate": "啟用",
+ "add": "\u65b0\u589e",
+ "alpha": "字母順序",
+ "author": "作者",
+ "avatar": "\u4f7f\u7528\u8005\u7167\u7247",
+ "back": "返回",
+ "cancel": "\u53d6\u6d88",
+ "change": "\u8b8a\u66f4",
+ "close": "\u95dc\u9589",
+ "changes": "變更",
+ "confirm": "儲存",
+ "collapse": "收合",
+ "collapse.all": "全部收合",
+ "color": "顏色",
+ "coordinates": "座標",
+ "copy": "Copy",
+ "copy.all": "全部複製",
+ "copy.success": "複製成功",
+ "copy.success.multiple": "{count} 資料已複製",
+ "copy.url": "複製網址",
+ "create": "建立",
+ "custom": "自訂",
+
+ "date": "日期",
+ "date.select": "選擇日期",
+
+ "day": "日",
+ "days.fri": "\u4e94",
+ "days.mon": "\u4e00",
+ "days.sat": "\u516d",
+ "days.sun": "\u65e5",
+ "days.thu": "\u56db",
+ "days.tue": "\u4e8c",
+ "days.wed": "\u4e09",
+
+ "debugging": "除錯中",
+
+ "delete": "\u522a\u9664",
+ "delete.all": "全部刪除",
+
+ "dialog.fields.empty": "沒有可用的欄位",
+ "dialog.files.empty": "沒有可用的檔案",
+ "dialog.pages.empty": "沒有可用的頁面",
+ "dialog.text.empty": "沒有可用的文字",
+ "dialog.users.empty": "沒有可用的使用者",
+
+ "dimensions": "尺寸",
+ "disable": "停用",
+ "disabled": "已停用",
+ "discard": "\u653e\u68c4",
+
+ "drawer.fields.empty": "沒有欄位可顯示",
+
+ "domain": "網域",
+ "download": "下載",
+ "duplicate": "建立副本",
+
+ "edit": "\u7de8\u8f2f",
+
+ "email": "電子郵件",
+ "email.placeholder": "mail@example.com",
+
+ "enter": "輸入",
+ "entries": "資料項目",
+ "entry": "進入",
+
+ "environment": "環境",
+
+ "error": "錯誤",
+ "error.access.code": "無效的存取碼",
+ "error.access.login": "請先登入",
+ "error.access.panel": "你沒有進入控制台的權限",
+ "error.access.view": "你沒有瀏覽這個項目的權限",
+
+ "error.avatar.create.fail": "無法建立使用者照片",
+ "error.avatar.delete.fail": "\u7121\u6cd5\u522a\u9664\u4f7f\u7528\u8005\u7167\u7247",
+ "error.avatar.dimensions.invalid": "請將個人檔案圖片的寬度和高度控製在 3000 畫素以下",
+ "error.avatar.mime.forbidden": "\u88ab\u7981\u6b62\u7684 mime \u985e\u578b",
+
+ "error.blueprint.notFound": "無法載入藍圖「{name}」",
+
+ "error.blocks.max.plural": "最多只能加入 {max} 個區塊",
+ "error.blocks.max.singular": "最多只能加入 1 個區塊",
+ "error.blocks.min.plural": "至少需要 {min} 個區塊",
+ "error.blocks.min.singular": "至少需要 1 個區塊",
+ "error.blocks.validation": "使用「{fieldset}」區塊類型的區塊 {index} 中的「{field}」欄位出錯",
+
+ "error.cache.type.invalid": "無效快取類型 \"{type}\"",
+
+ "error.content.lock.delete": "內容鎖定中,無法刪除",
+ "error.content.lock.move": "內容鎖定中,無法移動",
+ "error.content.lock.publish": "內容鎖定中,無法發佈",
+ "error.content.lock.replace": "內容鎖定中,無法替換",
+ "error.content.lock.update": "內容鎖定中,無法更新",
+
+ "error.entries.max.plural": "最多只能加入 {max} 筆資料",
+ "error.entries.max.singular": "最多只能加入 1 筆資料",
+ "error.entries.min.plural": "至少需要 {min} 筆資料",
+ "error.entries.min.singular": "至少需要 1 筆資料",
+ "error.entries.supports": "「{type}」欄位類型不支援指定的資料類型",
+ "error.entries.validation": "行 {index} 中的「{field}」欄位出錯。",
+
+ "error.email.preset.notFound": "找不到電子信箱預設設定「{name}」",
+
+ "error.field.converter.invalid": "欄位轉換無效「{converter}」",
+ "error.field.link.options": "連結欄位的選項格式錯誤:「{options}」",
+ "error.field.type.missing": "欄位「{ name }」:欄位類型「{ type }」不存在",
+
+ "error.file.changeName.empty": "請輸入新的檔名",
+ "error.file.changeName.permission": "你沒有變更「{filename}」檔名的權限",
+ "error.file.changeTemplate.invalid": "檔案「{id}」的樣板無法變更為「{template}」(有效:「{blueprints}」)。",
+ "error.file.changeTemplate.permission": "你沒有變更「{id}」樣板的權限",
+
+ "error.file.delete.multiple": "刪除多個檔案時發生錯誤",
+ "error.file.duplicate": "檔案「{filename}」重複",
+ "error.file.extension.forbidden": "\u88ab\u7981\u6b62\u7684\u526f\u6a94\u540d",
+ "error.file.extension.invalid": "無效的副檔名:{extension}",
+ "error.file.extension.missing": "檔案「{filename}」沒有副檔名",
+ "error.file.maxheight": "檔案高度不能超過 {max} 像素",
+ "error.file.maxsize": "檔案太大",
+ "error.file.maxwidth": "檔案寬度不能超過 {max} 像素",
+ "error.file.mime.differs": "上傳的檔案必須是相同的 mime 類型「{mime}」",
+ "error.file.mime.forbidden": "不允許使用媒體類型「{mime}」。",
+ "error.file.mime.invalid": "無效的 MIME 類型:「{mime}」",
+ "error.file.mime.missing": "無法偵測「{filename}」檔案的媒體類型",
+ "error.file.minheight": "檔案高度不能小於 {min} 像素",
+ "error.file.minsize": "檔案太小",
+ "error.file.minwidth": "檔案寬度不能小於 {min} 像素",
+ "error.file.name.unique": "檔名已經存在",
+ "error.file.name.missing": "請輸入檔名",
+ "error.file.notFound": "\u627e\u4e0d\u5230\u6a94\u6848",
+ "error.file.orientation": "影像的方向必須是「{orientation}」",
+ "error.file.sort.permission": "你沒有變更「{filename}」排序的權限",
+ "error.file.type.forbidden": "此「{type}」的檔案不允許上傳",
+ "error.file.type.invalid": "無效的檔案類型:{filename}",
+ "error.file.undefined": "\u627e\u4e0d\u5230\u6a94\u6848",
+
+ "error.form.incomplete": "表單尚未填寫完成",
+ "error.form.notSaved": "表單無法儲存,請檢查是否有錯誤",
+
+ "error.language.code": "語言代碼無效",
+ "error.language.create.permission": "你沒有新增語言的權限",
+ "error.language.delete.permission": "你沒有刪除語言的權限",
+ "error.language.duplicate": "語言已經存在",
+ "error.language.name": "語言名稱無效",
+ "error.language.notFound": "找不到語言",
+ "error.language.update.permission": "你沒有變更語言設定的權限",
+
+ "error.layout.validation.block": "在版面組態第 {layoutIndex} 區塊中,使用「{fieldset}」區塊類型的第 {blockIndex} 區塊內,欄位「{field}」發生錯誤",
+ "error.layout.validation.settings": "第 {index} 個版面組態的設定有誤",
+
+ "error.license.domain": "授權的網域名稱無效或不符",
+ "error.license.email": "Please enter a valid email address",
+ "error.license.format": "授權碼格式錯誤",
+ "error.license.verification": "授權驗證失敗",
+
+ "error.login.totp.confirm.invalid": "驗證碼無效,請重新確認",
+ "error.login.totp.confirm.missing": "請輸入驗證碼",
+
+ "error.object.validation": "欄位「{label}」有錯誤:\n{message}",
+
+ "error.offline": "系統目前離線,請稍後再試",
+
+ "error.page.changeSlug.permission": "\u7121\u6cd5\u66f4\u6539\u9801\u9762 URL",
+ "error.page.changeSlug.reserved": "頂層頁面的路徑不得以「{path}」作為開頭",
+ "error.page.changeStatus.incomplete": "請填寫所有必要欄位後再變更狀態",
+ "error.page.changeStatus.permission": "你沒有變更頁面狀態的權限",
+ "error.page.changeStatus.toDraft.invalid": "頁面「{slug}」無法轉換為草稿狀態",
+ "error.page.changeTemplate.invalid": "頁面「{slug}」的樣板無法變更",
+ "error.page.changeTemplate.permission": "你沒有變更「{slug}」樣板的權限",
+ "error.page.changeTitle.empty": "請輸入頁面標題",
+ "error.page.changeTitle.permission": "你沒有變更「{slug}」標題的權限",
+ "error.page.create.permission": "你沒有建立「{slug}」標題的權限",
+ "error.page.delete": "無法刪除頁面「{slug}」",
+ "error.page.delete.confirm": "你確定要刪除這個頁面嗎?",
+ "error.page.delete.hasChildren": "此頁面下有子頁面,請先刪除子頁面",
+ "error.page.delete.multiple": "刪除多個頁面時發生錯誤",
+ "error.page.delete.permission": "你沒有刪除「{slug}」的權限",
+ "error.page.draft.duplicate": "已經有草稿頁面使用「{slug}」作為網址附加碼",
+ "error.page.duplicate": "已有使用網址附加碼「{slug}」的頁面存在",
+ "error.page.duplicate.permission": "你沒有複製「{slug}」的權限",
+ "error.page.move.ancestor": "無法移動到其子孫頁面下",
+ "error.page.move.directory": "移動頁面失敗,資料夾錯誤",
+ "error.page.move.duplicate": "已有使用網址附加碼「{slug}」的子頁面存在",
+ "error.page.move.noSections": "頁面「{parent}」因藍圖中未包含任何頁面區段,無法成為其他頁面的上層頁面",
+ "error.page.move.notFound": "找不到要移動的頁面",
+ "error.page.move.permission": "你沒有移動「{slug}」的權限",
+ "error.page.move.template": "樣板「{template}」不被允許作為「{parent}」的子頁面",
+ "error.page.notFound": "\u627e\u4e0d\u5230\u9801\u9762",
+ "error.page.num.invalid": "頁面排序編號無效",
+ "error.page.slug.invalid": "頁面網址無效,只能使用小寫英數、連字號與底線",
+ "error.page.slug.maxlength": "網址附加碼長度必須少於 {length} 個字元",
+ "error.page.sort.permission": "頁面「{slug}」無法進行排序",
+ "error.page.status.invalid": "頁面狀態無效",
+ "error.page.undefined": "\u627e\u4e0d\u5230\u9801\u9762",
+ "error.page.update.permission": "你沒有更新「{slug}」的權限",
+
+ "error.section.files.max.plural": "「{section}」區段中最多只能加入 {max} 個檔案",
+ "error.section.files.max.singular": "「{section}」區段中最多只能加入 1 個檔案",
+ "error.section.files.min.plural": "「{section}」區段中至少需要 {min} 個檔案",
+ "error.section.files.min.singular": "「{section}」區段中至少需要 1 個檔案",
+
+ "error.section.pages.max.plural": "「{section}」區段中最多只能加入 {max} 個頁面",
+ "error.section.pages.max.singular": "「{section}」區段中最多只能加入 1 個頁面",
+ "error.section.pages.min.plural": "「{section}」區段中至少需要 {min} 個頁面",
+ "error.section.pages.min.singular": "「{section}」區段中至少需要 1 個頁面",
+
+ "error.section.notLoaded": "無法載入「{name}」區段",
+ "error.section.type.invalid": "區段類型「{type}」無效",
+
+ "error.site.changeTitle.empty": "網站標題不得為空",
+ "error.site.changeTitle.permission": "你沒有變更網站標題的權限",
+ "error.site.update.permission": "你沒有變更網站設定的權限",
+
+ "error.structure.validation": "第 {index} 列的「{field}」欄位發生錯誤",
+
+ "error.template.default.notFound": "找不到預設樣板",
+
+ "error.unexpected": "發生未預期的錯誤!如需更多資訊,請啟用除錯模式:https://getkirby.com/docs/reference/system/options/debug",
+
+ "error.user.changeEmail.permission": "你沒有變更使用者「{name}」電子郵件的權限",
+ "error.user.changeLanguage.permission": "你沒有變更使用者「{name}」語言的權限",
+ "error.user.changeName.permission": "你沒有變更使用者「{name}」名稱的權限",
+ "error.user.changePassword.permission": "你沒有變更使用者「{name}」密碼的權限",
+ "error.user.changeRole.lastAdmin": "無法取消最後一位管理員的權限",
+ "error.user.changeRole.permission": "你沒有變更使用者「{name}」角色的權限",
+ "error.user.changeRole.toAdmin": "你無法將自己升級為管理員",
+ "error.user.create.permission": "你沒有新增使用者的權限",
+ "error.user.delete": "\u8a72\u4f7f\u7528\u8005\u4e0d\u53ef\u88ab\u522a\u9664",
+ "error.user.delete.lastAdmin": "\u4f60\u7121\u6cd5\u522a\u9664\u6700\u5f8c\u7684\u7ba1\u7406\u8005",
+ "error.user.delete.lastUser": "無法刪除最後一位使用者",
+ "error.user.delete.permission": "\u4f60\u7121\u6cd5\u4fee\u6539\u6b64\u4f7f\u7528\u8005",
+ "error.user.duplicate": "已有使用者使用電子郵件位址「{email}」",
+ "error.user.email.invalid": "Please enter a valid email address",
+ "error.user.language.invalid": "無效的語言代碼",
+ "error.user.notFound": "\u627e\u4e0d\u5230\u4f7f\u7528\u8005",
+ "error.user.password.excessive": "請輸入有效的密碼。密碼長度不得超過 1000 個字元。",
+ "error.user.password.invalid": "請輸入有效的密碼。密碼長度至少需為 8 個字元。",
+ "error.user.password.notSame": "\u8acb\u78ba\u8a8d\u5bc6\u78bc\u7121\u8aa4",
+ "error.user.password.undefined": "請輸入密碼",
+ "error.user.password.wrong": "密碼錯誤",
+ "error.user.role.invalid": "權限設定無效",
+ "error.user.undefined": "找不到使用者",
+ "error.user.update.permission": "你沒有更新使用者「{name}」的權限",
+
+ "error.validation.accepted": "請勾選此選項",
+ "error.validation.alpha": "只能包含英文字母",
+ "error.validation.alphanum": "僅能輸入 a-z 的英文字母或數字 0-9",
+ "error.validation.anchor": "無效的錨點格式",
+ "error.validation.between": "必須介於 {min} 到 {max} 之間",
+ "error.validation.boolean": "請選擇是或否",
+ "error.validation.color": "請輸入有效的顏色,格式需為 {format}",
+ "error.validation.contains": "必須包含「{value}」",
+ "error.validation.date": "無效的日期格式",
+ "error.validation.date.after": "日期必須晚於 {date}",
+ "error.validation.date.before": "日期必須早於 {date}",
+ "error.validation.date.between": "日期必須介於 {min} 到 {max} 之間",
+ "error.validation.denied": "不允許的值",
+ "error.validation.different": "此欄位必須與 {other} 不同",
+ "error.validation.email": "Please enter a valid email address",
+ "error.validation.endswith": "必須以「{value}」結尾",
+ "error.validation.filename": "無效的檔名",
+ "error.validation.in": "請輸入以下其中一項:({in})",
+ "error.validation.integer": "請輸入整數",
+ "error.validation.ip": "請輸入有效的 IP 位址",
+ "error.validation.less": "數值必須小於 {max}",
+ "error.validation.linkType": "無效的連結類型",
+ "error.validation.match": "格式不正確",
+ "error.validation.max": "數值不得超過 {max}",
+ "error.validation.maxlength": "請輸入較短的內容(最多 {max} 個字元)",
+ "error.validation.maxwords": "請輸入不超過 {max} 個詞語 (words)",
+ "error.validation.min": "數值不得小於 {min}",
+ "error.validation.minlength": "請輸入較長的內容(至少 {min} 個字元)",
+ "error.validation.minwords": "請輸入至少 {min} 個詞語(words)",
+ "error.validation.more": "數值必須大於 {min}",
+ "error.validation.notcontains": "不得包含「{value}」",
+ "error.validation.notin": "請不要輸入以下任一項:({notIn})",
+ "error.validation.option": "請選擇有效的選項",
+ "error.validation.num": "請輸入數字",
+ "error.validation.required": "此欄位為必填",
+ "error.validation.same": "此欄位必須與 {other} 相同",
+ "error.validation.size": "大小必須為 {size}",
+ "error.validation.startswith": "必須以「{value}」開頭",
+ "error.validation.tel": "請輸入有效的電話號碼",
+ "error.validation.time": "無效的時間格式",
+ "error.validation.time.after": "時間必須晚於 {time}",
+ "error.validation.time.before": "時間必須早於 {time}",
+ "error.validation.time.between": "時間必須介於 {min} 到 {max} 之間",
+ "error.validation.uuid": "請輸入有效的 UUID",
+ "error.validation.url": "請輸入有效的網址",
+
+ "expand": "展開",
+ "expand.all": "全部展開",
+
+ "field.invalid": "欄位無效",
+ "field.required": "此欄位為必填",
+ "field.blocks.changeType": "變更區塊類型",
+ "field.blocks.code.name": "程式碼",
+ "field.blocks.code.language": "慣用語言",
+ "field.blocks.code.placeholder": "輸入程式碼…",
+ "field.blocks.delete.confirm": "你確定要刪除這個區塊嗎?",
+ "field.blocks.delete.confirm.all": "你確定要刪除所有區塊嗎?",
+ "field.blocks.delete.confirm.selected": "你確定要刪除已選取的區塊嗎?",
+ "field.blocks.empty": "尚未加入任何區塊",
+ "field.blocks.fieldsets.empty": "沒有可用的區塊類型",
+ "field.blocks.fieldsets.label": "新增區塊",
+ "field.blocks.fieldsets.paste": "按下 {{shortcut}} 可從剪貼簿匯入版面/區塊,僅會插入目前欄位允許的區塊類型。",
+ "field.blocks.gallery.name": "圖集",
+ "field.blocks.gallery.images.empty": "尚未加入圖片",
+ "field.blocks.gallery.images.label": "圖片",
+ "field.blocks.heading.level": "標題層級",
+ "field.blocks.heading.name": "標題",
+ "field.blocks.heading.text": "標題文字",
+ "field.blocks.heading.placeholder": "輸入標題…",
+ "field.blocks.figure.back.plain": "純色背景",
+ "field.blocks.figure.back.pattern.light": "圖樣(亮色)",
+ "field.blocks.figure.back.pattern.dark": "圖樣(暗色)",
+ "field.blocks.image.alt": "替代文字",
+ "field.blocks.image.caption": "圖片說明",
+ "field.blocks.image.crop": "裁切",
+ "field.blocks.image.link": "連結",
+ "field.blocks.image.location": "圖片位置",
+ "field.blocks.image.location.internal": "內部上傳",
+ "field.blocks.image.location.external": "外部連結",
+ "field.blocks.image.name": "圖片",
+ "field.blocks.image.placeholder": "拖曳或點擊以選擇圖片",
+ "field.blocks.image.ratio": "顯示比例",
+ "field.blocks.image.url": "圖片網址",
+ "field.blocks.line.name": "分隔線",
+ "field.blocks.list.name": "清單",
+ "field.blocks.markdown.name": "Markdown",
+ "field.blocks.markdown.label": "內容",
+ "field.blocks.markdown.placeholder": "輸入 Markdown 內容…",
+ "field.blocks.quote.name": "引言",
+ "field.blocks.quote.text.label": "引言內容",
+ "field.blocks.quote.text.placeholder": "輸入引言文字…",
+ "field.blocks.quote.citation.label": "出處",
+ "field.blocks.quote.citation.placeholder": "輸入引用來源…",
+ "field.blocks.text.name": "段落",
+ "field.blocks.text.placeholder": "輸入段落內容…",
+ "field.blocks.video.autoplay": "自動播放",
+ "field.blocks.video.caption": "影片說明",
+ "field.blocks.video.controls": "顯示控制列",
+ "field.blocks.video.location": "影片位置",
+ "field.blocks.video.loop": "重複播放",
+ "field.blocks.video.muted": "靜音",
+ "field.blocks.video.name": "影片",
+ "field.blocks.video.placeholder": "貼上影片網址或拖曳影片",
+ "field.blocks.video.poster": "預覽縮圖",
+ "field.blocks.video.preload": "預先載入",
+ "field.blocks.video.url.label": "影片網址",
+ "field.blocks.video.url.placeholder": "https://youtube.com/?v=",
+
+ "field.entries.delete.confirm.all": "你確定要刪除所有資料嗎?",
+ "field.entries.empty": "還沒有資料",
+
+ "field.files.empty": "沒有選取任何檔案",
+ "field.files.empty.single": "尚未選取檔案",
+
+ "field.layout.change": "變更版面配置",
+ "field.layout.delete": "刪除版面",
+ "field.layout.delete.confirm": "你確定要刪除這個版面嗎?",
+ "field.layout.delete.confirm.all": "你確定要刪除所有版面嗎?",
+ "field.layout.empty": "尚未設定任何版面",
+ "field.layout.select": "選擇版面",
+
+ "field.object.empty": "尚未填寫內容",
+
+ "field.pages.empty": "沒有選取任何頁面",
+ "field.pages.empty.single": "尚未選取頁面",
+
+ "field.structure.delete.confirm": "\u4f60\u771f\u7684\u8981\u522a\u9664\u6b64\u7b46\u8cc7\u6599\u55ce\uff1f",
+ "field.structure.delete.confirm.all": "你確定要刪除所有資料嗎?",
+ "field.structure.empty": "尚未加入任何資料",
+
+ "field.users.empty": "沒有選取任何使用者",
+ "field.users.empty.single": "尚未選取使用者",
+
+ "fields.empty": "此頁面沒有設定欄位",
+
+ "file": "檔案",
+ "file.blueprint": "此檔案尚未設定藍圖。你可以在 /site/blueprints/files/{blueprint}.yml 中定義設定內容",
+ "file.changeTemplate": "變更樣板",
+ "file.changeTemplate.notice": "變更樣板可能會導致資料遺失",
+ "file.delete.confirm": "\u78ba\u8a8d\u522a\u9664\u6a94\u6848\uff1f",
+ "file.focus.placeholder": "選取聚焦區域",
+ "file.focus.reset": "重設焦點",
+ "file.focus.title": "圖片焦點",
+ "file.sort": "變更檔案順序",
+
+ "files": "附加檔案",
+ "files.delete.confirm.selected": "你確定要刪除已選取的檔案嗎?",
+ "files.empty": "此處沒有檔案",
+
+ "filter": "篩選",
+
+ "form.discard": "放棄變更",
+ "form.discard.confirm": "你確定要放棄未儲存的變更嗎?",
+ "form.locked": "此表單已鎖定,無法編輯",
+ "form.unsaved": "尚有未儲存的變更",
+ "form.preview": "預覽內容",
+ "form.preview.draft": "預覽草稿",
+
+ "hide": "隱藏",
+ "hour": "時",
+ "hue": "色相",
+ "import": "匯入",
+ "info": "資訊",
+ "insert": "\u63d2\u5165",
+ "insert.after": "插入在後",
+ "insert.before": "插入在前",
+ "install": "安裝",
+
+ "installation": "安裝",
+ "installation.completed": "安裝完成",
+ "installation.disabled": "安裝功能已停用",
+ "installation.issues.accounts": "\u60a8\u6c92\u6709\u300c\/site\/accounts\u300d\u8cc7\u6599\u593e\u7684\u4fee\u6539\u6b0a\u9650",
+ "installation.issues.content": "\u60a8\u6c92\u6709\u300c\/content\u300d\u8cc7\u6599\u593e\u7684\u4fee\u6539\u6b0a\u9650",
+ "installation.issues.curl": "伺服器未啟用 cURL,可能會導致某些功能無法使用",
+ "installation.issues.headline": "請先解決下列安裝問題:",
+ "installation.issues.mbstring": "PHP 尚未啟用 mbstring 擴充套件",
+ "installation.issues.media": "無法建立 /media 資料夾或寫入權限不足",
+ "installation.issues.php": "請確保使用 PHP 8 以上版本",
+ "installation.issues.sessions": "PHP sessions 未正確啟用,請檢查伺服器設定",
+
+ "language": "\u6163\u7528\u8a9e\u8a00",
+ "language.code": "語言代碼",
+ "language.convert": "轉換語言",
+ "language.convert.confirm": "你確定要將所有內容轉換為此語言嗎?你確定要將「{name}」轉換為預設語言嗎?此操作無法還原。\n如果「{name}」中有未翻譯的內容,將不會有可用的預設語言做為備援,可能會導致網站部份內容為空。",
+ "language.create": "新增語言",
+ "language.default": "預設語言",
+ "language.delete.confirm": "你確定要刪除語言「{name}」及其所有翻譯內容嗎?此操作無法還原!",
+ "language.deleted": "語言已刪除",
+ "language.direction": "書寫方向",
+ "language.direction.ltr": "由左至右",
+ "language.direction.rtl": "由右至左",
+ "language.locale": "PHP 語系字串",
+ "language.locale.warning": "請確認區域設定符合 PHP 認可的格式",
+ "language.name": "語言名稱",
+ "language.secondary": "次要語言",
+ "language.settings": "語言設定",
+ "language.updated": "語言已更新",
+ "language.variables": "語言變數",
+ "language.variables.empty": "尚未設定語言變數",
+
+ "language.variable.delete.confirm": "你確定要刪除「{key}」這個變數嗎?",
+ "language.variable.entries": "Values",
+ "language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts 0, 1, 2 and more. Use the {count} placeholder to insert the actual count.",
+ "language.variable.key": "變數名稱",
+ "language.variable.multiple": "Countable?",
+ "language.variable.multiple.text": "Use different translation strings",
+ "language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
+ "language.variable.notFound": "找不到指定的語言變數",
+ "language.variable.value": "變數內容",
+
+ "languages": "語言清單",
+ "languages.default": "預設語言",
+ "languages.empty": "尚未設定語言",
+ "languages.secondary": "次要語言清單",
+ "languages.secondary.empty": "尚未設定次要語言",
+
+ "license": "Kirby \u6191\u8b49",
+ "license.activate": "啟用授權",
+ "license.activate.label": "輸入您的授權碼以啟用 Kirby",
+ "license.activate.domain": "你的授權將會啟用於 {host}",
+ "license.activate.local": "你即將為本機網域 {host} 啟用你的 Kirby 授權。\n如果這個網站將部署到公開網域,請改為在公開網域上啟用授權。\n如果 {host} 就是你要使用授權的網域,請繼續操作。",
+ "license.activated": "授權已啟用",
+ "license.buy": "購買授權",
+ "license.code": "授權碼",
+ "license.code.help": "您可以在購買確認信中找到授權碼",
+ "license.code.label": "輸入授權碼",
+ "license.status.active.info": "包含至 {date} 前的新主要版本",
+ "license.status.active.label": "已啟用",
+ "license.status.demo.info": "此為試用版安裝,僅供開發或測試使用",
+ "license.status.demo.label": "試用中",
+ "license.status.inactive.info": "此網站尚未啟用授權",
+ "license.status.inactive.label": "未啟用",
+ "license.status.legacy.bubble": "舊版授權",
+ "license.status.legacy.info": "此授權使用的是舊版授權機制",
+ "license.status.legacy.label": "舊版授權",
+ "license.status.missing.bubble": "缺少授權",
+ "license.status.missing.info": "請輸入有效授權碼以使用 Kirby",
+ "license.status.missing.label": "未授權",
+ "license.status.unknown.info": "無法確認授權狀態,請檢查您的網路連線",
+ "license.status.unknown.label": "未知狀態",
+ "license.manage": "管理授權",
+ "license.purchased": "您已購買授權",
+ "license.success": "授權啟用成功",
+ "license.unregistered.label": "未註冊",
+
+ "link": "\u9023\u7d50",
+ "link.text": "\u9023\u7d50\u6587\u5b57",
+
+ "loading": "載入中…",
+
+ "lock.unsaved": "有尚未儲存的變更",
+ "lock.unsaved.empty": "所有變更已儲存",
+ "lock.unsaved.files": "有檔案變更尚未儲存",
+ "lock.unsaved.pages": "有頁面變更尚未儲存",
+ "lock.unsaved.users": "有使用者變更尚未儲存",
+ "lock.isLocked": "{email} 尚未儲存的變更",
+ "lock.unlock": "解除鎖定",
+ "lock.unlock.submit": "解鎖並覆蓋 {email} 尚未儲存的變更",
+ "lock.isUnlocked": "目前已解除鎖定",
+
+ "login": "登入",
+ "login.code.label.login": "使用一次性登入碼登入",
+ "login.code.label.password-reset": "重設密碼的安全碼",
+ "login.code.placeholder.email": "000 000",
+ "login.code.placeholder.totp": "000000",
+ "login.code.text.email": "我們已寄送一組登入連結至你的電子信箱",
+ "login.code.text.totp": "請輸入兩步驟驗證碼",
+ "login.email.login.body": "嗨 {user.nameOrEmail},\n\n你最近請求了 {site} 控製臺的登入驗證碼。\n以下驗證碼在 {timeout} 分鐘內有效:\n\n{code}\n\n如果你並未請求此驗證碼,請忽略此封信;如有疑問,請聯絡你的管理員。\n為了安全起見,請不要轉寄此封電子郵件。",
+ "login.email.login.subject": "您的 Kirby 登入連結",
+ "login.email.password-reset.body": "嗨 {user.nameOrEmail},\n\n你最近請求了 {site} 控製臺的密碼重設驗證碼。\n以下驗證碼在 {timeout} 分鐘內有效:\n\n{code}\n\n如果你並未請求此驗證碼,請忽略此封信;如有疑問,請聯絡你的管理員。\n為了安全起見,請不要轉寄此封電子郵件。",
+ "login.email.password-reset.subject": "您的 Kirby 密碼重設連結",
+ "login.remember": "記住我",
+ "login.reset": "重設密碼",
+ "login.toggleText.code.email": "使用電子信箱登入",
+ "login.toggleText.code.email-password": "使用電子信箱與密碼登入",
+ "login.toggleText.password-reset.email": "忘記密碼?使用電子信箱重設",
+ "login.toggleText.password-reset.email-password": "返回使用密碼登入",
+ "login.totp.enable.option": "設定一次性驗證碼",
+ "login.totp.enable.intro": "增加帳號安全性,需使用驗證器應用程式",
+ "login.totp.enable.qr.label": "1. 掃描這個 QR 碼",
+ "login.totp.enable.qr.help": "無法掃描嗎?請將設定金鑰 {secret} 手動加入你的驗證器 App。",
+ "login.totp.enable.confirm.headline": "2. 使用產生的驗證碼進行確認",
+ "login.totp.enable.confirm.text": "你的 App 每 30 秒會產生一組新的一次性驗證碼。請輸入目前的驗證碼以完成設定:",
+ "login.totp.enable.confirm.label": "驗證碼",
+ "login.totp.enable.confirm.help": "來自驗證器 App 的 6 位數碼",
+ "login.totp.enable.success": "已成功啟用兩步驟驗證",
+ "login.totp.disable.option": "停用兩步驟驗證",
+ "login.totp.disable.label": "停用驗證",
+ "login.totp.disable.help": "您的帳號將不再需要驗證器登入",
+ "login.totp.disable.admin": "這將會停用 {user} 的一次性驗證碼。\n未來他們登入時,系統會改為要求其他第二驗證方式,例如透過電子郵件傳送的登入碼。\n{user} 可於下次登入後重新設定一次性驗證碼。",
+ "login.totp.disable.success": "已成功停用兩步驟驗證",
+
+ "logout": "登出",
+
+ "merge": "合併",
+ "menu": "選單",
+ "meridiem": "上午/下午",
+ "mime": "MIME 類型",
+ "minutes": "分鐘",
+
+ "month": "月",
+ "months.april": "\u56db\u6708",
+ "months.august": "\u516b\u6708",
+ "months.december": "\u5341\u4e8c\u6708",
+ "months.february": "二月",
+ "months.january": "\u4e00\u6708",
+ "months.july": "\u4e03\u6708",
+ "months.june": "\u516d\u6708",
+ "months.march": "\u4e09\u6708",
+ "months.may": "\u4e94\u6708",
+ "months.november": "\u5341\u4e00\u6708",
+ "months.october": "\u5341\u6708",
+ "months.september": "\u4e5d\u6708",
+
+ "more": "更多",
+ "move": "移動",
+ "name": "名稱",
+ "next": "下一步",
+ "night": "夜間",
+ "no": "否",
+ "off": "關閉",
+ "on": "開啟",
+ "open": "開啟",
+ "open.newWindow": "在新視窗開啟",
+ "option": "選項",
+ "options": "多選項",
+ "options.none": "無可用選項",
+ "options.all": "顯示全部 {count} 個選項",
+
+ "orientation": "方向",
+ "orientation.landscape": "橫向",
+ "orientation.portrait": "直向",
+ "orientation.square": "正方形",
+
+ "page": "頁",
+ "page.blueprint": "此頁面尚未設定藍圖。你可以在 /site/blueprints/pages/{blueprint}.yml 中定義設定內容。",
+ "page.changeSlug": "\u66f4\u6539\u9801\u9762\u7db2\u5740",
+ "page.changeSlug.fromTitle": "\u5f9e\u9801\u9762\u6a19\u984c\u8f38\u5165",
+ "page.changeStatus": "變更狀態",
+ "page.changeStatus.position": "頁面位置",
+ "page.changeStatus.select": "選擇狀態",
+ "page.changeTemplate": "變更樣板",
+ "page.changeTemplate.notice": "變更樣板可能會導致部分資料遺失,請小心操作",
+ "page.create": "建立為 {status}",
+ "page.delete.confirm": "\u78ba\u8a8d\u522a\u9664\u6b64\u9801\u9762\uff1f",
+ "page.delete.confirm.subpages": "此頁面下仍有子頁面,是否一併刪除?",
+ "page.delete.confirm.title": "請輸入頁面標題以確認",
+ "page.duplicate.appendix": "Copy",
+ "page.duplicate.files": "複製檔案",
+ "page.duplicate.pages": "複製子頁面",
+ "page.move": "移動頁面",
+ "page.sort": "排序頁面",
+ "page.status": "頁面狀態",
+ "page.status.draft": "草稿",
+ "page.status.draft.description": "該頁面處於草稿模式,僅對已登入的編輯者或透過秘密連結可見",
+ "page.status.listed": "已列出",
+ "page.status.listed.description": "該頁面對所有人公開",
+ "page.status.unlisted": "未列出",
+ "page.status.unlisted.description": "該頁面僅能透過網址存取",
+
+ "pages": "頁面",
+ "pages.delete.confirm.selected": "你確定要刪除所有選取的頁面嗎?",
+ "pages.empty": "目前沒有任何頁面",
+ "pages.status.draft": "草稿",
+ "pages.status.listed": "已列出",
+ "pages.status.unlisted": "未列出",
+
+ "pagination.page": "頁",
+
+ "password": "\u5bc6\u78bc",
+ "paste": "貼上",
+ "paste.after": "貼在後方",
+ "paste.success": "已貼上 {count} 個項目!",
+ "pixel": "像素",
+ "plugin": "外掛",
+ "plugins": "外掛列表",
+ "prev": "上一步",
+ "preview": "預覽",
+
+ "publish": "發佈",
+ "published": "已發佈",
+
+ "remove": "移除",
+ "rename": "重新命名",
+ "renew": "重新啟用",
+ "replace": "\u66f4\u63db",
+ "replace.with": "取代為…",
+ "retry": "\u91cd\u8a66",
+ "revert": "\u653e\u68c4",
+ "revert.confirm": "你確定要還原變更嗎?",
+
+ "role": "\u6b0a\u9650",
+ "role.admin.description": "具有所有權限,可管理使用者與網站設定",
+ "role.admin.title": "管理員",
+ "role.all": "所有角色",
+ "role.empty": "尚未設定角色",
+ "role.description.placeholder": "角色描述…",
+ "role.nobody.description": "無法登入後台的訪客角色",
+ "role.nobody.title": "訪客",
+
+ "save": "\u5132\u5b58",
+ "saved": "已儲存",
+ "search": "搜尋",
+ "searching": "搜尋中…",
+ "search.min": "請至少輸入 {min} 個字元",
+ "search.all": "顯示全部 {count} 筆結果",
+ "search.results.none": "找不到符合的結果",
+
+ "section.invalid": "區段無效",
+ "section.required": "此區段為必填",
+
+ "security": "安全性",
+ "select": "選取",
+ "server": "伺服器",
+ "settings": "設定",
+ "show": "顯示",
+ "site.blueprint": "網站藍圖",
+ "size": "大小",
+ "slug": "\u9801\u9762\u7db2\u5740",
+ "sort": "排序",
+ "sort.drag": "拖曳以排序",
+ "split": "分割",
+
+ "stats.empty": "目前沒有統計資料",
+ "status": "狀態",
+
+ "system.info.copy": "複製系統資訊",
+ "system.info.copied": "系統資訊已複製",
+ "system.issues.content": "無法寫入 /content 資料夾",
+ "system.issues.eol.kirby": "你正在使用已終止維護的 Kirby 版本",
+ "system.issues.eol.plugin": "你安裝的 {plugin} 外掛已達到生命週期終點,將不再收到安全性更新。",
+ "system.issues.eol.php": "你安裝的 PHP 版本 {release} 已達生命週期終點,將不再收到安全性更新。",
+ "system.issues.debug": "目前系統處於除錯模式",
+ "system.issues.git": "專案資料夾中未偵測到 Git 儲存庫",
+ "system.issues.https": "網站未透過 HTTPS 保護",
+ "system.issues.kirby": "Kirby 核心檔案可能已變更,請重新安裝",
+ "system.issues.local": "網站可能仍在本機開發環境中運行",
+ "system.issues.site": "找不到 site/config.php 或檔案設定錯誤",
+ "system.issues.vue.compiler": "Vue 樣板編譯器已啟用",
+ "system.issues.vulnerability.kirby": "你的安裝可能受到以下漏洞影響(嚴重性:{severity}):{description}",
+ "system.issues.vulnerability.plugin": "你的安裝可能受到 {plugin} 外掛中以下漏洞影響(嚴重性:{severity}):{description}",
+ "system.updateStatus": "更新狀態",
+ "system.updateStatus.error": "無法檢查更新狀態",
+ "system.updateStatus.not-vulnerable": "目前版本無安全漏洞",
+ "system.updateStatus.security-update": "有免費的安全性更新版本 {version} 可供下載",
+ "system.updateStatus.security-upgrade": "有包含安全修正的版本 {version} 可供升級",
+ "system.updateStatus.unreleased": "目前為尚未正式發佈的版本",
+ "system.updateStatus.up-to-date": "已是最新版本",
+ "system.updateStatus.update": "有免費更新版本 {version} 可供下載",
+ "system.updateStatus.upgrade": "可升級至版本 {version}",
+
+ "tel": "電話",
+ "tel.placeholder": "+49123456789",
+ "template": "頁面樣板",
+
+ "theme": "主題",
+ "theme.light": "亮色主題",
+ "theme.dark": "暗色主題",
+ "theme.automatic": "配合系統預設",
+
+ "title": "頁面標題",
+ "today": "今天",
+
+ "toolbar.button.clear": "清除格式",
+ "toolbar.button.code": "程式碼",
+ "toolbar.button.bold": "\u7c97\u9ad4",
+ "toolbar.button.email": "電子郵件",
+ "toolbar.button.headings": "標題",
+ "toolbar.button.heading.1": "標題 1",
+ "toolbar.button.heading.2": "標題 2",
+ "toolbar.button.heading.3": "標題 3",
+ "toolbar.button.heading.4": "標題 4",
+ "toolbar.button.heading.5": "標題 5",
+ "toolbar.button.heading.6": "標題 6",
+ "toolbar.button.italic": "\u659c\u9ad4",
+ "toolbar.button.file": "檔案",
+ "toolbar.button.file.select": "選擇檔案",
+ "toolbar.button.file.upload": "上傳檔案",
+ "toolbar.button.link": "\u9023\u7d50",
+ "toolbar.button.paragraph": "段落",
+ "toolbar.button.strike": "刪除線",
+ "toolbar.button.sub": "下標",
+ "toolbar.button.sup": "上標",
+ "toolbar.button.ol": "有序清單",
+ "toolbar.button.underline": "底線",
+ "toolbar.button.ul": "無序清單",
+
+ "translation.author": "翻譯者",
+ "translation.direction": "ltr",
+ "translation.name": "正體中文",
+ "translation.locale": "zh_TW",
+
+ "type": "類型",
+
+ "upload": "上傳",
+ "upload.error.cantMove": "檔案無法移動到目標位置",
+ "upload.error.cantWrite": "檔案無法寫入磁碟",
+ "upload.error.default": "上傳時發生未知錯誤",
+ "upload.error.extension": "副檔名不被允許",
+ "upload.error.formSize": "檔案超出表單限制的大小",
+ "upload.error.iniPostSize": "檔案超出 PHP 設定中的最大值",
+ "upload.error.iniSize": "檔案大小超過上傳限制",
+ "upload.error.noFile": "沒有選擇任何檔案",
+ "upload.error.noFiles": "找不到任何可上傳的檔案",
+ "upload.error.partial": "檔案僅部分上傳",
+ "upload.error.tmpDir": "找不到暫存資料夾",
+ "upload.errors": "上傳錯誤",
+ "upload.progress": "上傳進度",
+
+ "url": "網址",
+ "url.placeholder": "例如:https://example.com",
+
+ "user": "使用者",
+ "user.blueprint": "你可以在 /site/blueprints/users/{blueprint}.yml 中為此使用者角色定義額外的區段與表單欄位。",
+ "user.changeEmail": "變更電子信箱",
+ "user.changeLanguage": "變更語言",
+ "user.changeName": "變更名稱",
+ "user.changePassword": "變更密碼",
+ "user.changePassword.current": "目前密碼",
+ "user.changePassword.new": "更改密碼",
+ "user.changePassword.new.confirm": "確認新密碼",
+ "user.changeRole": "變更角色",
+ "user.changeRole.select": "選擇新角色",
+ "user.create": "新增使用者",
+ "user.delete": "刪除使用者",
+ "user.delete.confirm": "你確定要刪除「{email}」嗎?",
+
+ "users": "使用者",
+
+ "version": "版本",
+ "version.changes": "版本變更紀錄",
+ "version.compare": "比較版本",
+ "version.current": "目前版本",
+ "version.latest": "最新版本",
+ "versionInformation": "版本資訊",
+
+ "view": "檢視",
+ "view.account": "\u60a8\u7684\u5e33\u865f",
+ "view.installation": "\u5b89\u88dd",
+ "view.languages": "語言管理",
+ "view.resetPassword": "重設密碼",
+ "view.site": "網站設定",
+ "view.system": "系統資訊",
+ "view.users": "\u4f7f\u7528\u8005",
+
+ "welcome": "歡迎使用 Kirby",
+ "year": "年",
+ "yes": "是"
+}
diff --git a/public/kirby/router.php b/public/kirby/router.php
index ea8cc86..86fb005 100644
--- a/public/kirby/router.php
+++ b/public/kirby/router.php
@@ -5,11 +5,17 @@
$uri = parse_url('https://getkirby.com/' . ltrim($_SERVER['REQUEST_URI'], '/'), PHP_URL_PATH) ?? '/';
$uri = urldecode($uri);
-// Emulate Apache's `mod_rewrite` functionality
-if ($uri !== '/' && file_exists($_SERVER['DOCUMENT_ROOT'] . '/' . ltrim($uri, '/'))) {
+// emulate Apache's `mod_rewrite` functionality, but prevent
+// disclosure of the existence of files outside the document root
+$path = $_SERVER['DOCUMENT_ROOT'] . '/' . ltrim($uri, '/');
+if (
+ $uri !== '/' &&
+ file_exists($path) === true &&
+ substr(realpath($path), 0, strlen($_SERVER['DOCUMENT_ROOT'])) === $_SERVER['DOCUMENT_ROOT']
+) {
return false;
}
$_SERVER['SCRIPT_NAME'] = '/index.php';
-require $_SERVER['DOCUMENT_ROOT'] . '/' . $_SERVER['SCRIPT_NAME'];
+require $_SERVER['DOCUMENT_ROOT'] . $_SERVER['SCRIPT_NAME'];
diff --git a/public/kirby/src/Api/Api.php b/public/kirby/src/Api/Api.php
index e7647b3..b79633a 100644
--- a/public/kirby/src/Api/Api.php
+++ b/public/kirby/src/Api/Api.php
@@ -12,9 +12,7 @@ use Kirby\Http\Response;
use Kirby\Http\Route;
use Kirby\Http\Router;
use Kirby\Toolkit\Collection as BaseCollection;
-use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Pagination;
-use Kirby\Toolkit\Str;
use Throwable;
/**
@@ -212,7 +210,7 @@ class Api
*/
public function clone(array $props = []): static
{
- return new static(array_merge([
+ return new static([
'autentication' => $this->authentication,
'data' => $this->data,
'routes' => $this->routes,
@@ -220,8 +218,9 @@ class Api
'collections' => $this->collections,
'models' => $this->models,
'requestData' => $this->requestData,
- 'requestMethod' => $this->requestMethod
- ], $props));
+ 'requestMethod' => $this->requestMethod,
+ ...$props
+ ]);
}
/**
@@ -235,7 +234,9 @@ class Api
array|BaseCollection|null $collection = null
): Collection {
if (isset($this->collections[$name]) === false) {
- throw new NotFoundException(sprintf('The collection "%s" does not exist', $name));
+ throw new NotFoundException(
+ message: sprintf('The collection "%s" does not exist', $name)
+ );
}
return new Collection($this, $collection, $this->collections[$name]);
@@ -262,7 +263,9 @@ class Api
}
if ($this->hasData($key) === false) {
- throw new NotFoundException(sprintf('Api data for "%s" does not exist', $key));
+ throw new NotFoundException(
+ message: sprintf('Api data for "%s" does not exist', $key)
+ );
}
// lazy-load data wrapped in Closures
@@ -322,7 +325,9 @@ class Api
$name ??= $this->match($this->models, $object);
if (isset($this->models[$name]) === false) {
- throw new NotFoundException(sprintf('The model "%s" does not exist', $name ?? 'NULL'));
+ throw new NotFoundException(
+ message: sprintf('The model "%s" does not exist', $name ?? 'NULL')
+ );
}
return new Model($this, $object, $this->models[$name]);
@@ -431,7 +436,9 @@ class Api
return $this->collection($collection, $object);
}
- throw new NotFoundException(sprintf('The object "%s" cannot be resolved', get_class($object)));
+ throw new NotFoundException(
+ message: sprintf('The object "%s" cannot be resolved', $object::class)
+ );
}
/**
@@ -541,7 +548,7 @@ class Api
'status' => 'error',
'message' => $e->getMessage(),
'code' => empty($e->getCode()) === true ? 500 : $e->getCode(),
- 'exception' => get_class($e),
+ 'exception' => $e::class,
'key' => null,
'file' => F::relativepath($e->getFile(), $docRoot),
'line' => $e->getLine(),
@@ -577,13 +584,12 @@ class Api
protected function setRequestData(
array|null $requestData = []
): static {
- $defaults = [
+ $this->requestData = [
'query' => [],
'body' => [],
- 'files' => []
+ 'files' => [],
+ ...$requestData ?? []
];
-
- $this->requestData = array_merge($defaults, (array)$requestData);
return $this;
}
@@ -611,131 +617,6 @@ class Api
bool $single = false,
bool $debug = false
): array {
- $trials = 0;
- $uploads = [];
- $errors = [];
- $files = $this->requestFiles();
-
- // get error messages from translation
- $errorMessages = [
- UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
- UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
- UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
- UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
- UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
- UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
- UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
- ];
-
- if (empty($files) === true) {
- $postMaxSize = Str::toBytes(ini_get('post_max_size'));
- $uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
-
- if ($postMaxSize < $uploadMaxFileSize) {
- throw new Exception(
- I18n::translate(
- 'upload.error.iniPostSize',
- 'The uploaded file exceeds the post_max_size directive in php.ini'
- )
- );
- }
-
- throw new Exception(
- I18n::translate(
- 'upload.error.noFiles',
- 'No files were uploaded'
- )
- );
- }
-
- foreach ($files as $upload) {
- if (
- isset($upload['tmp_name']) === false &&
- is_array($upload) === true
- ) {
- continue;
- }
-
- $trials++;
-
- try {
- if ($upload['error'] !== 0) {
- throw new Exception(
- $errorMessages[$upload['error']] ??
- I18n::translate('upload.error.default', 'The file could not be uploaded')
- );
- }
-
- // get the extension of the uploaded file
- $extension = F::extension($upload['name']);
-
- // try to detect the correct mime and add the extension
- // accordingly. This will avoid .tmp filenames
- if (
- empty($extension) === true ||
- in_array($extension, ['tmp', 'temp']) === true
- ) {
- $mime = F::mime($upload['tmp_name']);
- $extension = F::mimeToExtension($mime);
- $filename = F::name($upload['name']) . '.' . $extension;
- } else {
- $filename = basename($upload['name']);
- }
-
- $source = dirname($upload['tmp_name']) . '/' . uniqid() . '.' . $filename;
-
- // move the file to a location including the extension,
- // for better mime detection
- if (
- $debug === false &&
- move_uploaded_file($upload['tmp_name'], $source) === false
- ) {
- throw new Exception(
- I18n::translate('upload.error.cantMove')
- );
- }
-
- $data = $callback($source, $filename);
-
- if (is_object($data) === true) {
- $data = $this->resolve($data)->toArray();
- }
-
- $uploads[$upload['name']] = $data;
- } catch (Exception $e) {
- $errors[$upload['name']] = $e->getMessage();
- }
-
- if ($single === true) {
- break;
- }
- }
-
- // return a single upload response
- if ($trials === 1) {
- if (empty($errors) === false) {
- return [
- 'status' => 'error',
- 'message' => current($errors)
- ];
- }
-
- return [
- 'status' => 'ok',
- 'data' => current($uploads)
- ];
- }
-
- if (empty($errors) === false) {
- return [
- 'status' => 'error',
- 'errors' => $errors
- ];
- }
-
- return [
- 'status' => 'ok',
- 'data' => $uploads
- ];
+ return (new Upload($this, $single, $debug))->process($callback);
}
}
diff --git a/public/kirby/src/Api/Collection.php b/public/kirby/src/Api/Collection.php
index af66235..c9a9a16 100644
--- a/public/kirby/src/Api/Collection.php
+++ b/public/kirby/src/Api/Collection.php
@@ -40,7 +40,7 @@ class Collection
if ($data === null) {
if (($schema['default'] ?? null) instanceof Closure === false) {
- throw new Exception('Missing collection data');
+ throw new Exception(message: 'Missing collection data');
}
$this->data = $schema['default']->call($this->api);
@@ -50,7 +50,7 @@ class Collection
isset($schema['type']) === true &&
$this->data instanceof $schema['type'] === false
) {
- throw new Exception('Invalid collection type');
+ throw new Exception(message: 'Invalid collection type');
}
}
@@ -69,7 +69,7 @@ class Collection
}
if ($keys !== null && is_array($keys) === false) {
- throw new Exception('Invalid select keys');
+ throw new Exception(message: 'Invalid select keys');
}
$this->select = $keys;
diff --git a/public/kirby/src/Api/Controller/Changes.php b/public/kirby/src/Api/Controller/Changes.php
new file mode 100644
index 0000000..12c59d9
--- /dev/null
+++ b/public/kirby/src/Api/Controller/Changes.php
@@ -0,0 +1,137 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://getkirby.com/license
+ */
+class Changes
+{
+ /**
+ * Cleans up legacy lock files. The `discard`, `publish` and `save` actions
+ * are perfect for this cleanup job. They will be stopped early if
+ * the lock is still active and otherwise, we can use them to clean
+ * up outdated .lock files to keep the content folders clean. This
+ * can be removed as soon as old .lock files should no longer be around.
+ *
+ * @todo Remove in 6.0.0
+ */
+ protected static function cleanup(ModelWithContent $model): void
+ {
+ F::remove(Lock::legacyFile($model));
+ }
+
+ /**
+ * Discards unsaved changes by deleting the changes version
+ */
+ public static function discard(ModelWithContent $model): array
+ {
+ $model->version('changes')->delete('current');
+
+ // Removes the old .lock file when it is no longer needed
+ // @todo Remove in 6.0.0
+ static::cleanup($model);
+
+ return [
+ 'status' => 'ok'
+ ];
+ }
+
+ /**
+ * Saves the lastest state of changes first and then publishes them
+ */
+ public static function publish(ModelWithContent $model, array $input): array
+ {
+ // save the given changes first
+ static::save(
+ model: $model,
+ input: $input
+ );
+
+ // Removes the old .lock file when it is no longer needed
+ // @todo Remove in 6.0.0
+ static::cleanup($model);
+
+ // get the changes version
+ $changes = $model->version('changes');
+
+ // if the changes version does not exist, we need to return early
+ if ($changes->exists('current') === false) {
+ return [
+ 'status' => 'ok',
+ ];
+ }
+
+ // publish the changes
+ $changes->publish(
+ language: 'current'
+ );
+
+ return [
+ 'status' => 'ok'
+ ];
+ }
+
+ /**
+ * Saves form input in a new or existing `changes` version
+ */
+ public static function save(ModelWithContent $model, array $input): array
+ {
+ // Removes the old .lock file when it is no longer needed
+ // @todo Remove in 6.0.0
+ static::cleanup($model);
+
+ // get the current language
+ $language = Language::ensure('current');
+
+ // create the fields instance for the model
+ $fields = Fields::for($model, $language);
+
+ // get the changes and latest version for the model
+ $changes = $model->version('changes');
+ $latest = $model->version('latest');
+
+ // get the source version for the existing content
+ $source = $changes->exists($language) === true ? $changes : $latest;
+ $content = $source->content($language)->toArray();
+
+ // fill in the form values and pass through any values that are not
+ // defined as fields, such as uuid, title or similar.
+ $fields->fill(input: $content);
+
+ // submit the new values from the request input
+ $fields->submit(input: $input);
+
+ // save the changes
+ $changes->save(
+ fields: $fields->toStoredValues(),
+ language: $language
+ );
+
+ // if the changes are identical to the latest version,
+ // we can delete the changes version already at this point
+ if ($changes->isIdentical(version: $latest, language: $language)) {
+ $changes->delete(
+ language: $language
+ );
+ }
+
+ return [
+ 'status' => 'ok'
+ ];
+ }
+}
diff --git a/public/kirby/src/Api/Model.php b/public/kirby/src/Api/Model.php
index 24af00b..fa3a000 100644
--- a/public/kirby/src/Api/Model.php
+++ b/public/kirby/src/Api/Model.php
@@ -49,7 +49,7 @@ class Model
if ($data === null) {
if (($schema['default'] ?? null) instanceof Closure === false) {
- throw new Exception('Missing model data');
+ throw new Exception(message: 'Missing model data');
}
$this->data = $schema['default']->call($this->api);
@@ -61,7 +61,7 @@ class Model
) {
$class = match ($this->data) {
null => 'null',
- default => get_class($this->data),
+ default => $this->data::class,
};
throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', $class, $schema['type']));
}
@@ -82,7 +82,7 @@ class Model
}
if ($keys !== null && is_array($keys) === false) {
- throw new Exception('Invalid select keys');
+ throw new Exception(message: 'Invalid select keys');
}
$this->select = $keys;
@@ -109,7 +109,7 @@ class Model
if (is_string($value) === true) {
if ($value === 'any') {
- throw new Exception('Invalid sub view: "any"');
+ throw new Exception(message: 'Invalid sub view: "any"');
}
$selection[$key] = [
diff --git a/public/kirby/src/Api/Upload.php b/public/kirby/src/Api/Upload.php
new file mode 100644
index 0000000..adf301d
--- /dev/null
+++ b/public/kirby/src/Api/Upload.php
@@ -0,0 +1,436 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://getkirby.com/license
+ * @since 5.0.0
+ * @unstable
+ */
+readonly class Upload
+{
+ public function __construct(
+ protected Api $api,
+ protected bool $single = true,
+ protected bool $debug = false
+ ) {
+ }
+
+ /**
+ * Ensures a clean chunk ID by stripping forbidden characters
+ *
+ * @throws \Kirby\Exception\InvalidArgumentException Too short ID string
+ */
+ public static function chunkId(string $id): string
+ {
+ $id = Str::slug($id, '', 'a-z0-9');
+
+ if (strlen($id) < 3) {
+ throw new InvalidArgumentException(
+ message: 'Chunk ID must at least be 3 characters long'
+ );
+ }
+
+ return $id;
+ }
+
+ /**
+ * Returns the ideal size for a file chunk
+ */
+ public static function chunkSize(): int
+ {
+ $max = [
+ Str::toBytes(ini_get('upload_max_filesize')),
+ Str::toBytes(ini_get('post_max_size'))
+ ];
+
+ // consider cloudflare proxy limit, if detected
+ if (isset($_SERVER['HTTP_CF_CONNECTING_IP']) === true) {
+ $max[] = Str::toBytes('100M');
+ }
+
+ // to be sure, only use 95% of the max possible upload size
+ return (int)floor(min($max) * 0.95);
+ }
+
+ /**
+ * Clean up tmp directory of stale files
+ */
+ public static function cleanTmpDir(): void
+ {
+ foreach (Dir::files($dir = static::tmpDir(), [], true) as $file) {
+ // remove any file that hasn't been altered
+ // in the last 24 hours
+ if (F::modified($file) < time() - 86400) {
+ F::remove($file);
+ }
+ }
+
+ // remove tmp directory if completely empty
+ if (Dir::isEmpty($dir) === true) {
+ Dir::remove($dir);
+ }
+ }
+
+ /**
+ * Throws an exception with the appropriate translated error message
+ *
+ * @throws \Exception Any upload error
+ */
+ public static function error(int $error): void
+ {
+ // get error messages from translation
+ $message = [
+ UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
+ UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
+ UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
+ UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
+ UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
+ UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
+ UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
+ ];
+
+ throw new Exception(
+ message: $message[$error] ?? I18n::translate('upload.error.default', 'The file could not be uploaded')
+ );
+ }
+
+ /**
+ * Sanitize the filename and extension
+ * based on the detected mime type
+ */
+ public static function filename(array $upload): string
+ {
+ // get the extension of the uploaded file
+ $extension = F::extension($upload['name']);
+
+ // try to detect the correct mime and add the extension
+ // accordingly. This will avoid .tmp filenames
+ if (
+ empty($extension) === true ||
+ in_array($extension, ['tmp', 'temp'], true) === true
+ ) {
+ $mime = F::mime($upload['tmp_name']);
+ $extension = F::mimeToExtension($mime);
+ $filename = F::name($upload['name']) . '.' . $extension;
+ return $filename;
+ }
+
+ return basename($upload['name']);
+ }
+
+ /**
+ * Upload the files and call closure for each file
+ *
+ * @throws \Exception Any upload error
+ */
+ public function process(Closure $callback): array
+ {
+ $files = $this->api->requestFiles();
+ $uploads = [];
+ $errors = [];
+
+ static::validateFiles($files);
+
+ foreach ($files as $upload) {
+ if (
+ isset($upload['tmp_name']) === false &&
+ is_array($upload) === true
+ ) {
+ continue;
+ }
+
+ try {
+ if ($upload['error'] !== 0) {
+ static::error($upload['error']);
+ }
+
+ $filename = static::filename($upload);
+ $source = $this->source($upload['tmp_name'], $filename);
+
+ // if the file is uploaded in chunks…
+ if ($this->api->requestHeaders('Upload-Length')) {
+ $source = $this->processChunk($source, $filename);
+ }
+
+ // apply callback only to complete uploads
+ // (incomplete chunk request will return empty $source)
+ $data = match ($source) {
+ null => null,
+ default => $callback($source, $filename)
+ };
+
+ $uploads[$upload['name']] = match (true) {
+ is_object($data) => $this->api->resolve($data)->toArray(),
+ default => $data
+ };
+ } catch (Exception $e) {
+ $errors[$upload['name']] = $e->getMessage();
+
+ // clean up file from system tmp directory
+ F::unlink($upload['tmp_name']);
+ }
+
+ if ($this->single === true) {
+ break;
+ }
+ }
+
+ return static::response($uploads, $errors);
+ }
+
+ /**
+ * Handle chunked uploads by merging all chunks
+ * in the tmp directory and only returning the new
+ * $source path to the tmp file once complete
+ *
+ * @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
+ * @throws \Kirby\Exception\Exception Chunk offset does not match existing tmp file
+ * @throws \Kirby\Exception\InvalidArgumentException Too short ID string
+ * @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
+ */
+ public function processChunk(
+ string $source,
+ string $filename
+ ): string|null {
+ // ensure the tmp upload directory exists
+ Dir::make($dir = static::tmpDir());
+
+ // create path for file in tmp upload directory;
+ // prefix with id while file isn't completely uploaded yet
+ $id = $this->api->requestHeaders('Upload-Id', '');
+ $id = static::chunkId($id);
+ $total = (int)$this->api->requestHeaders('Upload-Length');
+ $filename = basename($filename);
+ $tmpRoot = $dir . '/' . $id . '-' . $filename;
+
+ // validate various aspects of the request
+ // to ensure the chunk isn't trying to do malicious actions
+ static::validateChunk(
+ source: $source,
+ tmp: $tmpRoot,
+ total: $total,
+ offset: $this->api->requestHeaders('Upload-Offset'),
+ template: $this->api->requestBody('template'),
+ );
+
+ // stream chunk content and append it to partial file
+ stream_copy_to_stream(
+ fopen($source, 'r'),
+ fopen($tmpRoot, 'a')
+ );
+
+ // clear file stat cache so the following call to `F::size`
+ // really returns the updated file size
+ clearstatcache();
+
+ // if file isn't complete yet, return early
+ if (F::size($tmpRoot) < $total) {
+ return null;
+ }
+
+ // remove id from partial filename now the file is complete,
+ // so we can pass the path from the tmp upload directory
+ // as new source path for the file back to the API upload method
+ rename(
+ $tmpRoot,
+ $source = $dir . '/' . $filename
+ );
+
+ return $source;
+ }
+
+ /**
+ * Convert uploads and errors in response array for API response
+ */
+ public static function response(
+ array $uploads,
+ array $errors
+ ): array {
+ if (count($uploads) + count($errors) <= 1) {
+ if (count($errors) > 0) {
+ return [
+ 'status' => 'error',
+ 'message' => current($errors)
+ ];
+ }
+
+ return [
+ 'status' => 'ok',
+ 'data' => $uploads ? current($uploads) : null
+ ];
+ }
+
+ if (count($errors) > 0) {
+ return [
+ 'status' => 'error',
+ 'errors' => $errors
+ ];
+ }
+
+ return [
+ 'status' => 'ok',
+ 'data' => $uploads
+ ];
+ }
+
+ /**
+ * Move the tmp file to a location including the extension,
+ * for better mime detection and return updated source path
+ *
+ * @codeCoverageIgnore
+ */
+ public function source(string $source, string $filename): string
+ {
+ if ($this->debug === true) {
+ return $source;
+ }
+
+ $target = dirname($source) . '/' . uniqid() . '.' . $filename;
+
+ if (move_uploaded_file($source, $target)) {
+ return $target;
+ }
+
+ throw new Exception(
+ message: I18n::translate('upload.error.cantMove')
+ );
+ }
+
+ /**
+ * Returns root of directory used for
+ * temporarily storing (incomplete) uploads
+ * @codeCoverageIgnore
+ */
+ protected static function tmpDir(): string
+ {
+ return App::instance()->root('cache') . '/.uploads';
+ }
+
+ /**
+ * Ensures the sent chunk is valid
+ *
+ * @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
+ * @throws \Kirby\Exception\InvalidArgumentException Chunk offset does not match existing tmp file
+ * @throws \Kirby\Exception\InvalidArgumentException The maximum file size for this blueprint was exceeded
+ * @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
+ */
+ protected static function validateChunk(
+ string $source,
+ string $tmp,
+ int $total,
+ int $offset,
+ string|null $template = null
+ ): void {
+ $file = new File([
+ 'parent' => new Page(['slug' => 'tmp']),
+ 'filename' => $filename = basename($tmp),
+ 'template' => $template
+ ]);
+
+ // if the blueprint `maxsize` option is set,
+ // ensure that the total size communicated in the header
+ // as well as the current tmp size after adding this chunk
+ // do not exceed the max limit
+ if (
+ ($max = $file->blueprint()->accept()['maxsize'] ?? null) &&
+ (
+ $total > $max ||
+ (F::size($source) + F::size($tmp)) > $max
+ )
+ ) {
+ throw new InvalidArgumentException(
+ key: 'file.maxsize'
+ );
+ }
+
+ // validate the first chunk
+ if ($offset === 0) {
+ // sent chunk is expected to be the first part,
+ // but tmp file already exists
+ if (F::exists($tmp) === true) {
+ throw new DuplicateException(
+ message: 'A tmp file upload with the same filename and upload id already exists: ' . $filename
+ );
+ }
+
+ // validate file (extension, name) for first chunk;
+ // will also be validate again by `$model->createFile()`
+ // when completely uploaded
+ FileRules::validFile($file, false);
+
+ // first chunk is valid
+ return;
+ }
+
+ // validate subsequent chunks:
+ // no tmp in place
+ if (F::exists($tmp) === false) {
+ throw new NotFoundException(
+ message: 'Chunk offset ' . $offset . ' for non-existing tmp file: ' . $filename
+ );
+ }
+
+ // sent chunk's offset is not the continuation of the tmp file
+ if ($offset !== F::size($tmp)) {
+ throw new InvalidArgumentException(
+ message: 'Chunk offset ' . $offset . ' does not match the existing tmp upload file size of ' . F::size($tmp)
+ );
+ }
+ }
+
+ /**
+ * Validate the files array for upload
+ *
+ * @throws \Exception No files were uploaded
+ */
+ protected static function validateFiles(array $files): void
+ {
+ if ($files === []) {
+ $postMaxSize = Str::toBytes(ini_get('post_max_size'));
+ $uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
+
+ // @codeCoverageIgnoreStart
+ if ($postMaxSize < $uploadMaxFileSize) {
+ throw new Exception(
+ message:
+ I18n::translate(
+ 'upload.error.iniPostSize',
+ 'The uploaded file exceeds the post_max_size directive in php.ini'
+ )
+ );
+ }
+ // @codeCoverageIgnoreEnd
+
+ throw new Exception(
+ message:
+ I18n::translate(
+ 'upload.error.noFiles',
+ 'No files were uploaded'
+ )
+ );
+ }
+ }
+}
diff --git a/public/kirby/src/Blueprint/Collection.php b/public/kirby/src/Blueprint/Collection.php
deleted file mode 100644
index a8dc604..0000000
--- a/public/kirby/src/Blueprint/Collection.php
+++ /dev/null
@@ -1,97 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class Collection extends BaseCollection
-{
- /**
- * The expected object type
- */
- public const TYPE = Node::class;
-
- public function __construct(array $objects = [])
- {
- foreach ($objects as $object) {
- $this->__set($object->id, $object);
- }
- }
-
- /**
- * The Kirby Collection class only shows the key to
- * avoid huge tress with dump, but for the blueprint
- * collections this is really not useful
- *
- * @codeCoverageIgnore
- */
- public function __debugInfo(): array
- {
- return A::map($this->data, fn ($item) => (array)$item);
- }
-
- /**
- * Validate the type of every item that is being
- * added to the collection. They need to have
- * the class defined by static::TYPE.
- */
- public function __set(string $key, $value): void
- {
- if (is_a($value, static::TYPE) === false) {
- throw new TypeError('Each value in the collection must be an instance of ' . static::TYPE);
- }
-
- parent::__set($key, $value);
- }
-
- /**
- * Creates a collection from a nested array structure
- */
- public static function factory(array $items): static
- {
- $collection = new static();
- $className = static::TYPE;
-
- foreach ($items as $id => $item) {
- if (is_array($item) === true) {
- $item['id'] ??= $id;
- $item = $className::factory($item);
- $collection->__set($item->id, $item);
- } else {
- $collection->__set($id, $className::factory($item));
- }
- }
-
- return $collection;
- }
-
- /**
- * Renders each item with a model and returns
- * an array of all rendered results
- */
- public function render(ModelWithContent $model): array
- {
- $props = [];
-
- foreach ($this->data as $key => $item) {
- $props[$key] = $item->render($model);
- }
-
- return $props;
- }
-}
diff --git a/public/kirby/src/Blueprint/Config.php b/public/kirby/src/Blueprint/Config.php
deleted file mode 100644
index c7edb23..0000000
--- a/public/kirby/src/Blueprint/Config.php
+++ /dev/null
@@ -1,75 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class Config
-{
- public string $file;
- public string $id;
- public string|array|Closure|null $plugin;
- public string $root;
-
- public function __construct(
- public string $path
- ) {
- $kirby = App::instance();
-
- $this->id = basename($this->path);
- $this->root = $kirby->root('blueprints');
- $this->file = $this->root . '/' . $this->path . '.yml';
- $this->plugin = $kirby->extension('blueprints', $this->path);
- }
-
- public function read(): array
- {
- if (F::exists($this->file, $this->root) === true) {
- return $this->unpack($this->file);
- }
-
- return $this->unpack($this->plugin);
- }
-
- public function write(array $props): bool
- {
- return Yaml::write($this->file, $props);
- }
-
- public function unpack(string|array|Closure|null $extension): array
- {
- return match (true) {
- // extension does not exist
- is_null($extension)
- => throw new NotFoundException('"' . $this->path . '" could not be found'),
-
- // extension is stored as a file path
- is_string($extension)
- => Yaml::read($extension),
-
- // extension is a callback to be resolved
- is_callable($extension)
- => $extension(App::instance()),
-
- // extension is already defined as array
- default
- => $extension
- };
- }
-}
diff --git a/public/kirby/src/Blueprint/Extension.php b/public/kirby/src/Blueprint/Extension.php
deleted file mode 100644
index 9d3d25f..0000000
--- a/public/kirby/src/Blueprint/Extension.php
+++ /dev/null
@@ -1,65 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class Extension
-{
- public function __construct(
- public string $path
- ) {
- }
-
- public static function apply(array $props): array
- {
- if (isset($props['extends']) === false) {
- return $props;
- }
-
- // already extended
- if (is_a($props['extends'], Extension::class) === true) {
- return $props;
- }
-
- $extension = new static($props['extends']);
- return $extension->extend($props);
- }
-
- public function extend(array $props): array
- {
- $props = array_replace_recursive(
- $this->read(),
- $props
- );
-
- $props['extends'] = $this;
-
- return $props;
- }
-
- public static function factory(string|array $path): static
- {
- if (is_string($path) === true) {
- return new static(path: $path);
- }
-
- return new static(...$path);
- }
-
- public function read(): array
- {
- $config = new Config($this->path);
- return $config->read();
- }
-}
diff --git a/public/kirby/src/Blueprint/Factory.php b/public/kirby/src/Blueprint/Factory.php
deleted file mode 100644
index 756611c..0000000
--- a/public/kirby/src/Blueprint/Factory.php
+++ /dev/null
@@ -1,119 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class Factory
-{
- /**
- * Resolves the properties by
- * applying a map of factories (propName => class)
- */
- public static function apply(array $properties, array $factories): array
- {
- foreach ($factories as $property => $class) {
- // skip non-existing properties, empty properties
- // or properties that are matching objects
- if (
- isset($properties[$property]) === false ||
- $properties[$property] === null ||
- is_a($properties[$property], $class) === true
- ) {
- continue;
- }
-
- $properties[$property] = $class::factory($properties[$property]);
- }
-
- return $properties;
- }
-
- public static function forNamedType(ReflectionNamedType|null $type, $value)
- {
- // get the class name for the single type
- $className = $type->getName();
-
- // check if there's a factory for the value
- if (method_exists($className, 'factory') === true) {
- return $className::factory($value);
- }
-
- // try to assign the value directly and trust
- // in PHP's type system.
- return $value;
- }
-
- public static function forProperties(string $class, array $properties): array
- {
- foreach ($properties as $property => $value) {
- try {
- $properties[$property] = static::forProperty($class, $property, $value);
- } catch (ReflectionException $e) {
- // the property does not exist
- unset($properties[$property]);
- }
- }
-
- return $properties;
- }
-
- public static function forProperty(string $class, string $property, $value)
- {
- if (is_null($value) === true) {
- return $value;
- }
-
- // instantly assign objects
- // PHP's type system will find issues automatically
- if (is_object($value) === true) {
- return $value;
- }
-
- // get the type for the property
- $reflection = new ReflectionProperty($class, $property);
- $propType = $reflection->getType();
-
- // no type given
- if ($propType === null) {
- return $value;
- }
-
- // union types
- if ($propType instanceof ReflectionUnionType) {
- return static::forUnionType($propType, $value);
- }
-
- return static::forNamedType($propType, $value);
- }
-
- /**
- * For properties with union types,
- * the first named type is used to create
- * the factory or pass a built-in value
- */
- public static function forUnionType(ReflectionUnionType $type, $value)
- {
- return static::forNamedType($type->getTypes()[0], $value);
- }
-
- public static function make(string $class, array $properties): object
- {
- return new $class(...static::forProperties($class, $properties));
- }
-}
diff --git a/public/kirby/src/Blueprint/Node.php b/public/kirby/src/Blueprint/Node.php
deleted file mode 100644
index 96d4dca..0000000
--- a/public/kirby/src/Blueprint/Node.php
+++ /dev/null
@@ -1,117 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class Node
-{
- public const TYPE = 'node';
-
- public function __construct(
- public string $id,
- public Extension|null $extends = null,
- ) {
- }
-
- /**
- * Dynamic getter for properties
- */
- public function __call(string $name, array $args)
- {
- $this->defaults();
- return $this->$name;
- }
-
- /**
- * Apply default values
- */
- public function defaults(): static
- {
- return $this;
- }
-
- /**
- * Creates an instance by a set of array properties.
- */
- public static function factory(array $props): static
- {
- $props = Extension::apply($props);
- $props = static::polyfill($props);
- return Factory::make(static::class, $props);
- }
-
- public static function load(string|array $props): static
- {
- // load by path
- if (is_string($props) === true) {
- $props = static::loadProps($props);
- }
-
- return static::factory($props);
- }
-
- public static function loadProps(string $path): array
- {
- $config = new Config($path);
- $props = $config->read();
-
- // add the id if it's not set yet
- $props['id'] ??= basename($path);
-
- return $props;
- }
-
- /**
- * Optional method that runs before static::factory sends
- * its properties to the instance. This is perfect to clean
- * up props or keep deprecated props compatible.
- */
- public static function polyfill(array $props): array
- {
- return $props;
- }
-
- public function render(ModelWithContent $model)
- {
- // apply default values
- $this->defaults();
-
- $array = [];
-
- // go through all public properties
- foreach (get_object_vars($this) as $key => $value) {
- if (is_object($value) === false && is_resource($value) === false) {
- $array[$key] = $value;
- continue;
- }
-
- if (method_exists($value, 'render') === true) {
- $array[$key] = $value->render($model);
- }
- }
-
- return $array;
- }
-
- /**
- * Universal setter for properties
- */
- public function set(string $property, $value): static
- {
- $this->$property = Factory::forProperty(static::class, $property, $value);
- return $this;
- }
-}
diff --git a/public/kirby/src/Blueprint/NodeI18n.php b/public/kirby/src/Blueprint/NodeI18n.php
deleted file mode 100644
index 6dc06aa..0000000
--- a/public/kirby/src/Blueprint/NodeI18n.php
+++ /dev/null
@@ -1,44 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class NodeI18n extends NodeProperty
-{
- public function __construct(
- public array $translations,
- ) {
- }
-
- public static function factory($value = null): static|null
- {
- if ($value === false || $value === null) {
- return null;
- }
-
- if (is_array($value) === false) {
- $value = ['en' => $value];
- }
-
- return new static($value);
- }
-
- public function render(ModelWithContent $model): string|null
- {
- return I18n::translate($this->translations, $this->translations);
- }
-}
diff --git a/public/kirby/src/Blueprint/NodeIcon.php b/public/kirby/src/Blueprint/NodeIcon.php
deleted file mode 100644
index 3fa672d..0000000
--- a/public/kirby/src/Blueprint/NodeIcon.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class NodeIcon extends NodeString
-{
- public static function field()
- {
- $field = parent::field();
- $field->id = 'icon';
- $field->label->translations = ['en' => 'Icon'];
-
- return $field;
- }
-}
diff --git a/public/kirby/src/Blueprint/NodeProperty.php b/public/kirby/src/Blueprint/NodeProperty.php
deleted file mode 100644
index 6aef2db..0000000
--- a/public/kirby/src/Blueprint/NodeProperty.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-abstract class NodeProperty
-{
- abstract public static function factory($value = null): static|null;
-
- public function render(ModelWithContent $model)
- {
- return null;
- }
-}
diff --git a/public/kirby/src/Blueprint/NodeString.php b/public/kirby/src/Blueprint/NodeString.php
deleted file mode 100644
index 1093e1f..0000000
--- a/public/kirby/src/Blueprint/NodeString.php
+++ /dev/null
@@ -1,39 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class NodeString extends NodeProperty
-{
- public function __construct(
- public string $value,
- ) {
- }
-
- public static function factory($value = null): static|null
- {
- if ($value === null) {
- return null;
- }
-
- return new static($value);
- }
-
- public function render(ModelWithContent $model): string|null
- {
- return $this->value;
- }
-}
diff --git a/public/kirby/src/Blueprint/NodeText.php b/public/kirby/src/Blueprint/NodeText.php
deleted file mode 100644
index bbc4cd7..0000000
--- a/public/kirby/src/Blueprint/NodeText.php
+++ /dev/null
@@ -1,30 +0,0 @@
-
- * @link https://getkirby.com
- * @copyright Bastian Allgeier
- * @license https://opensource.org/licenses/MIT
- *
- * // TODO: include in test coverage once blueprint refactoring is done
- * @codeCoverageIgnore
- */
-class NodeText extends NodeI18n
-{
- public function render(ModelWithContent $model): string|null
- {
- if ($text = parent::render($model)) {
- return $model->toSafeString($text);
- }
-
- return $text;
- }
-}
diff --git a/public/kirby/src/Cache/ApcuCache.php b/public/kirby/src/Cache/ApcuCache.php
index ee13215..98fe7de 100644
--- a/public/kirby/src/Cache/ApcuCache.php
+++ b/public/kirby/src/Cache/ApcuCache.php
@@ -68,10 +68,10 @@ class ApcuCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
- *
- * // put an item in the cache for 15 minutes
- * $cache->set('value', 'my value', 15);
- *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
diff --git a/public/kirby/src/Cache/Cache.php b/public/kirby/src/Cache/Cache.php
index 5b2a05b..6c9fc88 100644
--- a/public/kirby/src/Cache/Cache.php
+++ b/public/kirby/src/Cache/Cache.php
@@ -70,7 +70,6 @@ abstract class Cache
return $this->expired($key) === false;
}
-
/**
* Calculates the expiration timestamp
*/
@@ -132,13 +131,13 @@ abstract class Cache
/**
* Gets an item from the cache
*
- *
- * // get an item from the cache driver
- * $value = $cache->get('value');
+ * ```php
+ * // get an item from the cache driver
+ * $value = $cache->get('value');
*
- * // return a default value if the requested item isn't cached
- * $value = $cache->get('value', 'default value');
- *
+ * // return a default value if the requested item isn't cached
+ * $value = $cache->get('value', 'default value');
+ * ```
*/
public function get(string $key, $default = null)
{
@@ -229,10 +228,10 @@ abstract class Cache
* returns whether the operation was successful;
* this needs to be defined by the driver
*
- *
- * // put an item in the cache for 15 minutes
- * $cache->set('value', 'my value', 15);
- *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
*/
abstract public function set(string $key, $value, int $minutes = 0): bool;
}
diff --git a/public/kirby/src/Cache/FileCache.php b/public/kirby/src/Cache/FileCache.php
index 4916dc5..9f6cace 100644
--- a/public/kirby/src/Cache/FileCache.php
+++ b/public/kirby/src/Cache/FileCache.php
@@ -32,13 +32,12 @@ class FileCache extends Cache
*/
public function __construct(array $options)
{
- $defaults = [
+ parent::__construct([
'root' => null,
'prefix' => null,
- 'extension' => null
- ];
-
- parent::__construct(array_merge($defaults, $options));
+ 'extension' => null,
+ ...$options
+ ]);
// build the full root including prefix
$this->root = $this->options['root'];
@@ -120,10 +119,10 @@ class FileCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
- *
- * // put an item in the cache for 15 minutes
- * $cache->set('value', 'my value', 15);
- *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
@@ -196,7 +195,7 @@ class FileCache extends Cache
$files = array_diff($files, ['.', '..']);
- if (empty($files) === true && Dir::remove($dir) === true) {
+ if ($files === [] && Dir::remove($dir) === true) {
// continue with the next level up
$dir = dirname($dir);
} else {
diff --git a/public/kirby/src/Cache/MemCached.php b/public/kirby/src/Cache/MemCached.php
index d6b1f89..06f79dd 100644
--- a/public/kirby/src/Cache/MemCached.php
+++ b/public/kirby/src/Cache/MemCached.php
@@ -34,13 +34,12 @@ class MemCached extends Cache
*/
public function __construct(array $options = [])
{
- $defaults = [
+ parent::__construct([
'host' => 'localhost',
'port' => 11211,
'prefix' => null,
- ];
-
- parent::__construct(array_merge($defaults, $options));
+ ...$options
+ ]);
$this->connection = new MemcachedExt();
$this->enabled = $this->connection->addServer(
@@ -62,10 +61,10 @@ class MemCached extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
- *
- * // put an item in the cache for 15 minutes
- * $cache->set('value', 'my value', 15);
- *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
diff --git a/public/kirby/src/Cache/MemoryCache.php b/public/kirby/src/Cache/MemoryCache.php
index 55fdbc1..ecf52ad 100644
--- a/public/kirby/src/Cache/MemoryCache.php
+++ b/public/kirby/src/Cache/MemoryCache.php
@@ -31,10 +31,10 @@ class MemoryCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
- *
- * // put an item in the cache for 15 minutes
- * $cache->set('value', 'my value', 15);
- *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
diff --git a/public/kirby/src/Cache/NullCache.php b/public/kirby/src/Cache/NullCache.php
index 7c2211d..48ed80e 100644
--- a/public/kirby/src/Cache/NullCache.php
+++ b/public/kirby/src/Cache/NullCache.php
@@ -26,10 +26,10 @@ class NullCache extends Cache
* Writes an item to the cache for a given number of minutes and
* returns whether the operation was successful
*
- *
- * // put an item in the cache for 15 minutes
- * $cache->set('value', 'my value', 15);
- *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
diff --git a/public/kirby/src/Cache/RedisCache.php b/public/kirby/src/Cache/RedisCache.php
new file mode 100644
index 0000000..f089922
--- /dev/null
+++ b/public/kirby/src/Cache/RedisCache.php
@@ -0,0 +1,160 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://opensource.org/licenses/MIT
+ */
+class RedisCache extends Cache
+{
+ /**
+ * Store for the redis connection
+ */
+ protected Redis $connection;
+
+ /**
+ * Sets all parameters which are needed to connect to Redis
+ *
+ * @param array $options 'host' (default: 127.0.0.1)
+ * 'port' (default: 6379)
+ */
+ public function __construct(array $options = [])
+ {
+ $options = [
+ 'host' => '127.0.0.1',
+ 'port' => 6379,
+ ...$options
+ ];
+
+ parent::__construct($options);
+
+ // available options for the redis driver
+ $allowed = [
+ 'host',
+ 'port',
+ 'readTimeout',
+ 'connectTimeout',
+ 'persistent',
+ 'auth',
+ 'ssl',
+ 'retryInterval',
+ 'backoff'
+ ];
+
+ // filters only redis supported keys
+ $redisOptions = array_intersect_key($options, array_flip($allowed));
+
+ // creates redis connection
+ $this->connection = new Redis($redisOptions);
+
+ // sets the prefix if defined
+ if ($prefix = $options['prefix'] ?? null) {
+ $this->connection->setOption(Redis::OPT_PREFIX, rtrim($prefix, '/') . '/');
+ }
+
+ // selects the database if defined
+ $database = $options['database'] ?? null;
+ if ($database !== null) {
+ $this->connection->select($database);
+ }
+ }
+
+ /**
+ * Returns the database number
+ */
+ public function databaseNum(): int
+ {
+ return $this->connection->getDbNum();
+ }
+
+ /**
+ * Returns whether the cache is ready to store values
+ */
+ public function enabled(): bool
+ {
+ try {
+ return Helpers::handleErrors(
+ fn () => $this->connection->ping(),
+ fn (int $errno, string $errstr) => true,
+ fn () => false
+ );
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * Determines if an item exists in the cache
+ */
+ public function exists(string $key): bool
+ {
+ return $this->connection->exists($this->key($key)) !== 0;
+ }
+
+ /**
+ * Removes keys from the database
+ * and returns whether the operation was successful
+ */
+ public function flush(): bool
+ {
+ return $this->connection->flushDB();
+ }
+
+ /**
+ * The key is not modified, because the prefix is added by the redis driver itself
+ */
+ protected function key(string $key): string
+ {
+ return $key;
+ }
+
+ /**
+ * Removes an item from the cache
+ * and returns whether the operation was successful
+ */
+ public function remove(string $key): bool
+ {
+ return $this->connection->del($this->key($key));
+ }
+
+ /**
+ * Internal method to retrieve the raw cache value;
+ * needs to return a Value object or null if not found
+ */
+ public function retrieve(string $key): Value|null
+ {
+ $value = $this->connection->get($this->key($key));
+ return Value::fromJson($value);
+ }
+
+ /**
+ * Writes an item to the cache for a given number of minutes
+ * and returns whether the operation was successful
+ *
+ * ```php
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ * ```
+ */
+ public function set(string $key, $value, int $minutes = 0): bool
+ {
+ $key = $this->key($key);
+ $value = (new Value($value, $minutes))->toJson();
+
+ if ($minutes > 0) {
+ return $this->connection->setex($key, $minutes * 60, $value);
+ }
+
+ return $this->connection->set($key, $value);
+ }
+}
diff --git a/public/kirby/src/Cache/Value.php b/public/kirby/src/Cache/Value.php
index 693d95d..2fc64a5 100644
--- a/public/kirby/src/Cache/Value.php
+++ b/public/kirby/src/Cache/Value.php
@@ -20,7 +20,7 @@ class Value
/**
* Cached value
*/
- protected $value;
+ protected mixed $value;
/**
* the number of minutes until the value expires
diff --git a/public/kirby/src/Cms/Api.php b/public/kirby/src/Cms/Api.php
index 49dcac9..d7945b4 100644
--- a/public/kirby/src/Cms/Api.php
+++ b/public/kirby/src/Cms/Api.php
@@ -55,9 +55,10 @@ class Api extends BaseApi
*/
public function clone(array $props = []): static
{
- return parent::clone(array_merge([
- 'kirby' => $this->kirby
- ], $props));
+ return parent::clone([
+ 'kirby' => $this->kirby,
+ ...$props
+ ]);
}
/**
@@ -194,7 +195,9 @@ class Api extends BaseApi
string|null $path = null
): mixed {
if (!$section = $model->blueprint()?->section($name)) {
- throw new NotFoundException('The section "' . $name . '" could not be found');
+ throw new NotFoundException(
+ message: 'The section "' . $name . '" could not be found'
+ );
}
$sectionApi = $this->clone([
@@ -216,9 +219,7 @@ class Api extends BaseApi
*/
public function session(array $options = []): Session
{
- return $this->kirby->session(array_merge([
- 'detect' => true
- ], $options));
+ return $this->kirby->session(['detect' => true, ...$options]);
}
/**
@@ -234,7 +235,6 @@ class Api extends BaseApi
* returns the current authenticated user if no
* id is passed
*
- * @param string|null $id User's id
* @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found
*/
public function user(string|null $id = null): User|null
diff --git a/public/kirby/src/Cms/App.php b/public/kirby/src/Cms/App.php
index cdc8343..4777671 100644
--- a/public/kirby/src/Cms/App.php
+++ b/public/kirby/src/Cms/App.php
@@ -3,7 +3,10 @@
namespace Kirby\Cms;
use Closure;
+use Exception as GlobalException;
use Generator;
+use Kirby\Content\Storage;
+use Kirby\Content\VersionCache;
use Kirby\Data\Data;
use Kirby\Email\Email as BaseEmail;
use Kirby\Exception\ErrorPageException;
@@ -68,9 +71,9 @@ class App
protected Core $core;
protected Language|null $defaultLanguage = null;
protected Environment|null $environment = null;
+ protected Events $events;
protected Language|null $language = null;
protected Languages|null $languages = null;
- protected ContentLocks|null $locks = null;
protected bool|null $multilang = null;
protected string|null $nonce = null;
protected array $options;
@@ -96,7 +99,11 @@ class App
*/
public function __construct(array $props = [], bool $setInstance = true)
{
- $this->core = new Core($this);
+ $this->core = new Core($this);
+ $this->events = new Events($this);
+
+ // start with a fresh version cache
+ VersionCache::reset();
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
@@ -128,13 +135,12 @@ class App
// configurable properties
$this->setLanguages($props['languages'] ?? null);
$this->setRoles($props['roles'] ?? null);
- $this->setSite($props['site'] ?? null);
$this->setUser($props['user'] ?? null);
$this->setUsers($props['users'] ?? null);
// set the singleton
if (static::$instance === null || $setInstance === true) {
- static::$instance = ModelWithContent::$kirby = Model::$kirby = $this;
+ static::$instance = ModelWithContent::$kirby = $this;
}
// setup the I18n class with the translation loader
@@ -147,6 +153,11 @@ class App
$this->extensionsFromOptions();
$this->extensionsFromFolders();
+ // must be set after the extensions are loaded.
+ // the default storage instance must be defined
+ // and the App::$instance singleton needs to be set
+ $this->setSite($props['site'] ?? null);
+
// trigger hook for use in plugins
$this->trigger('system.loadPlugins:after');
@@ -178,7 +189,7 @@ class App
/**
* Returns the Api instance
*
- * @internal
+ * @unstable
*/
public function api(): Api
{
@@ -190,59 +201,40 @@ class App
$extensions = $this->extensions['api'] ?? [];
$routes = (include $root . '/routes.php')($this);
- $api = [
+ return $this->api = new Api([
'debug' => $this->option('debug', false),
'authentication' => $extensions['authentication'] ?? include $root . '/authentication.php',
- 'data' => $extensions['data'] ?? [],
- 'collections' => array_merge($extensions['collections'] ?? [], include $root . '/collections.php'),
- 'models' => array_merge($extensions['models'] ?? [], include $root . '/models.php'),
- 'routes' => array_merge($routes, $extensions['routes'] ?? []),
+ 'data' => $extensions['data'] ?? [],
+ 'collections' => [
+ ...$extensions['collections'] ?? [],
+ ...include $root . '/collections.php'
+ ],
+ 'models' => [
+ ...$extensions['models'] ?? [],
+ ...include $root . '/models.php'
+ ],
+ 'routes' => [
+ ...$routes,
+ ...$extensions['routes'] ?? []
+ ],
'kirby' => $this,
- ];
-
- return $this->api = new Api($api);
+ ]);
}
/**
* Applies a hook to the given value
*
* @param string $name Full event name
- * @param array $args Associative array of named event arguments
- * @param string $modify Key in $args that is modified by the hooks
- * @param \Kirby\Cms\Event|null $originalEvent Event object (internal use)
+ * @param array $args Associative array of named arguments
+ * @param string|null $modify Key in $args that is modified by the hooks (default: first argument)
* @return mixed Resulting value as modified by the hooks
*/
public function apply(
string $name,
array $args,
- string $modify,
- Event|null $originalEvent = null
+ string|null $modify = null
): mixed {
- $event = $originalEvent ?? new Event($name, $args);
-
- if ($functions = $this->extension('hooks', $name)) {
- foreach ($functions as $function) {
- // bind the App object to the hook
- $newValue = $event->call($this, $function);
-
- // update value if one was returned
- if ($newValue !== null) {
- $event->updateArgument($modify, $newValue);
- }
- }
- }
-
- // apply wildcard hooks if available
- $nameWildcards = $event->nameWildcards();
- if ($originalEvent === null && count($nameWildcards) > 0) {
- foreach ($nameWildcards as $nameWildcard) {
- // the $event object is passed by reference
- // and will be modified down the chain
- $this->apply($nameWildcard, $event->arguments(), $modify, $event);
- }
- }
-
- return $event->argument($modify);
+ return $this->events->apply($name, $args, $modify);
}
/**
@@ -271,7 +263,10 @@ class App
// move the option to the plugin option array
// don't overwrite nested arrays completely but merge them
- $this->options[$plugin] = array_replace_recursive($this->options[$plugin], [$option => $value]);
+ $this->options[$plugin] = array_replace_recursive(
+ $this->options[$plugin],
+ [$option => $value]
+ );
unset($this->options[$key]);
}
}
@@ -287,7 +282,7 @@ class App
*/
protected function bakeRoots(array|null $roots = null): static
{
- $roots = array_merge($this->core->roots(), (array)$roots);
+ $roots = [...$this->core->roots(), ...$roots ?? []];
$this->roots = Ingredients::bake($roots);
return $this;
}
@@ -299,7 +294,7 @@ class App
*/
protected function bakeUrls(array|null $urls = null): static
{
- $urls = array_merge($this->core->urls(), (array)$urls);
+ $urls = [...$this->core->urls(), ...$urls ?? []];
$this->urls = Ingredients::bake($urls);
return $this;
}
@@ -318,9 +313,18 @@ class App
}
}
- foreach (glob($this->root('blueprints') . '/' . $type . '/*.yml') as $blueprint) {
- $name = F::name($blueprint);
- $blueprints[$name] = $name;
+ try {
+ // protect against path traversal attacks
+ $root = $this->root('blueprints') . '/' . $type;
+ $realpath = Dir::realpath($root, $this->root('blueprints'));
+
+ foreach (glob($realpath . '/*.yml') as $blueprint) {
+ $name = F::name($blueprint);
+ $blueprints[$name] = $name;
+ }
+ } catch (GlobalException) {
+ // if the realpath operation failed, the following glob was skipped,
+ // keeping just the blueprints from extensions
}
ksort($blueprints);
@@ -360,17 +364,18 @@ class App
* automatically injected
*
* @return \Kirby\Toolkit\Collection|null
- * @todo 5.0 Add return type declaration
+ * @todo 6.0 Add return type declaration
*/
public function collection(string $name, array $options = [])
{
- return $this->collections()->get($name, array_merge($options, [
+ return $this->collections()->get($name, [
+ ...$options,
'kirby' => $this,
'site' => $site = $this->site(),
'pages' => new LazyValue(fn () => $site->children()),
'users' => new LazyValue(fn () => $this->users())
- ]));
+ ]);
}
/**
@@ -383,8 +388,6 @@ class App
/**
* Returns a core component
- *
- * @internal
*/
public function component(string $name): mixed
{
@@ -393,8 +396,6 @@ class App
/**
* Returns the content extension
- *
- * @internal
*/
public function contentExtension(): string
{
@@ -403,8 +404,6 @@ class App
/**
* Returns files that should be ignored when scanning folders
- *
- * @internal
*/
public function contentIgnore(): array
{
@@ -415,15 +414,15 @@ class App
* Generates a non-guessable token based on model
* data and a configured salt
*
- * @param mixed $model Object to pass to the salt callback if configured
+ * @param object|null $model Object to pass to the salt callback if configured
* @param string $value Model data to include in the generated token
*/
- public function contentToken(mixed $model, string $value): string
+ public function contentToken(object|null $model, string $value): string
{
- if (method_exists($model, 'root') === true) {
- $default = $model->root();
- } else {
- $default = $this->root('content');
+ $default = $this->root('content');
+
+ if ($model !== null && method_exists($model, 'id') === true) {
+ $default .= '/' . $model->id();
}
$salt = $this->option('content.salt', $default);
@@ -445,25 +444,30 @@ class App
string $contentType = 'html'
): array {
$name = basename(strtolower($name));
+ $data = [];
- if ($controller = $this->controllerLookup($name, $contentType)) {
- return (array)$controller->call($this, $arguments);
+ // always use the site controller as defaults, if available
+ $site = $this->controllerLookup('site', $contentType);
+ $site ??= $this->controllerLookup('site');
+
+ if ($site !== null) {
+ $data = (array)$site->call($this, $arguments);
}
- if ($contentType !== 'html') {
- // no luck for a specific representation controller?
- // let's try the html controller instead
- if ($controller = $this->controllerLookup($name)) {
- return (array)$controller->call($this, $arguments);
- }
+ // try to find a specific representation controller
+ $controller = $this->controllerLookup($name, $contentType);
+ // no luck for a specific representation controller?
+ // let's try the html controller instead
+ $controller ??= $this->controllerLookup($name);
+
+ if ($controller !== null) {
+ return [
+ ...$data,
+ ...(array)$controller->call($this, $arguments)
+ ];
}
- // still no luck? Let's take the site controller
- if ($controller = $this->controllerLookup('site')) {
- return (array)$controller->call($this, $arguments);
- }
-
- return [];
+ return $data;
}
/**
@@ -478,7 +482,7 @@ class App
}
// controller from site root
- $controller = Controller::load($this->root('controllers') . '/' . $name . '.php');
+ $controller = Controller::load($this->root('controllers') . '/' . $name . '.php', $this->root('controllers'));
// controller from extension
$controller ??= $this->extension('controllers', $name);
@@ -576,11 +580,12 @@ class App
$visitor = $this->visitor();
foreach ($visitor->acceptedLanguages() as $acceptedLang) {
- $closure = function ($language) use ($acceptedLang) {
+ $closure = static function ($language) use ($acceptedLang) {
$languageLocale = $language->locale(LC_ALL);
$acceptedLocale = $acceptedLang->locale();
- return $languageLocale === $acceptedLocale ||
+ return
+ $languageLocale === $acceptedLocale ||
$acceptedLocale === Str::substr($languageLocale, 0, 2);
};
@@ -715,7 +720,7 @@ class App
* Takes almost any kind of input and
* tries to convert it into a valid response
*
- * @internal
+ * @unstable
*/
public function io(mixed $input): Response
{
@@ -724,12 +729,11 @@ class App
// any direct exception will be turned into an error page
if ($input instanceof Throwable) {
- if ($input instanceof Exception) {
- $code = $input->getHttpCode();
- } else {
- $code = $input->getCode();
- }
$message = $input->getMessage();
+ $code = match (true) {
+ $input instanceof Exception => $input->getHttpCode(),
+ default => $input->getCode()
+ };
if ($code < 400 || $code > 599) {
$code = 500;
@@ -739,7 +743,7 @@ class App
return $response->code($code)->send($errorPage->render([
'errorCode' => $code,
'errorMessage' => $message,
- 'errorType' => get_class($input)
+ 'errorType' => $input::class
]));
}
@@ -770,10 +774,7 @@ class App
// lazily (only if they are not already set);
// the case-insensitive nature of headers will be
// handled by PHP's `header()` function
- $data['headers'] = array_merge(
- $response->headers(),
- $data['headers']
- );
+ $data['headers'] = [...$response->headers(), ...$data['headers']];
return new Response($data);
}
@@ -811,13 +812,14 @@ class App
return $response->json($input)->send();
}
- throw new InvalidArgumentException('Unexpected input');
+ throw new InvalidArgumentException(
+ message: 'Unexpected input'
+ );
}
/**
* Renders a single KirbyTag with the given attributes
*
- * @internal
* @param string|array $type Tag type or array with all tag arguments
* (the key of the first element becomes the type)
*/
@@ -848,33 +850,33 @@ class App
}
/**
- * KirbyTags Parser
- *
- * @internal
+ * Parses and resolves KirbyTags in text
*/
- public function kirbytags(string|null $text = null, array $data = []): string
- {
+ public function kirbytags(
+ string|null $text = null,
+ array $data = []
+ ): string {
$data['kirby'] ??= $this;
$data['site'] ??= $data['kirby']->site();
$data['parent'] ??= $data['site']->page();
$options = $this->options;
- $text = $this->apply('kirbytags:before', compact('text', 'data', 'options'), 'text');
+ $text = $this->apply('kirbytags:before', compact('text', 'data', 'options'));
$text = KirbyTags::parse($text, $data, $options);
- $text = $this->apply('kirbytags:after', compact('text', 'data', 'options'), 'text');
+ $text = $this->apply('kirbytags:after', compact('text', 'data', 'options'));
return $text;
}
/**
* Parses KirbyTags first and Markdown afterwards
- *
- * @internal
*/
- public function kirbytext(string|null $text = null, array $options = []): string
- {
- $text = $this->apply('kirbytext:before', compact('text'), 'text');
+ public function kirbytext(
+ string|null $text = null,
+ array $options = []
+ ): string {
+ $text = $this->apply('kirbytext:before', compact('text'));
$text = $this->kirbytags($text, $options);
$text = $this->markdown($text, $options['markdown'] ?? []);
@@ -882,7 +884,7 @@ class App
$text = $this->smartypants($text);
}
- $text = $this->apply('kirbytext:after', compact('text'), 'text');
+ $text = $this->apply('kirbytext:after', compact('text'));
return $text;
}
@@ -906,8 +908,6 @@ class App
/**
* Returns the current language code
- *
- * @internal
*/
public function languageCode(string|null $languageCode = null): string|null
{
@@ -925,7 +925,10 @@ class App
}
if ($this->languages !== null) {
- return $clone === true ? clone $this->languages : $this->languages;
+ return match($clone) {
+ true => clone $this->languages,
+ false => $this->languages
+ };
}
return $this->languages = Languages::load();
@@ -939,26 +942,16 @@ class App
return new Loader($this);
}
- /**
- * Returns the app's locks object
- */
- public function locks(): ContentLocks
- {
- return $this->locks ??= new ContentLocks();
- }
-
/**
* Parses Markdown
- *
- * @internal
*/
public function markdown(string|null $text = null, array|null $options = null): string
{
// merge global options with local options
- $options = array_merge(
- $this->options['markdown'] ?? [],
- (array)$options
- );
+ $options = [
+ ...$this->options['markdown'] ?? [],
+ ...$options ?? []
+ ];
return ($this->component('markdown'))($this, $text, $options);
}
@@ -1061,7 +1054,11 @@ class App
// merge into one clean options array;
// the `env.php` options always override everything else
$hostAddrOptions = $this->environment()->options($root);
- $this->options = array_replace_recursive($this->options, $hostAddrOptions, $envOptions);
+ $this->options = array_replace_recursive(
+ $this->options,
+ $hostAddrOptions,
+ $envOptions
+ );
// reload the environment if the host/address config has overridden
// the `url` option; this ensures that the base URL is correct
@@ -1115,14 +1112,21 @@ class App
$this->api = null;
}
- if (isset($options['home']) === true || isset($options['error']) === true) {
+ if (
+ isset($options['home']) === true ||
+ isset($options['error']) === true
+ ) {
$this->site = null;
}
// checks custom language definition for slugs
if ($slugsOption = $this->option('slugs')) {
- // slugs option must be set to string or "slugs" => ["language" => "de"] as array
- if (is_string($slugsOption) === true || isset($slugsOption['language']) === true) {
+ // slugs option must be set to string or
+ // "slugs" => ["language" => "de"] as array
+ if (
+ is_string($slugsOption) === true ||
+ isset($slugsOption['language']) === true
+ ) {
$this->i18n();
}
}
@@ -1184,7 +1188,7 @@ class App
string|null $path = null,
string|null $method = null
): Response|null {
- if (($_ENV['KIRBY_RENDER'] ?? true) === false) {
+ if ((filter_var($_ENV['KIRBY_RENDER'] ?? true, FILTER_VALIDATE_BOOLEAN)) === false) {
return null;
}
@@ -1211,7 +1215,7 @@ class App
/**
* Path resolver for the router
*
- * @internal
+ * @unstable
* @throws \Kirby\Exception\NotFoundException if the home page cannot be found
*/
public function resolve(
@@ -1238,7 +1242,9 @@ class App
return $homePage;
}
- throw new NotFoundException('The home page does not exist');
+ throw new NotFoundException(
+ message: 'The home page does not exist'
+ );
}
// search for the page by path
@@ -1248,7 +1254,7 @@ class App
if (!$page && $draft = $site->draft($path)) {
if (
$this->user() ||
- $draft->isVerified($this->request()->get('token'))
+ $draft->renderVersionFromRequest() !== null
) {
$page = $draft;
}
@@ -1287,16 +1293,46 @@ class App
}
}
+ // try to resolve clean URLs to site files
+ if (str_contains($path, '/') === false) {
+ return $this->resolveFile($site->file($path));
+ }
+
$id = dirname($path);
$filename = basename($path);
- // try to resolve image urls for pages and drafts
+ // try to resolve clean URLs to files for pages and drafts
if ($page = $site->findPageOrDraft($id)) {
- return $page->file($filename);
+ return $this->resolveFile($page->file($filename));
}
- // try to resolve site files at least
- return $site->file($filename);
+ // none of our resolvers were successful
+ return null;
+ }
+
+ /**
+ * Filters a resolved file object using the configuration
+ * @internal
+ */
+ public function resolveFile(File|null $file): File|null
+ {
+ // shortcut for files that don't exist
+ if ($file === null) {
+ return null;
+ }
+
+ $option = $this->option('content.fileRedirects', false);
+
+ if ($option === true) {
+ return $file;
+ }
+
+ if ($option instanceof Closure) {
+ return $option($file) === true ? $file : null;
+ }
+
+ // option was set to `false` or an invalid value
+ return null;
}
/**
@@ -1307,14 +1343,6 @@ class App
return $this->response ??= new Responder();
}
- /**
- * Returns all user roles
- */
- public function roles(): Roles
- {
- return $this->roles ??= Roles::load($this->root('roles'));
- }
-
/**
* Returns a system root
*/
@@ -1341,8 +1369,6 @@ class App
/**
* Returns the Router singleton
- *
- * @internal
*/
public function router(): Router
{
@@ -1374,8 +1400,6 @@ class App
/**
* Returns all defined routes
- *
- * @internal
*/
public function routes(): array
{
@@ -1385,7 +1409,7 @@ class App
$registry = $this->extensions('routes');
$system = $this->core->routes();
- $routes = array_merge($system['before'], $registry, $system['after']);
+ $routes = [...$system['before'], ...$registry, ...$system['after']];
return $this->routes = $routes;
}
@@ -1423,8 +1447,6 @@ class App
/**
* Load and set the current language if it exists
* Otherwise fall back to the default language
- *
- * @internal
*/
public function setCurrentLanguage(
string|null $languageCode = null
@@ -1434,7 +1456,8 @@ class App
return $this->language = null;
}
- $this->language = $this->language($languageCode) ?? $this->defaultLanguage();
+ $this->language = $this->language($languageCode);
+ $this->language ??= $this->defaultLanguage();
Locale::set($this->language->locale());
@@ -1509,7 +1532,7 @@ class App
*
* @return $this
*/
- protected function setSite(Site|array|null $site = null): static
+ public function setSite(Site|array|null $site = null): static
{
if (is_array($site) === true) {
$site = new Site($site);
@@ -1533,8 +1556,6 @@ class App
/**
* Applies the smartypants rule on the text
- *
- * @internal
*/
public function smartypants(string|null $text = null): string
{
@@ -1551,8 +1572,8 @@ class App
if ($this->multilang() === true) {
$languageSmartypants = $this->language()->smartypants() ?? [];
- if (empty($languageSmartypants) === false) {
- $options = array_merge($options, $languageSmartypants);
+ if ($languageSmartypants !== []) {
+ $options = [...$options, ...$languageSmartypants];
}
}
@@ -1580,7 +1601,7 @@ class App
$snippet = ($this->component('snippet'))(
$this,
$name,
- array_merge($this->data, $data),
+ [...$this->data, ...$data],
$slots
);
@@ -1592,6 +1613,14 @@ class App
return null;
}
+ /**
+ * Returns the default storage instance for a given Model
+ */
+ public function storage(ModelWithContent $model): Storage
+ {
+ return $this->component('storage')($this, $model);
+ }
+
/**
* System check class
*/
@@ -1603,8 +1632,6 @@ class App
/**
* Uses the template component to initialize
* and return the Template object
- *
- * @internal
*/
public function template(
string $name,
@@ -1626,47 +1653,13 @@ class App
* Trigger a hook by name
*
* @param string $name Full event name
- * @param array $args Associative array of named event arguments
- * @param \Kirby\Cms\Event|null $originalEvent Event object (internal use)
+ * @param array $args Associative array of named arguments
*/
public function trigger(
string $name,
- array $args = [],
- Event|null $originalEvent = null
+ array $args = []
): void {
- $event = $originalEvent ?? new Event($name, $args);
-
- if ($functions = $this->extension('hooks', $name)) {
- static $level = 0;
- static $triggered = [];
- $level++;
-
- foreach ($functions as $index => $function) {
- if (in_array($function, $triggered[$name] ?? []) === true) {
- continue;
- }
-
- // mark the hook as triggered, to avoid endless loops
- $triggered[$name][] = $function;
-
- // bind the App object to the hook
- $event->call($this, $function);
- }
-
- $level--;
-
- if ($level === 0) {
- $triggered = [];
- }
- }
-
- // trigger wildcard hooks if available
- $nameWildcards = $event->nameWildcards();
- if ($originalEvent === null && count($nameWildcards) > 0) {
- foreach ($nameWildcards as $nameWildcard) {
- $this->trigger($nameWildcard, $args, $event);
- }
- }
+ $this->events->trigger($name, $args);
}
/**
@@ -1715,7 +1708,9 @@ class App
try {
return static::$version ??= Data::read(dirname(__DIR__, 2) . '/composer.json')['version'] ?? null;
} catch (Throwable) {
- throw new LogicException('The Kirby version cannot be detected. The composer.json is probably missing or not readable.');
+ throw new LogicException(
+ message: 'The Kirby version cannot be detected. The composer.json is probably missing or not readable.'
+ );
}
}
diff --git a/public/kirby/src/Cms/AppCaches.php b/public/kirby/src/Cms/AppCaches.php
index 6c1595c..8ea7b5d 100644
--- a/public/kirby/src/Cms/AppCaches.php
+++ b/public/kirby/src/Cms/AppCaches.php
@@ -40,10 +40,10 @@ trait AppCaches
$types = $this->extensions['cacheTypes'] ?? [];
if (array_key_exists($type, $types) === false) {
- throw new InvalidArgumentException([
- 'key' => 'cache.type.invalid',
- 'data' => ['type' => $type]
- ]);
+ throw new InvalidArgumentException(
+ key: 'cache.type.invalid',
+ data: ['type' => $type]
+ );
}
$className = $types[$type];
@@ -53,10 +53,10 @@ trait AppCaches
// check if it is a usable cache object
if ($cache instanceof Cache === false) {
- throw new InvalidArgumentException([
- 'key' => 'cache.type.invalid',
- 'data' => ['type' => $type]
- ]);
+ throw new InvalidArgumentException(
+ key: 'cache.type.invalid',
+ data: ['type' => $type]
+ );
}
return $this->caches[$key] = $cache;
@@ -79,7 +79,7 @@ trait AppCaches
$prefix =
str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
'/' .
- str_replace('.', '/', $key);
+ str_replace(['/', '.'], ['_', '/'], $key);
$defaults = [
'active' => true,
@@ -93,7 +93,7 @@ trait AppCaches
return $defaults;
}
- return array_merge($defaults, $options);
+ return [...$defaults, ...$options];
}
/**
@@ -111,7 +111,7 @@ trait AppCaches
// plain keys without dots don't need further investigation
// since they can never be from a plugin.
- if (strpos($key, '.') === false) {
+ if (str_contains($key, '.') === false) {
return $prefixedKey;
}
diff --git a/public/kirby/src/Cms/AppErrors.php b/public/kirby/src/Cms/AppErrors.php
index 677a9c3..1dc565e 100644
--- a/public/kirby/src/Cms/AppErrors.php
+++ b/public/kirby/src/Cms/AppErrors.php
@@ -30,8 +30,6 @@ trait AppErrors
* Allows to disable Whoops globally in CI;
* can be overridden by explicitly setting
* the `whoops` option to `true` or `false`
- *
- * @internal
*/
public static bool $enableWhoops = true;
@@ -148,11 +146,14 @@ trait AppErrors
if ($this->option('debug') === true) {
echo Response::json([
'status' => 'error',
- 'exception' => get_class($exception),
+ 'exception' => $exception::class,
'code' => $code,
'message' => $exception->getMessage(),
'details' => $details,
- 'file' => F::relativepath($exception->getFile(), $this->environment()->get('DOCUMENT_ROOT', '')),
+ 'file' => F::relativepath(
+ $exception->getFile(),
+ $this->environment()->get('DOCUMENT_ROOT', '')
+ ),
'line' => $exception->getLine(),
], $httpCode);
} else {
diff --git a/public/kirby/src/Cms/AppPlugins.php b/public/kirby/src/Cms/AppPlugins.php
index 645c652..b5eef6b 100644
--- a/public/kirby/src/Cms/AppPlugins.php
+++ b/public/kirby/src/Cms/AppPlugins.php
@@ -11,6 +11,8 @@ use Kirby\Filesystem\F;
use Kirby\Filesystem\Mime;
use Kirby\Form\Field as FormField;
use Kirby\Image\Image;
+use Kirby\Plugin\License;
+use Kirby\Plugin\Plugin;
use Kirby\Text\KirbyTag;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection as ToolkitCollection;
@@ -57,6 +59,7 @@ trait AppPlugins
'collectionMethods' => [],
'fieldMethods' => [],
'fileMethods' => [],
+ 'filePreviews' => [],
'fileTypes' => [],
'filesMethods' => [],
'fields' => [],
@@ -94,8 +97,7 @@ trait AppPlugins
/**
* Register all given extensions
*
- * @internal
- * @param \Kirby\Cms\Plugin $plugin|null The plugin which defined those extensions
+ * @param \Kirby\Plugin\Plugin|null $plugin The plugin which defined those extensions
*/
public function extend(
array $extensions,
@@ -120,7 +122,11 @@ trait AppPlugins
$api['routes'] = $api['routes']($this);
}
- return $this->extensions['api'] = A::merge($this->extensions['api'], $api, A::MERGE_APPEND);
+ return $this->extensions['api'] = A::merge(
+ $this->extensions['api'],
+ $api,
+ A::MERGE_APPEND
+ );
}
return $this->extensions['api'];
@@ -144,7 +150,10 @@ trait AppPlugins
*/
protected function extendAssetMethods(array $methods): array
{
- return $this->extensions['assetMethods'] = Asset::$methods = array_merge(Asset::$methods, $methods);
+ return $this->extensions['assetMethods'] = Asset::$methods = [
+ ...Asset::$methods,
+ ...$methods
+ ];
}
/**
@@ -152,7 +161,10 @@ trait AppPlugins
*/
protected function extendAuthChallenges(array $challenges): array
{
- return $this->extensions['authChallenges'] = Auth::$challenges = array_merge(Auth::$challenges, $challenges);
+ return $this->extensions['authChallenges'] = Auth::$challenges = [
+ ...Auth::$challenges,
+ ...$challenges
+ ];
}
/**
@@ -160,7 +172,10 @@ trait AppPlugins
*/
protected function extendBlockMethods(array $methods): array
{
- return $this->extensions['blockMethods'] = Block::$methods = array_merge(Block::$methods, $methods);
+ return $this->extensions['blockMethods'] = Block::$methods = [
+ ...Block::$methods,
+ ...$methods
+ ];
}
/**
@@ -168,7 +183,7 @@ trait AppPlugins
*/
protected function extendBlockModels(array $models): array
{
- return $this->extensions['blockModels'] = Block::$models = array_merge(Block::$models, $models);
+ return $this->extensions['blockModels'] = Block::extendModels($models);
}
/**
@@ -176,7 +191,10 @@ trait AppPlugins
*/
protected function extendBlocksMethods(array $methods): array
{
- return $this->extensions['blockMethods'] = Blocks::$methods = array_merge(Blocks::$methods, $methods);
+ return $this->extensions['blockMethods'] = Blocks::$methods = [
+ ...Blocks::$methods,
+ ...$methods
+ ];
}
/**
@@ -184,7 +202,10 @@ trait AppPlugins
*/
protected function extendBlueprints(array $blueprints): array
{
- return $this->extensions['blueprints'] = array_merge($this->extensions['blueprints'], $blueprints);
+ return $this->extensions['blueprints'] = [
+ ...$this->extensions['blueprints'],
+ ...$blueprints
+ ];
}
/**
@@ -192,7 +213,10 @@ trait AppPlugins
*/
protected function extendCacheTypes(array $cacheTypes): array
{
- return $this->extensions['cacheTypes'] = array_merge($this->extensions['cacheTypes'], $cacheTypes);
+ return $this->extensions['cacheTypes'] = [
+ ...$this->extensions['cacheTypes'],
+ ...$cacheTypes
+ ];
}
/**
@@ -200,7 +224,10 @@ trait AppPlugins
*/
protected function extendCommands(array $commands): array
{
- return $this->extensions['commands'] = array_merge($this->extensions['commands'], $commands);
+ return $this->extensions['commands'] = [
+ ...$this->extensions['commands'],
+ ...$commands
+ ];
}
/**
@@ -208,7 +235,10 @@ trait AppPlugins
*/
protected function extendCollectionFilters(array $filters): array
{
- return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = array_merge(ToolkitCollection::$filters, $filters);
+ return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = [
+ ...ToolkitCollection::$filters,
+ ...$filters
+ ];
}
/**
@@ -216,7 +246,10 @@ trait AppPlugins
*/
protected function extendCollectionMethods(array $methods): array
{
- return $this->extensions['collectionMethods'] = Collection::$methods = array_merge(Collection::$methods, $methods);
+ return $this->extensions['collectionMethods'] = Collection::$methods = [
+ ...Collection::$methods,
+ ...$methods
+ ];
}
/**
@@ -224,7 +257,10 @@ trait AppPlugins
*/
protected function extendCollections(array $collections): array
{
- return $this->extensions['collections'] = array_merge($this->extensions['collections'], $collections);
+ return $this->extensions['collections'] = [
+ ...$this->extensions['collections'],
+ ...$collections
+ ];
}
/**
@@ -232,7 +268,10 @@ trait AppPlugins
*/
protected function extendComponents(array $components): array
{
- return $this->extensions['components'] = array_merge($this->extensions['components'], $components);
+ return $this->extensions['components'] = [
+ ...$this->extensions['components'],
+ ...$components
+ ];
}
/**
@@ -240,7 +279,10 @@ trait AppPlugins
*/
protected function extendControllers(array $controllers): array
{
- return $this->extensions['controllers'] = array_merge($this->extensions['controllers'], $controllers);
+ return $this->extensions['controllers'] = [
+ ...$this->extensions['controllers'],
+ ...$controllers
+ ];
}
/**
@@ -248,7 +290,24 @@ trait AppPlugins
*/
protected function extendFileMethods(array $methods): array
{
- return $this->extensions['fileMethods'] = File::$methods = array_merge(File::$methods, $methods);
+ return $this->extensions['fileMethods'] = File::$methods = [
+ ...File::$methods,
+ ...$methods
+ ];
+ }
+
+ /**
+ * Registers additional file preview handlers
+ * @since 5.0.0
+ */
+ protected function extendFilePreviews(array $previews): array
+ {
+ return $this->extensions['filePreviews'] = [
+ ...$previews,
+ // make sure new previews go first, so that custom
+ // handler can override core default previews
+ ...$this->extensions['filePreviews'],
+ ];
}
/**
@@ -269,26 +328,36 @@ trait AppPlugins
F::$types[$type] = [];
}
- if (in_array($extension, F::$types[$type]) === false) {
+ if (in_array($extension, F::$types[$type], true) === false) {
F::$types[$type][] = $extension;
}
}
if ($mime !== null) {
+ // if `Mime::$types[$extension]` is not already an array,
+ // make it one and append the new MIME type
+ // unless it's already in the list
if (array_key_exists($extension, Mime::$types) === true) {
- // if `Mime::$types[$extension]` is not already an array, make it one
- // and append the new MIME type unless it's already in the list
- Mime::$types[$extension] = array_unique(array_merge((array)Mime::$types[$extension], (array)$mime));
+ Mime::$types[$extension] = array_unique([
+ ...(array)Mime::$types[$extension],
+ ...(array)$mime
+ ]);
} else {
Mime::$types[$extension] = $mime;
}
}
- if ($resizable === true && in_array($extension, Image::$resizableTypes) === false) {
+ if (
+ $resizable === true &&
+ in_array($extension, Image::$resizableTypes, true) === false
+ ) {
Image::$resizableTypes[] = $extension;
}
- if ($viewable === true && in_array($extension, Image::$viewableTypes) === false) {
+ if (
+ $viewable === true &&
+ in_array($extension, Image::$viewableTypes, true) === false
+ ) {
Image::$viewableTypes[] = $extension;
}
}
@@ -306,7 +375,10 @@ trait AppPlugins
*/
protected function extendFilesMethods(array $methods): array
{
- return $this->extensions['filesMethods'] = Files::$methods = array_merge(Files::$methods, $methods);
+ return $this->extensions['filesMethods'] = Files::$methods = [
+ ...Files::$methods,
+ ...$methods
+ ];
}
/**
@@ -314,7 +386,10 @@ trait AppPlugins
*/
protected function extendFieldMethods(array $methods): array
{
- return $this->extensions['fieldMethods'] = Field::$methods = array_merge(Field::$methods, array_change_key_case($methods));
+ return $this->extensions['fieldMethods'] = Field::$methods = [
+ ...Field::$methods,
+ ...array_change_key_case($methods)
+ ];
}
/**
@@ -322,7 +397,10 @@ trait AppPlugins
*/
protected function extendFields(array $fields): array
{
- return $this->extensions['fields'] = FormField::$types = array_merge(FormField::$types, $fields);
+ return $this->extensions['fields'] = FormField::$types = [
+ ...FormField::$types,
+ ...$fields
+ ];
}
/**
@@ -358,7 +436,10 @@ trait AppPlugins
*/
protected function extendLayoutMethods(array $methods): array
{
- return $this->extensions['layoutMethods'] = Layout::$methods = array_merge(Layout::$methods, $methods);
+ return $this->extensions['layoutMethods'] = Layout::$methods = [
+ ...Layout::$methods,
+ ...$methods
+ ];
}
/**
@@ -366,7 +447,10 @@ trait AppPlugins
*/
protected function extendLayoutColumnMethods(array $methods): array
{
- return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = array_merge(LayoutColumn::$methods, $methods);
+ return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = [
+ ...LayoutColumn::$methods,
+ ...$methods
+ ];
}
/**
@@ -374,7 +458,10 @@ trait AppPlugins
*/
protected function extendLayoutsMethods(array $methods): array
{
- return $this->extensions['layoutsMethods'] = Layouts::$methods = array_merge(Layouts::$methods, $methods);
+ return $this->extensions['layoutsMethods'] = Layouts::$methods = [
+ ...Layouts::$methods,
+ ...$methods
+ ];
}
/**
@@ -388,7 +475,11 @@ trait AppPlugins
$options = [$plugin->prefix() => $options];
}
- return $this->extensions['options'] = $this->options = A::merge($options, $this->options, A::MERGE_REPLACE);
+ return $this->extensions['options'] = $this->options = A::merge(
+ $options,
+ $this->options,
+ A::MERGE_REPLACE
+ );
}
/**
@@ -396,7 +487,10 @@ trait AppPlugins
*/
protected function extendPageMethods(array $methods): array
{
- return $this->extensions['pageMethods'] = Page::$methods = array_merge(Page::$methods, $methods);
+ return $this->extensions['pageMethods'] = Page::$methods = [
+ ...Page::$methods,
+ ...$methods
+ ];
}
/**
@@ -404,7 +498,10 @@ trait AppPlugins
*/
protected function extendPagesMethods(array $methods): array
{
- return $this->extensions['pagesMethods'] = Pages::$methods = array_merge(Pages::$methods, $methods);
+ return $this->extensions['pagesMethods'] = Pages::$methods = [
+ ...Pages::$methods,
+ ...$methods
+ ];
}
/**
@@ -412,7 +509,7 @@ trait AppPlugins
*/
protected function extendPageModels(array $models): array
{
- return $this->extensions['pageModels'] = Page::$models = array_merge(Page::$models, $models);
+ return $this->extensions['pageModels'] = Page::extendModels($models);
}
/**
@@ -420,7 +517,10 @@ trait AppPlugins
*/
protected function extendPages(array $pages): array
{
- return $this->extensions['pages'] = array_merge($this->extensions['pages'], $pages);
+ return $this->extensions['pages'] = [
+ ...$this->extensions['pages'],
+ ...$pages
+ ];
}
/**
@@ -434,7 +534,10 @@ trait AppPlugins
$permissions = [$plugin->prefix() => $permissions];
}
- return $this->extensions['permissions'] = Permissions::$extendedActions = array_merge(Permissions::$extendedActions, $permissions);
+ return $this->extensions['permissions'] = Permissions::$extendedActions = [
+ ...Permissions::$extendedActions,
+ ...$permissions
+ ];
}
/**
@@ -446,7 +549,10 @@ trait AppPlugins
$routes = $routes($this);
}
- return $this->extensions['routes'] = array_merge($this->extensions['routes'], $routes);
+ return $this->extensions['routes'] = [
+ ...$this->extensions['routes'],
+ ...$routes
+ ];
}
/**
@@ -454,7 +560,10 @@ trait AppPlugins
*/
protected function extendSections(array $sections): array
{
- return $this->extensions['sections'] = Section::$types = array_merge(Section::$types, $sections);
+ return $this->extensions['sections'] = Section::$types = [
+ ...Section::$types,
+ ...$sections
+ ];
}
/**
@@ -462,7 +571,10 @@ trait AppPlugins
*/
protected function extendSiteMethods(array $methods): array
{
- return $this->extensions['siteMethods'] = Site::$methods = array_merge(Site::$methods, $methods);
+ return $this->extensions['siteMethods'] = Site::$methods = [
+ ...Site::$methods,
+ ...$methods
+ ];
}
/**
@@ -478,7 +590,10 @@ trait AppPlugins
*/
protected function extendSnippets(array $snippets): array
{
- return $this->extensions['snippets'] = array_merge($this->extensions['snippets'], $snippets);
+ return $this->extensions['snippets'] = [
+ ...$this->extensions['snippets'],
+ ...$snippets
+ ];
}
/**
@@ -486,7 +601,10 @@ trait AppPlugins
*/
protected function extendStructureMethods(array $methods): array
{
- return $this->extensions['structureMethods'] = Structure::$methods = array_merge(Structure::$methods, $methods);
+ return $this->extensions['structureMethods'] = Structure::$methods = [
+ ...Structure::$methods,
+ ...$methods
+ ];
}
/**
@@ -494,7 +612,10 @@ trait AppPlugins
*/
protected function extendStructureObjectMethods(array $methods): array
{
- return $this->extensions['structureObjectMethods'] = StructureObject::$methods = array_merge(StructureObject::$methods, $methods);
+ return $this->extensions['structureObjectMethods'] = StructureObject::$methods = [
+ ...StructureObject::$methods,
+ ...$methods
+ ];
}
/**
@@ -502,7 +623,10 @@ trait AppPlugins
*/
protected function extendTags(array $tags): array
{
- return $this->extensions['tags'] = KirbyTag::$types = array_merge(KirbyTag::$types, array_change_key_case($tags));
+ return $this->extensions['tags'] = KirbyTag::$types = [
+ ...KirbyTag::$types,
+ ...array_change_key_case($tags)
+ ];
}
/**
@@ -510,7 +634,10 @@ trait AppPlugins
*/
protected function extendTemplates(array $templates): array
{
- return $this->extensions['templates'] = array_merge($this->extensions['templates'], $templates);
+ return $this->extensions['templates'] = [
+ ...$this->extensions['templates'],
+ ...$templates
+ ];
}
/**
@@ -518,7 +645,10 @@ trait AppPlugins
*/
protected function extendTranslations(array $translations): array
{
- return $this->extensions['translations'] = array_replace_recursive($this->extensions['translations'], $translations);
+ return $this->extensions['translations'] = array_replace_recursive(
+ $this->extensions['translations'],
+ $translations
+ );
}
/**
@@ -528,7 +658,10 @@ trait AppPlugins
*/
protected function extendThirdParty(array $extensions): array
{
- return $this->extensions['thirdParty'] = array_replace_recursive($this->extensions['thirdParty'], $extensions);
+ return $this->extensions['thirdParty'] = array_replace_recursive(
+ $this->extensions['thirdParty'],
+ $extensions
+ );
}
/**
@@ -536,7 +669,10 @@ trait AppPlugins
*/
protected function extendUserMethods(array $methods): array
{
- return $this->extensions['userMethods'] = User::$methods = array_merge(User::$methods, $methods);
+ return $this->extensions['userMethods'] = User::$methods = [
+ ...User::$methods,
+ ...$methods
+ ];
}
/**
@@ -544,7 +680,7 @@ trait AppPlugins
*/
protected function extendUserModels(array $models): array
{
- return $this->extensions['userModels'] = User::$models = array_merge(User::$models, $models);
+ return $this->extensions['userModels'] = User::extendModels($models);
}
/**
@@ -552,7 +688,10 @@ trait AppPlugins
*/
protected function extendUsersMethods(array $methods): array
{
- return $this->extensions['usersMethods'] = Users::$methods = array_merge(Users::$methods, $methods);
+ return $this->extensions['usersMethods'] = Users::$methods = [
+ ...Users::$methods,
+ ...$methods
+ ];
}
/**
@@ -560,13 +699,15 @@ trait AppPlugins
*/
protected function extendValidators(array $validators): array
{
- return $this->extensions['validators'] = V::$validators = array_merge(V::$validators, $validators);
+ return $this->extensions['validators'] = V::$validators = [
+ ...V::$validators,
+ ...$validators
+ ];
}
/**
* Returns a given extension by type and name
*
- * @internal
* @param string $type i.e. `'hooks'`
* @param string $name i.e. `'page.delete:before'`
*/
@@ -580,8 +721,6 @@ trait AppPlugins
/**
* Returns the extensions registry
- *
- * @internal
*/
public function extensions(string|null $type = null): array
{
@@ -681,6 +820,7 @@ trait AppPlugins
$this->extendBlueprints($this->core->blueprints());
$this->extendFieldMethods($this->core->fieldMethods());
$this->extendFields($this->core->fields());
+ $this->extendFilePreviews($this->core->filePreviews());
$this->extendSections($this->core->sections());
$this->extendSnippets($this->core->snippets());
$this->extendTags($this->core->kirbyTags());
@@ -716,7 +856,8 @@ trait AppPlugins
array|null $extends = null,
array $info = [],
string|null $root = null,
- string|null $version = null
+ string|null $version = null,
+ Closure|string|array|null $license = null,
): Plugin|null {
if ($extends === null) {
return static::$plugins[$name] ?? null;
@@ -726,6 +867,7 @@ trait AppPlugins
name: $name,
extends: $extends,
info: $info,
+ license: $license,
// TODO: Remove fallback to $extends in v7
root: $root ?? $extends['root'] ?? dirname(debug_backtrace()[0]['file']),
version: $version
@@ -734,7 +876,9 @@ trait AppPlugins
$name = $plugin->name();
if (isset(static::$plugins[$name]) === true) {
- throw new DuplicateException('The plugin "' . $name . '" has already been registered');
+ throw new DuplicateException(
+ message: 'The plugin "' . $name . '" has already been registered'
+ );
}
return static::$plugins[$name] = $plugin;
@@ -744,7 +888,6 @@ trait AppPlugins
* Loads and returns all plugins in the site/plugins directory
* Loading only happens on the first call.
*
- * @internal
* @param array|null $plugins Can be used to overwrite the plugins registry
*/
public function plugins(array|null $plugins = null): array
@@ -779,7 +922,10 @@ trait AppPlugins
$loaded = [];
foreach (Dir::read($root) as $dirname) {
- if (in_array(substr($dirname, 0, 1), ['.', '_']) === true) {
+ if (
+ str_starts_with($dirname, '.') ||
+ str_starts_with($dirname, '_')
+ ) {
continue;
}
diff --git a/public/kirby/src/Cms/AppTranslations.php b/public/kirby/src/Cms/AppTranslations.php
index 1e24f23..2dfe918 100644
--- a/public/kirby/src/Cms/AppTranslations.php
+++ b/public/kirby/src/Cms/AppTranslations.php
@@ -31,7 +31,7 @@ trait AppTranslations
$this->multilang() === true &&
$language = $this->languages()->find($locale)
) {
- $data = array_merge($data, $language->translations());
+ $data = [...$data, ...$language->translations()];
}
@@ -51,7 +51,7 @@ trait AppTranslations
if ($this->multilang() === true) {
// first try to fall back to the configured default language
$defaultCode = $this->defaultLanguage()->code();
- $fallback = [$defaultCode];
+ $fallback = [$defaultCode];
// if the default language is specified with a country code
// (e.g. `en-us`), also try with just the language code
@@ -105,8 +105,6 @@ trait AppTranslations
/**
* Set the current translation
- *
- * @internal
*/
public function setCurrentTranslation(string|null $translationCode = null): void
{
@@ -121,7 +119,7 @@ trait AppTranslations
public function translation(string|null $locale = null): Translation
{
$locale ??= I18n::locale();
- $locale = basename($locale);
+ $locale = basename($locale);
// prefer loading them from the translations collection
if ($this->translations instanceof Translations) {
@@ -135,11 +133,15 @@ trait AppTranslations
// inject current language translations
if ($language = $this->language($locale)) {
- $inject = array_merge($inject, $language->translations());
+ $inject = [...$inject, ...$language->translations()];
}
// load from disk instead
- return Translation::load($locale, $this->root('i18n:translations') . '/' . $locale . '.json', $inject);
+ return Translation::load(
+ $locale,
+ $this->root('i18n:translations') . '/' . $locale . '.json',
+ $inject
+ );
}
/**
@@ -161,14 +163,17 @@ trait AppTranslations
// merges language translations with extensions translations
if (empty($languageTranslations) === false) {
- $translations[$languageCode] = array_merge(
- $translations[$languageCode] ?? [],
- $languageTranslations
- );
+ $translations[$languageCode] = [
+ ...$translations[$languageCode] ?? [],
+ ...$languageTranslations
+ ];
}
}
}
- return $this->translations = Translations::load($this->root('i18n:translations'), $translations);
+ return $this->translations = Translations::load(
+ $this->root('i18n:translations'),
+ $translations
+ );
}
}
diff --git a/public/kirby/src/Cms/AppUsers.php b/public/kirby/src/Cms/AppUsers.php
index 6f2f7be..a82af8c 100644
--- a/public/kirby/src/Cms/AppUsers.php
+++ b/public/kirby/src/Cms/AppUsers.php
@@ -22,7 +22,6 @@ trait AppUsers
/**
* Returns the Authentication layer class
- * @internal
*/
public function auth(): Auth
{
@@ -67,6 +66,33 @@ trait AppUsers
}
}
+ /**
+ * Returns all user roles
+ */
+ public function roles(): Roles
+ {
+ return $this->roles ??= Roles::load($this->root('roles'));
+ }
+
+ /**
+ * Returns a specific user role by id
+ * or the role of the current user if no id is given
+ *
+ * @param bool $allowImpersonation If set to false, only the role of the
+ * actually logged in user will be returned
+ * (when `$id` is passed as `null`)
+ */
+ public function role(
+ string|null $id = null,
+ bool $allowImpersonation = true
+ ): Role|null {
+ if ($id !== null) {
+ return $this->roles()->find($id);
+ }
+
+ return $this->user(null, $allowImpersonation)?->role();
+ }
+
/**
* Set the currently active user id
*
diff --git a/public/kirby/src/Cms/Auth.php b/public/kirby/src/Cms/Auth.php
index 689f567..66e73bc 100644
--- a/public/kirby/src/Cms/Auth.php
+++ b/public/kirby/src/Cms/Auth.php
@@ -104,12 +104,10 @@ class Auth
if ($user === null) {
$this->kirby->trigger('user.login:failed', compact('email'));
- throw new NotFoundException([
- 'key' => 'user.notFound',
- 'data' => [
- 'name' => $email
- ]
- ]);
+ throw new NotFoundException(
+ key: 'user.notFound',
+ data: ['name' => $email]
+ );
}
// try to find an enabled challenge that is available for that user
@@ -140,16 +138,19 @@ class Auth
// if no suitable challenge was found, `$challenge === null` at this point
if ($challenge === null) {
- throw new LogicException('Could not find a suitable authentication challenge');
+ throw new LogicException(
+ 'Could not find a suitable authentication challenge'
+ );
}
} catch (Throwable $e) {
// only throw the exception in auth debug mode
$this->fail($e);
}
- // always set the email and timeout, even if the challenge
+ // always set the email, mode and timeout, even if the challenge
// won't be created; this avoids leaking whether the user exists
$session->set('kirby.challenge.email', $email);
+ $session->set('kirby.challenge.mode', $mode);
$session->set('kirby.challenge.timeout', time() + $timeout);
// sleep for a random amount of milliseconds
@@ -198,39 +199,52 @@ class Auth
* for a basic authentication header with
* valid credentials
*
- * @param \Kirby\Http\Request\Auth\BasicAuth|null $auth
* @throws \Kirby\Exception\InvalidArgumentException if the authorization header is invalid
* @throws \Kirby\Exception\PermissionException if basic authentication is not allowed
*/
public function currentUserFromBasicAuth(BasicAuth|null $auth = null): User|null
{
if ($this->kirby->option('api.basicAuth', false) !== true) {
- throw new PermissionException('Basic authentication is not activated');
+ 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');
+ 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');
+ throw new PermissionException(
+ 'Basic authentication cannot be used with 2FA'
+ );
}
}
- $request = $this->kirby->request();
- $auth ??= $request->auth();
+ $request = $this->kirby->request();
+ $auth ??= $request->auth();
if (!$auth || $auth->type() !== 'basic') {
- throw new InvalidArgumentException('Invalid authorization header');
+ throw new InvalidArgumentException(
+ 'Invalid authorization header'
+ );
}
- // only allow basic auth when https is enabled or insecure requests permitted
- if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) {
- throw new PermissionException('Basic authentication is only allowed over HTTPS');
+ // only allow basic auth when https is enabled or
+ // insecure requests permitted
+ if (
+ $request->ssl() === false &&
+ $this->kirby->option('api.allowInsecure', false) !== true
+ ) {
+ throw new PermissionException(
+ 'Basic authentication is only allowed over HTTPS'
+ );
}
return $this->validatePassword($auth->username(), $auth->password());
@@ -269,6 +283,7 @@ class Auth
if ($passwordTimestamp = $user->passwordTimestamp()) {
$loginTimestamp = $session->data()->get('kirby.loginTimestamp');
+
if (is_int($loginTimestamp) !== true) {
// session that was created before Kirby
// 3.5.8.3, 3.6.6.3, 3.7.5.2, 3.8.4.1 or 3.9.6
@@ -329,7 +344,7 @@ class Auth
'id' => 'nobody',
'role' => 'nobody',
]),
- default => ($this->kirby->users()->find($who) ?? throw new NotFoundException('The user "' . $who . '" cannot be found'))
+ default => $this->kirby->users()->find($who) ?? throw new NotFoundException(message: 'The user "' . $who . '" cannot be found'),
};
}
@@ -444,7 +459,11 @@ class Auth
bool $allowImpersonation = true
): Status {
// try to return from cache
- if ($this->status && $session === null && $allowImpersonation === true) {
+ if (
+ $this->status &&
+ $session === null &&
+ $allowImpersonation === true
+ ) {
return $this->status;
}
@@ -453,17 +472,18 @@ class Auth
$props = ['kirby' => $this->kirby];
if ($user = $this->user($sessionObj, $allowImpersonation)) {
// a user is currently logged in
- if ($allowImpersonation === true && $this->impersonate !== null) {
- $props['status'] = 'impersonated';
- } else {
- $props['status'] = 'active';
- }
+ $props['email'] = $user->email();
+ $props['status'] = match (true) {
+ $allowImpersonation === true &&
+ $this->impersonate !== null => 'impersonated',
+ default => 'active'
+ };
- $props['email'] = $user->email();
} elseif ($email = $sessionObj->get('kirby.challenge.email')) {
// a challenge is currently pending
$props['status'] = 'pending';
$props['email'] = $email;
+ $props['mode'] = $sessionObj->get('kirby.challenge.mode');
$props['challenge'] = $sessionObj->get('kirby.challenge.type');
$props['challengeFallback'] = A::last($this->enabledChallenges());
} else {
@@ -492,10 +512,10 @@ class Auth
if ($this->isBlocked($email) === true) {
$this->kirby->trigger('user.login:failed', compact('email'));
- throw new PermissionException([
- 'details' => ['reason' => 'rate-limited'],
- 'fallback' => 'Rate limit exceeded'
- ]);
+ throw new PermissionException(
+ details: ['reason' => 'rate-limited'],
+ fallback: 'Rate limit exceeded'
+ );
}
}
@@ -524,12 +544,10 @@ class Auth
}
}
- throw new NotFoundException([
- 'key' => 'user.notFound',
- 'data' => [
- 'name' => $email
- ]
- ]);
+ throw new NotFoundException(
+ key: 'user.notFound',
+ data: ['name' => $email]
+ );
} catch (Throwable $e) {
$details = $e instanceof Exception ? $e->getDetails() : [];
@@ -537,7 +555,7 @@ class Auth
if (($details['reason'] ?? null) !== 'rate-limited') {
try {
$this->track($email);
- } catch (Throwable $e) {
+ } catch (Throwable) {
// $e is overwritten with the exception
// from the track method if there's one
}
@@ -549,7 +567,7 @@ class Auth
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
- $this->fail($e, new PermissionException(['key' => 'access.login']));
+ $this->fail($e, new PermissionException(key: 'access.login'));
}
}
@@ -583,7 +601,8 @@ class Auth
// remove entries that are no longer needed
$originalLog = $log;
- $time = time() - $this->kirby->option('auth.timeout', 3600);
+ $time = time() - $this->kirby->option('auth.timeout', 3600);
+
foreach ($log as $category => $entries) {
$log[$category] = array_filter(
$entries,
@@ -619,6 +638,7 @@ class Auth
$session = $this->kirby->session();
$session->remove('kirby.challenge.code');
$session->remove('kirby.challenge.email');
+ $session->remove('kirby.challenge.mode');
$session->remove('kirby.challenge.timeout');
$session->remove('kirby.challenge.type');
@@ -628,13 +648,12 @@ class Auth
/**
* Clears the cached user data after logout
- * @internal
*/
public function flush(): void
{
$this->impersonate = null;
- $this->status = null;
- $this->user = null;
+ $this->status = null;
+ $this->user = null;
}
/**
@@ -782,18 +801,20 @@ class Auth
try {
$session = $this->kirby->session();
- // time-limiting; check this early so that we can destroy the session no
- // matter if the user exists (avoids leaking user information to attackers)
+ // time-limiting; check this early so that we can
+ // destroy the session no matter if the user exists
+ // (avoids leaking user information to attackers)
$timeout = $session->get('kirby.challenge.timeout');
+
if ($timeout !== null && time() > $timeout) {
// this challenge can never be completed,
// so delete it immediately
$this->logout();
- throw new PermissionException([
- 'details' => ['challengeDestroyed' => true],
- 'fallback' => 'Authentication challenge timeout'
- ]);
+ throw new PermissionException(
+ details: ['challengeDestroyed' => true],
+ fallback: 'Authentication challenge timeout'
+ );
}
// check if we have an active challenge
@@ -807,20 +828,18 @@ class Auth
// (otherwise "faked" challenges would be leaked)
$challengeDestroyed = is_string($email) !== true;
- throw new InvalidArgumentException([
- 'details' => compact('challengeDestroyed'),
- 'fallback' => 'No authentication challenge is active'
- ]);
+ throw new InvalidArgumentException(
+ details: compact('challengeDestroyed'),
+ fallback: 'No authentication challenge is active'
+ );
}
$user = $this->kirby->users()->find($email);
if ($user === null) {
- throw new NotFoundException([
- 'key' => 'user.notFound',
- 'data' => [
- 'name' => $email
- ]
- ]);
+ throw new NotFoundException(
+ key: 'user.notFound',
+ data: ['name' => $email]
+ );
}
// rate-limiting
@@ -833,16 +852,23 @@ class Auth
) {
$class = static::$challenges[$challenge];
if ($class::verify($user, $code) === true) {
+ $mode = $session->get('kirby.challenge.mode');
+
$this->logout();
$user->loginPasswordless();
+ // allow the user to set a new password without knowing the previous one
+ if ($mode === 'password-reset') {
+ $session->set('kirby.resetPassword', true);
+ }
+
// clear the status cache
$this->status = null;
return $user;
}
- throw new PermissionException(['key' => 'access.code']);
+ throw new PermissionException(key: 'access.code');
}
throw new LogicException(
@@ -867,10 +893,10 @@ class Auth
// even in production (used by the Panel to reset to the login form)
$challengeDestroyed = $details['challengeDestroyed'] ?? false;
- $fallback = new PermissionException([
- 'details' => compact('challengeDestroyed'),
- 'key' => 'access.code'
- ]);
+ $fallback = new PermissionException(
+ details: compact('challengeDestroyed'),
+ key: 'access.code'
+ );
// keep throwing the original error in debug mode,
// otherwise hide it to avoid leaking security-relevant information
diff --git a/public/kirby/src/Cms/Auth/EmailChallenge.php b/public/kirby/src/Cms/Auth/EmailChallenge.php
index e2bef6f..a7b49cf 100644
--- a/public/kirby/src/Cms/Auth/EmailChallenge.php
+++ b/public/kirby/src/Cms/Auth/EmailChallenge.php
@@ -48,20 +48,30 @@ class EmailChallenge extends Challenge
$formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3);
// use the login templates for 2FA
- $mode = $options['mode'];
- if ($mode === '2fa') {
- $mode = 'login';
- }
+ $mode = match($options['mode']) {
+ '2fa' => 'login',
+ default => $options['mode']
+ };
$kirby = $user->kirby();
+ $from = $kirby->option(
+ 'auth.challenge.email.from',
+ 'noreply@' . $kirby->url('index', true)->host()
+ );
+ $name = $kirby->option(
+ 'auth.challenge.email.fromName',
+ $kirby->site()->title()
+ );
+ $subject = $kirby->option(
+ 'auth.challenge.email.subject',
+ I18n::translate('login.email.' . $mode . '.subject', null, $user->language())
+ );
+
$kirby->email([
- 'from' => $kirby->option('auth.challenge.email.from', 'noreply@' . $kirby->url('index', true)->host()),
- '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', null, $user->language())
- ),
+ 'from' => $from,
+ 'fromName' => $name,
+ 'to' => $user,
+ 'subject' => $subject,
'template' => 'auth/' . $mode,
'data' => [
'user' => $user,
diff --git a/public/kirby/src/Cms/Auth/Status.php b/public/kirby/src/Cms/Auth/Status.php
index ec43107..99ad9e8 100644
--- a/public/kirby/src/Cms/Auth/Status.php
+++ b/public/kirby/src/Cms/Auth/Status.php
@@ -5,7 +5,7 @@ namespace Kirby\Cms\Auth;
use Kirby\Cms\App;
use Kirby\Cms\User;
use Kirby\Exception\InvalidArgumentException;
-use Kirby\Toolkit\Properties;
+use Stringable;
/**
* Information container for the
@@ -18,7 +18,7 @@ use Kirby\Toolkit\Properties;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
-class Status
+class Status implements Stringable
{
/**
* Type of the active challenge
@@ -41,6 +41,12 @@ class Status
*/
protected App $kirby;
+ /**
+ * Purpose of the challenge:
+ * `login|password-reset|2fa`
+ */
+ protected string|null $mode;
+
/**
* Authentication status:
* `active|impersonated|pending|inactive`
@@ -49,25 +55,24 @@ class Status
/**
* Class constructor
- *
- * @param array $props
*/
public function __construct(array $props)
{
- if (in_array($props['status'], ['active', 'impersonated', 'pending', 'inactive']) !== true) {
- throw new InvalidArgumentException([
- 'data' => [
+ if (in_array($props['status'], ['active', 'impersonated', 'pending', 'inactive'], true) !== true) {
+ throw new InvalidArgumentException(
+ data: [
'argument' => '$props[\'status\']',
'method' => 'Status::__construct'
]
- ]);
+ );
}
- $this->kirby = $props['kirby'];
- $this->challenge = $props['challenge'] ?? null;
+ $this->kirby = $props['kirby'];
+ $this->challenge = $props['challenge'] ?? null;
$this->challengeFallback = $props['challengeFallback'] ?? null;
- $this->email = $props['email'] ?? null;
- $this->status = $props['status'];
+ $this->email = $props['email'] ?? null;
+ $this->mode = $props['mode'] ?? null;
+ $this->status = $props['status'];
}
/**
@@ -106,11 +111,11 @@ class Status
public function clone(array $props = []): static
{
return new static(array_replace_recursive([
- 'kirby' => $this->kirby,
- 'challenge' => $this->challenge,
+ 'kirby' => $this->kirby,
+ 'challenge' => $this->challenge,
'challengeFallback' => $this->challengeFallback,
- 'email' => $this->email,
- 'status' => $this->status,
+ 'email' => $this->email,
+ 'status' => $this->status,
], $props));
}
@@ -122,6 +127,16 @@ class Status
return $this->email;
}
+ /**
+ * Returns the purpose of the challenge
+ *
+ * @return string `login|password-reset|2fa`
+ */
+ public function mode(): string|null
+ {
+ return $this->mode;
+ }
+
/**
* Returns the authentication status
*
@@ -140,6 +155,7 @@ class Status
return [
'challenge' => $this->challenge(),
'email' => $this->email(),
+ 'mode' => $this->mode(),
'status' => $this->status()
];
}
@@ -151,7 +167,7 @@ class Status
{
// for security, only return the user if they are
// already logged in
- if (in_array($this->status(), ['active', 'impersonated']) !== true) {
+ if (in_array($this->status(), ['active', 'impersonated'], true) !== true) {
return null;
}
diff --git a/public/kirby/src/Cms/Block.php b/public/kirby/src/Cms/Block.php
index f83c547..e0734d4 100644
--- a/public/kirby/src/Cms/Block.php
+++ b/public/kirby/src/Cms/Block.php
@@ -6,6 +6,7 @@ use Kirby\Content\Content;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Str;
+use Stringable;
use Throwable;
/**
@@ -19,18 +20,16 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
+ *
+ * @extends \Kirby\Cms\Item<\Kirby\Cms\Blocks>
*/
-class Block extends Item
+class Block extends Item implements Stringable
{
use HasMethods;
+ use HasModels;
public const ITEMS_CLASS = Blocks::class;
- /**
- * Registry with all block models
- */
- public static array $models = [];
-
protected Content $content;
protected bool $isHidden;
protected string $type;
@@ -65,7 +64,9 @@ class Block extends Item
// @codeCoverageIgnoreEnd
if (isset($params['type']) === false) {
- throw new InvalidArgumentException('The block type is missing');
+ throw new InvalidArgumentException(
+ message: 'The block type is missing'
+ );
}
// make sure the content is always defined as array to keep
@@ -125,35 +126,12 @@ class Block extends Item
/**
* Constructs a block object with registering blocks models
- * @internal
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function factory(array $params): static
{
- $type = $params['type'] ?? null;
-
- if (
- empty($type) === false &&
- $class = (static::$models[$type] ?? null)
- ) {
- $object = new $class($params);
-
- if ($object instanceof self) {
- return $object;
- }
- }
-
- // default model for blocks
- if ($class = (static::$models['default'] ?? null)) {
- $object = new $class($params);
-
- if ($object instanceof self) {
- return $object;
- }
- }
-
- return new static($params);
+ return static::model($params['type'] ?? 'default', $params);
}
/**
diff --git a/public/kirby/src/Cms/BlockConverter.php b/public/kirby/src/Cms/BlockConverter.php
index 6065fe2..ced1b0f 100644
--- a/public/kirby/src/Cms/BlockConverter.php
+++ b/public/kirby/src/Cms/BlockConverter.php
@@ -51,7 +51,7 @@ class BlockConverter
public static function editorBlocks(array $blocks = []): array
{
- if (empty($blocks) === true) {
+ if ($blocks === []) {
return $blocks;
}
@@ -63,7 +63,7 @@ class BlockConverter
$listStart = null;
foreach ($blocks as $index => $block) {
- if (in_array($block['type'], ['ul', 'ol']) === true) {
+ if (in_array($block['type'], ['ul', 'ol'], true) === true) {
$prev = $blocks[$index - 1] ?? null;
$next = $blocks[$index + 1] ?? null;
@@ -132,12 +132,10 @@ class BlockConverter
public static function editorCustom(array $params): array
{
return [
- 'content' => array_merge(
- $params['attrs'] ?? [],
- [
- 'body' => $params['content'] ?? null
- ]
- ),
+ 'content' => [
+ ...$params['attrs'] ?? [],
+ 'body' => $params['content'] ?? null
+ ],
'type' => $params['type'] ?? 'unknown'
];
}
diff --git a/public/kirby/src/Cms/Blocks.php b/public/kirby/src/Cms/Blocks.php
index 5e0c0c9..1c817b2 100644
--- a/public/kirby/src/Cms/Blocks.php
+++ b/public/kirby/src/Cms/Blocks.php
@@ -20,6 +20,8 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
+ *
+ * @extends \Kirby\Cms\Items<\Kirby\Cms\Block>
*/
class Blocks extends Items
{
@@ -73,7 +75,7 @@ class Blocks extends Items
*/
protected static function extractFromLayouts(array $input): array
{
- if (empty($input) === true) {
+ if ($input === []) {
return [];
}
@@ -114,7 +116,10 @@ class Blocks extends Items
*/
public static function parse(array|string|null $input): array
{
- if (empty($input) === false && is_array($input) === false) {
+ if (
+ empty($input) === false &&
+ is_array($input) === false
+ ) {
try {
$input = Json::decode((string)$input);
} catch (Throwable) {
@@ -127,17 +132,17 @@ class Blocks extends Items
// check for valid yaml
if (
- empty($yaml) === true ||
+ $yaml === [] ||
(
isset($first['_key']) === false &&
isset($first['type']) === false
)
) {
- throw new Exception('Invalid YAML');
- } else {
- $input = $yaml;
+ throw new Exception(message: 'Invalid YAML');
}
- } catch (Throwable $e) {
+
+ $input = $yaml;
+ } catch (Throwable) {
// the next 2 lines remain after removing block.converter
// @codeCoverageIgnoreEnd
$parser = new Parsley((string)$input, new BlockSchema());
diff --git a/public/kirby/src/Cms/Blueprint.php b/public/kirby/src/Cms/Blueprint.php
index ff97590..6a62014 100644
--- a/public/kirby/src/Cms/Blueprint.php
+++ b/public/kirby/src/Cms/Blueprint.php
@@ -25,25 +25,17 @@ use Throwable;
*/
class Blueprint
{
- public static $presets = [];
- public static $loaded = [];
+ public static array $presets = [];
+ public static array $loaded = [];
- protected $fields = [];
- protected $model;
- protected $props;
- protected $sections = [];
- protected $tabs = [];
+ protected array $fields = [];
+ protected ModelWithContent $model;
+ protected array $props;
+ protected array $sections = [];
+ protected array $tabs = [];
protected array|null $fileTemplates = null;
- /**
- * Magic getter/caller for any blueprint prop
- */
- public function __call(string $key, array|null $arguments = null): mixed
- {
- return $this->props[$key] ?? null;
- }
-
/**
* Creates a new blueprint object with the given props
*
@@ -52,11 +44,15 @@ class Blueprint
public function __construct(array $props)
{
if (empty($props['model']) === true) {
- throw new InvalidArgumentException('A blueprint model is required');
+ throw new InvalidArgumentException(
+ message: 'A blueprint model is required'
+ );
}
if ($props['model'] instanceof ModelWithContent === false) {
- throw new InvalidArgumentException('Invalid blueprint model');
+ throw new InvalidArgumentException(
+ message: 'Invalid blueprint model'
+ );
}
$this->model = $props['model'];
@@ -88,6 +84,14 @@ class Blueprint
$this->props = $props;
}
+ /**
+ * Magic getter/caller for any blueprint prop
+ */
+ public function __call(string $key, array|null $arguments = null): mixed
+ {
+ return $this->props[$key] ?? null;
+ }
+
/**
* Improved `var_dump` output
*
@@ -185,7 +189,10 @@ class Blueprint
foreach ($fieldsets as $fieldset) {
foreach (($fieldset['tabs'] ?? []) as $tab) {
- $templates = array_merge($templates, $this->acceptedFileTemplatesFromFields($tab['fields'] ?? []));
+ $templates = [
+ ...$templates,
+ ...$this->acceptedFileTemplatesFromFields($tab['fields'] ?? [])
+ ];
}
}
@@ -207,6 +214,14 @@ class Blueprint
return [($uploads['template'] ?? 'default')];
}
+ /**
+ * Gathers custom config for Panel view buttons
+ */
+ public function buttons(): array|false|null
+ {
+ return $this->props['buttons'] ?? null;
+ }
+
/**
* Converts all column definitions, that
* are not wrapped in a tab, into a generic tab
@@ -393,10 +408,10 @@ class Blueprint
}
// neither a valid file nor array data
- throw new NotFoundException([
- 'key' => 'blueprint.notFound',
- 'data' => ['name' => $name]
- ]);
+ throw new NotFoundException(
+ key: 'blueprint.notFound',
+ data: ['name' => $name]
+ );
}
/**
@@ -512,14 +527,18 @@ class Blueprint
$props = static::extend($props);
if (isset($props['name']) === false) {
- throw new InvalidArgumentException('The field name is missing');
+ throw new InvalidArgumentException(
+ message: 'The field name is missing'
+ );
}
$name = $props['name'];
$type = $props['type'] ?? $name;
if ($type !== 'group' && isset(Field::$types[$type]) === false) {
- throw new InvalidArgumentException('Invalid field type ("' . $type . '")');
+ throw new InvalidArgumentException(
+ message: 'Invalid field type ("' . $type . '")'
+ );
}
// support for nested fields
@@ -714,7 +733,7 @@ class Blueprint
$fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []);
// inject guide fields guide
- if (empty($fields) === true) {
+ if ($fields === []) {
$fields = [
$tabName . '-info' => [
'label' => 'Fields',
diff --git a/public/kirby/src/Cms/Collection.php b/public/kirby/src/Cms/Collection.php
index 251b228..71d653e 100644
--- a/public/kirby/src/Cms/Collection.php
+++ b/public/kirby/src/Cms/Collection.php
@@ -22,26 +22,35 @@ use Kirby\Uuid\Uuid;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
+ *
+ * @template TValue
+ * @extends \Kirby\Toolkit\Collection
- *
+ * ```php
* $dirname = F::dirname('/var/www/test.txt');
* // dirname is /var/www
- *
- *
+ * ```
*
* @param string $file The path
*/
@@ -225,7 +230,7 @@ class F
public static function extensionToType(string $extension): string|false
{
foreach (static::$types as $type => $extensions) {
- if (in_array($extension, $extensions) === true) {
+ if (in_array($extension, $extensions, true) === true) {
return $type;
}
}
@@ -248,12 +253,10 @@ class F
/**
* Extracts the filename from a file path
*
- *
- *
+ * ```php
* $filename = F::filename('/var/www/test.txt');
* // filename is test.txt
- *
- *
+ * ```
*
* @param string $name The path
*/
@@ -288,12 +291,12 @@ class F
public static function is(string $file, string $value): bool
{
// check for the extension
- if (in_array($value, static::extensions()) === true) {
+ if (in_array($value, static::extensions(), true) === true) {
return static::extension($file) === $value;
}
// check for the mime type
- if (strpos($value, '/') !== false) {
+ if (str_contains($value, '/') === true) {
return static::mime($file) === $value;
}
@@ -323,8 +326,11 @@ class F
/**
* Create a (symbolic) link to a file
*/
- public static function link(string $source, string $link, string $method = 'link'): bool
- {
+ public static function link(
+ string $source,
+ string $link,
+ string $method = 'link'
+ ): bool {
Dir::make(dirname($link), true);
if (is_file($link) === true) {
@@ -366,11 +372,10 @@ class F
// if the loaded file should not produce any output,
// call the loaidIsolated method from the Response class
// which checks for unintended ouput and throws an error if detected
- if ($allowOutput === false) {
- $result = Response::guardAgainstOutput($callback);
- } else {
- $result = $callback();
- }
+ $result = match ($allowOutput) {
+ true => $callback(),
+ false => Response::guardAgainstOutput($callback),
+ };
if (
$fallback !== null &&
@@ -459,8 +464,9 @@ class F
/**
* Converts a mime type to a file extension
*/
- public static function mimeToExtension(string|null $mime = null): string|false
- {
+ public static function mimeToExtension(
+ string|null $mime = null
+ ): string|false {
return Mime::toExtension($mime);
}
@@ -499,8 +505,11 @@ class F
* @param string $newRoot The path to the new location
* @param bool $force Force move if the target file exists
*/
- public static function move(string $oldRoot, string $newRoot, bool $force = false): bool
- {
+ public static function move(
+ string $oldRoot,
+ string $newRoot,
+ bool $force = false
+ ): bool {
// check if the file exists
if (file_exists($oldRoot) === false) {
return false;
@@ -598,15 +607,23 @@ class F
*/
public static function read(string $file): string|false
{
- if (
- is_readable($file) !== true &&
- Str::startsWith($file, 'https://') !== true &&
- Str::startsWith($file, 'http://') !== true
- ) {
+ if (str_contains($file, '://') === true) {
return false;
}
- return file_get_contents($file);
+ // exit early on empty paths that would trigger a PHP `ValueError`
+ if ($file === '') {
+ return false;
+ }
+
+ // to increase performance, directly try to load the file without checking
+ // if it exists; fall back to a `false` return value if it doesn't exist
+ // while letting other warnings through
+ return Helpers::handleErrors(
+ fn (): string|false => file_get_contents($file),
+ fn (int $errno, string $errstr): bool => str_contains($errstr, 'No such file'),
+ false
+ );
}
/**
@@ -615,8 +632,11 @@ class F
*
* @param bool $overwrite Force overwrite existing files
*/
- public static function rename(string $file, string $newName, bool $overwrite = false): string|false
- {
+ public static function rename(
+ string $file,
+ string $newName,
+ bool $overwrite = false
+ ): string|false {
// create the new name
$name = static::safeName(basename($newName));
@@ -638,8 +658,10 @@ class F
/**
* Returns the absolute path to the file if the file can be found.
*/
- public static function realpath(string $file, string|null $in = null): string
- {
+ public static function realpath(
+ string $file,
+ string|null $in = null
+ ): string {
$realpath = realpath($file);
if ($realpath === false || is_file($realpath) === false) {
@@ -653,7 +675,7 @@ class F
throw new Exception(sprintf('The parent directory does not exist: "%s"', $in));
}
- if (substr($realpath, 0, strlen($parent)) !== $parent) {
+ if (str_starts_with($realpath, $parent) === false) {
throw new Exception('The file is not within the parent directory');
}
}
@@ -667,8 +689,10 @@ class F
*
* @SuppressWarnings(PHPMD.CountInLoopExpression)
*/
- public static function relativepath(string $file, string|null $in = null): string
- {
+ public static function relativepath(
+ string $file,
+ string|null $in = null
+ ): string {
if (empty($in) === true) {
return basename($file);
}
@@ -685,8 +709,13 @@ class F
// make the paths relative by stripping what they have
// in common and adding `../` tokens at the start
$fileParts = explode('/', $file);
- $inParts = explode('/', $in);
- while (count($fileParts) && count($inParts) && ($fileParts[0] === $inParts[0])) {
+ $inParts = explode('/', $in);
+
+ while (
+ count($fileParts) &&
+ count($inParts) &&
+ ($fileParts[0] === $inParts[0])
+ ) {
array_shift($fileParts);
array_shift($inParts);
}
@@ -700,18 +729,16 @@ class F
/**
* Deletes a file
*
- *
- *
+ * ```php
* $remove = F::remove('test.txt');
- * if($remove) echo 'The file has been removed';
- *
- *
+ * if ($remove) echo 'The file has been removed';
+ * ```
*
* @param string $file The path for the file
*/
public static function remove(string $file): bool
{
- if (strpos($file, '*') !== false) {
+ if (str_contains($file, '*') === true) {
foreach (glob($file) as $f) {
static::remove($f);
}
@@ -720,6 +747,7 @@ class F
}
$file = realpath($file);
+
if (is_string($file) === false) {
return true;
}
@@ -731,12 +759,10 @@ class F
* Sanitize a file's full name (filename and extension)
* to strip unwanted special characters
*
- *
- *
+ * ```php
* $safe = f::safeName('über genius.txt');
* // safe will be ueber-genius.txt
- *
- *
+ * ```
*
* @param string $string The file name
*/
@@ -826,17 +852,15 @@ class F
*/
public static function type(string $file): string|null
{
- $length = strlen($file);
-
- if ($length >= 2 && $length <= 4) {
+ $length = strlen($file);
+ $extension = match ($length >= 2 && $length <= 4) {
// use the file name as extension
- $extension = $file;
- } else {
+ true => $file,
// get the extension from the filename
- $extension = pathinfo($file, PATHINFO_EXTENSION);
- }
+ false => pathinfo($file, PATHINFO_EXTENSION)
+ };
- if (empty($extension) === true) {
+ if (empty($extension) === true || $extension === 'tmp') {
// detect the mime type first to get the most reliable extension
$mime = static::mime($file);
$extension = static::mimeToExtension($mime);
@@ -846,7 +870,7 @@ class F
$extension = strtolower($extension);
foreach (static::$types as $type => $extensions) {
- if (in_array($extension, $extensions) === true) {
+ if (in_array($extension, $extensions, true) === true) {
return $type;
}
}
@@ -919,8 +943,11 @@ class F
* @param mixed $content Either a string, an object or an array. Arrays and objects will be serialized.
* @param bool $append true: append the content to an existing file if available. false: overwrite.
*/
- public static function write(string $file, $content, bool $append = false): bool
- {
+ public static function write(
+ string $file,
+ $content,
+ bool $append = false
+ ): bool {
if (is_array($content) === true || is_object($content) === true) {
$content = serialize($content);
}
diff --git a/public/kirby/src/Filesystem/File.php b/public/kirby/src/Filesystem/File.php
index fc9beec..4c5de06 100644
--- a/public/kirby/src/Filesystem/File.php
+++ b/public/kirby/src/Filesystem/File.php
@@ -11,6 +11,7 @@ use Kirby\Sane\Sane;
use Kirby\Toolkit\Escape;
use Kirby\Toolkit\Html;
use Kirby\Toolkit\V;
+use Stringable;
/**
* Flexible File object with a set of helpful
@@ -24,7 +25,7 @@ use Kirby\Toolkit\V;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
-class File
+class File implements Stringable
{
/**
* Parent file model
@@ -79,7 +80,9 @@ class File
$this->model !== null &&
method_exists($this->model, 'hasIsFileTrait') !== true
) {
- throw new InvalidArgumentException('The model object must use the "Kirby\Filesystem\IsFile" trait');
+ throw new InvalidArgumentException(
+ message: 'The model object must use the "Kirby\Filesystem\IsFile" trait'
+ );
}
}
@@ -114,7 +117,9 @@ class File
public function copy(string $target, bool $force = false): static
{
if (F::copy($this->root(), $target, $force) !== true) {
- throw new Exception('The file "' . $this->root() . '" could not be copied');
+ throw new Exception(
+ message: 'The file "' . $this->root() . '" could not be copied'
+ );
}
return new static($target);
@@ -127,11 +132,10 @@ class File
*/
public function dataUri(bool $base64 = true): string
{
- if ($base64 === true) {
- return 'data:' . $this->mime() . ';base64,' . $this->base64();
- }
-
- return 'data:' . $this->mime() . ',' . Escape::url($this->read());
+ return match ($base64) {
+ true => 'data:' . $this->mime() . ';base64,' . $this->base64(),
+ false => 'data:' . $this->mime() . ',' . Escape::url($this->read())
+ };
}
/**
@@ -140,7 +144,9 @@ class File
public function delete(): bool
{
if (F::remove($this->root()) !== true) {
- throw new Exception('The file "' . $this->root() . '" could not be deleted');
+ throw new Exception(
+ message: 'The file "' . $this->root() . '" could not be deleted'
+ );
}
return true;
@@ -155,7 +161,10 @@ class File
*/
public function download(string|null $filename = null): string
{
- return Response::download($this->root(), $filename ?? $this->filename());
+ return Response::download(
+ $this->root(),
+ $filename ?? $this->filename()
+ );
}
/**
@@ -241,7 +250,7 @@ class File
/**
* Checks if a preview can be displayed for the file
- * in the panel or in the frontend
+ * in the Panel or in the frontend
*/
public function isViewable(): bool
{
@@ -280,10 +289,10 @@ class File
// the MIME type could not be determined, but matching
// to it was requested explicitly
if ($mime === null) {
- throw new Exception([
- 'key' => 'file.mime.missing',
- 'data' => ['filename' => $this->filename()]
- ]);
+ throw new Exception(
+ key: 'file.mime.missing',
+ data: ['filename' => $this->filename()]
+ );
}
// determine if any pattern matches the MIME type;
@@ -295,30 +304,30 @@ class File
);
if ($matches !== true) {
- throw new Exception([
- 'key' => 'file.mime.invalid',
- 'data' => compact('mime')
- ]);
+ throw new Exception(
+ key: 'file.mime.invalid',
+ data: compact('mime')
+ );
}
}
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 (in_array($extension, $rules['extension'], true) !== 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')
- ]);
+ if (in_array($type, $rules['type'], true) !== true) {
+ throw new Exception(
+ key: 'file.type.invalid',
+ data: compact('type')
+ );
}
}
@@ -330,10 +339,10 @@ class File
$validator = $arguments[1];
if (V::$validator($this->$property(), $rule) === false) {
- throw new Exception([
- 'key' => 'file.' . $key,
- 'data' => [$property => $rule]
- ]);
+ throw new Exception(
+ key: 'file.' . $key,
+ data: [$property => $rule]
+ );
}
}
}
@@ -378,7 +387,9 @@ class File
public function move(string $newRoot, bool $overwrite = false): static
{
if (F::move($this->root(), $newRoot, $overwrite) !== true) {
- throw new Exception('The file: "' . $this->root() . '" could not be moved to: "' . $newRoot . '"');
+ throw new Exception(
+ message: 'The file: "' . $this->root() . '" could not be moved to: "' . $newRoot . '"'
+ );
}
return new static($newRoot);
@@ -433,7 +444,9 @@ class File
$newRoot = F::rename($this->root(), $newName, $overwrite);
if ($newRoot === false) {
- throw new Exception('The file: "' . $this->root() . '" could not be renamed to: "' . $newName . '"');
+ throw new Exception(
+ message: 'The file: "' . $this->root() . '" could not be renamed to: "' . $newName . '"'
+ );
}
return new static($newRoot);
@@ -558,7 +571,9 @@ class File
public function write(string $content): bool
{
if (F::write($this->root(), $content) !== true) {
- throw new Exception('The file "' . $this->root() . '" could not be written');
+ throw new Exception(
+ message: 'The file "' . $this->root() . '" could not be written'
+ );
}
return true;
diff --git a/public/kirby/src/Filesystem/Filename.php b/public/kirby/src/Filesystem/Filename.php
index 247f06a..0001792 100644
--- a/public/kirby/src/Filesystem/Filename.php
+++ b/public/kirby/src/Filesystem/Filename.php
@@ -4,6 +4,7 @@ namespace Kirby\Filesystem;
use Kirby\Cms\Language;
use Kirby\Toolkit\Str;
+use Stringable;
/**
* The `Filename` class handles complex
@@ -27,7 +28,7 @@ use Kirby\Toolkit\Str;
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
-class Filename
+class Filename implements Stringable
{
/**
* The sanitized file extension
@@ -109,7 +110,10 @@ class Filename
$result[] = match ($key) {
'dimensions' => $value,
- 'crop' => ($value === 'center') ? 'crop' : $key . '-' . $value,
+ 'crop' => match ($value) {
+ 'center' => 'crop',
+ default => $key . '-' . $value
+ },
default => $key . $value
};
}
@@ -188,7 +192,11 @@ class Filename
public function grayscale(): bool
{
// normalize options
- $value = $this->attributes['grayscale'] ?? $this->attributes['greyscale'] ?? $this->attributes['bw'] ?? false;
+ $value =
+ $this->attributes['grayscale'] ??
+ $this->attributes['greyscale'] ??
+ $this->attributes['bw'] ??
+ false;
// turn anything into boolean
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
diff --git a/public/kirby/src/Filesystem/IsFile.php b/public/kirby/src/Filesystem/IsFile.php
index e5b1ab1..883f4f7 100644
--- a/public/kirby/src/Filesystem/IsFile.php
+++ b/public/kirby/src/Filesystem/IsFile.php
@@ -62,7 +62,9 @@ trait IsFile
return $this->asset()->$method(...$arguments);
}
- throw new BadMethodCallException('The method: "' . $method . '" does not exist');
+ throw new BadMethodCallException(
+ message: 'The method: "' . $method . '" does not exist'
+ );
}
/**
@@ -74,23 +76,27 @@ trait IsFile
}
/**
- * Returns the file asset object
+ * Returns the file asset object. A new object will be created if it doesn't
+ * exist yet. The instance will be cached to avoid multiple instantiations,
+ * when calling asset methods.
*/
public function asset(array|string|null $props = null): File
{
- if ($this->asset !== null) {
- return $this->asset;
- }
-
- $props ??= [];
+ return $this->asset ??= $this->assetFactory($props ?? []);
+ }
+ /**
+ * Creates a new asset object based on the file type
+ */
+ protected function assetFactory(array|string $props = []): File|Image
+ {
if (is_string($props) === true) {
$props = ['root' => $props];
}
$props['model'] ??= $this;
- return $this->asset = match ($this->type()) {
+ return match ($this->type()) {
'image' => new Image($props),
default => new File($props)
};
diff --git a/public/kirby/src/Filesystem/Mime.php b/public/kirby/src/Filesystem/Mime.php
index 2bb2a03..7ddc9ac 100644
--- a/public/kirby/src/Filesystem/Mime.php
+++ b/public/kirby/src/Filesystem/Mime.php
@@ -22,10 +22,8 @@ class Mime
{
/**
* Extension to MIME type map
- *
- * @var array
*/
- public static $types = [
+ public static array $types = [
'ai' => 'application/postscript',
'aif' => 'audio/x-aiff',
'aifc' => 'audio/x-aiff',
@@ -59,6 +57,7 @@ class Mime
'log' => ['text/plain', 'text/x-log'],
'm4a' => 'audio/mp4',
'm4v' => 'video/mp4',
+ 'md' => 'text/markdown',
'mid' => 'audio/midi',
'midi' => 'audio/midi',
'mif' => 'application/vnd.mif',
@@ -102,7 +101,7 @@ class Mime
'tiff' => 'image/tiff',
'wav' => 'audio/x-wav',
'wbxml' => 'application/wbxml',
- 'webm' => 'video/webm',
+ 'webm' => ['video/webm', 'audio/webm'],
'webp' => 'image/webp',
'word' => ['application/msword', 'application/octet-stream'],
'xhtml' => 'application/xhtml+xml',
@@ -129,13 +128,13 @@ class Mime
// fixing map
$map = [
'text/html' => [
- 'svg' => [Mime::class, 'fromSvg'],
+ 'svg' => Mime::fromSvg(...),
],
'text/plain' => [
'css' => 'text/css',
'json' => 'application/json',
'mjs' => 'text/javascript',
- 'svg' => [Mime::class, 'fromSvg'],
+ 'svg' => Mime::fromSvg(...),
],
'text/x-asm' => [
'css' => 'text/css'
@@ -151,7 +150,7 @@ class Mime
]
];
- if ($mode = ($map[$mime][$extension] ?? null)) {
+ if ($mode = $map[$mime][$extension] ?? null) {
if (is_callable($mode) === true) {
return $mode($file, $mime, $extension);
}
@@ -170,7 +169,12 @@ class Mime
public static function fromExtension(string $extension): string|null
{
$mime = static::$types[$extension] ?? null;
- return is_array($mime) === true ? array_shift($mime) : $mime;
+
+ if (is_array($mime) === true) {
+ $mime = array_shift($mime);
+ }
+
+ return $mime;
}
/**
@@ -178,7 +182,10 @@ class Mime
*/
public static function fromFileInfo(string $file): string|false
{
- if (function_exists('finfo_file') === true && file_exists($file) === true) {
+ if (
+ function_exists('finfo_file') === true &&
+ file_exists($file) === true
+ ) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file);
finfo_close($finfo);
@@ -213,7 +220,10 @@ class Mime
$svg = new SimpleXMLElement(file_get_contents($file));
- if ($svg !== false && $svg->getName() === 'svg') {
+ if (
+ $svg !== false &&
+ $svg->getName() === 'svg'
+ ) {
return 'image/svg+xml';
}
}
@@ -254,7 +264,10 @@ class Mime
public static function toExtension(string|null $mime = null): string|false
{
foreach (static::$types as $key => $value) {
- if (is_array($value) === true && in_array($mime, $value) === true) {
+ if (
+ is_array($value) === true &&
+ in_array($mime, $value, true) === true
+ ) {
return $key;
}
@@ -269,36 +282,35 @@ class Mime
/**
* Returns all available extensions for a given MIME type
*/
- public static function toExtensions(string|null $mime = null, bool $matchWildcards = false): array
- {
- $extensions = [];
- $testMime = fn (string $v) => static::matches($v, $mime);
+ public static function toExtensions(
+ string|null $mime = null,
+ bool $matchWildcards = false
+ ): array {
+ // get all extensions
+ $extensions = array_keys(static::$types);
+
+ // filter extensions for given MIME type
+ $extensions = A::filter(
+ $extensions,
+ function ($extension) use ($mime, $matchWildcards) {
+ // get corresponding MIME types as array
+ $mimes = A::wrap(static::$types[$extension]);
- foreach (static::$types as $key => $value) {
- if (is_array($value) === true) {
if ($matchWildcards === true) {
- if (A::some($value, $testMime)) {
- $extensions[] = $key;
- }
- } else {
- if (in_array($mime, $value) === true) {
- $extensions[] = $key;
- }
- }
- } else {
- if ($matchWildcards === true) {
- if ($testMime($value) === true) {
- $extensions[] = $key;
- }
- } else {
- if ($value === $mime) {
- $extensions[] = $key;
- }
+ // check if at least one MIME type with wildcards matches
+ return A::some(
+ $mimes,
+ fn (string $v): bool => static::matches($v, $mime)
+ );
}
+
+ // check if at least one MIME type matches exactly
+ return in_array($mime, $mimes, true);
}
- }
+ );
- return $extensions;
+ // renumber array with consecutive keys
+ return array_values($extensions);
}
/**
diff --git a/public/kirby/src/Form/Field.php b/public/kirby/src/Form/Field.php
index 114a826..a0b74ca 100644
--- a/public/kirby/src/Form/Field.php
+++ b/public/kirby/src/Form/Field.php
@@ -3,13 +3,10 @@
namespace Kirby\Form;
use Closure;
-use Exception;
-use Kirby\Cms\App;
+use Kirby\Cms\HasSiblings;
use Kirby\Exception\InvalidArgumentException;
-use Kirby\Toolkit\A;
use Kirby\Toolkit\Component;
use Kirby\Toolkit\I18n;
-use Kirby\Toolkit\V;
/**
* Form Field object that takes a Vue component style
@@ -21,18 +18,25 @@ use Kirby\Toolkit\V;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
+ *
+ * @use \Kirby\Cms\HasSiblings<\Kirby\Form\Fields>
*/
class Field extends Component
{
- /**
- * An array of all found errors
- */
- protected array|null $errors = null;
+ use HasSiblings;
+ use Mixin\Api;
+ use Mixin\Model;
+ use Mixin\Translatable;
+ use Mixin\Validation;
+ use Mixin\When;
+ use Mixin\Value {
+ isEmptyValue as protected isEmptyValueFromMixin;
+ }
/**
* Parent collection with all fields of the current form
*/
- protected Fields|null $formFields;
+ protected Fields $siblings;
/**
* Registry for all component mixins
@@ -50,65 +54,31 @@ class Field extends Component
public function __construct(
string $type,
array $attrs = [],
- Fields|null $formFields = null
+ Fields|null $siblings = null
) {
if (isset(static::$types[$type]) === false) {
- throw new InvalidArgumentException([
- 'key' => 'field.type.missing',
- 'data' => ['name' => $attrs['name'] ?? '-', 'type' => $type]
- ]);
+ throw new InvalidArgumentException(
+ key: 'field.type.missing',
+ data: [
+ 'name' => $attrs['name'] ?? '-',
+ 'type' => $type
+ ]
+ );
}
- if (isset($attrs['model']) === false) {
- throw new InvalidArgumentException('Field requires a model');
- }
-
- $this->formFields = $formFields;
-
// use the type as fallback for the name
$attrs['name'] ??= $type;
$attrs['type'] = $type;
+ // set the name to lowercase
+ $attrs['name'] = strtolower($attrs['name']);
+
+ $this->setModel($attrs['model'] ?? null);
+
parent::__construct($type, $attrs);
- }
- /**
- * Returns field api call
- */
- public function api(): mixed
- {
- if (
- isset($this->options['api']) === true &&
- $this->options['api'] instanceof Closure
- ) {
- return $this->options['api']->call($this);
- }
-
- return null;
- }
-
- /**
- * Returns field data
- */
- public function data(bool $default = false): mixed
- {
- $save = $this->options['save'] ?? true;
-
- if ($default === true && $this->isEmpty($this->value)) {
- $value = $this->default();
- } else {
- $value = $this->value;
- }
-
- if ($save === false) {
- return null;
- }
-
- if ($save instanceof Closure) {
- return $save->call($this, $value);
- }
-
- return $value;
+ // set the siblings collection
+ $this->siblings = $siblings ?? new Fields([$this]);
}
/**
@@ -191,7 +161,7 @@ class Field extends Component
return $when;
},
/**
- * The width of the field in the field grid. Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4`
+ * The width of the field in the field grid, e.g. `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4`
*/
'width' => function (string $width = '1/1') {
return $width;
@@ -285,53 +255,84 @@ class Field extends Component
public static function factory(
string $type,
array $attrs = [],
- Fields|null $formFields = null
+ Fields|null $siblings = null
): static|FieldClass {
$field = static::$types[$type] ?? null;
if (is_string($field) && class_exists($field) === true) {
- $attrs['siblings'] = $formFields;
+ $attrs['siblings'] = $siblings;
return new $field($attrs);
}
- return new static($type, $attrs, $formFields);
+ return new static($type, $attrs, $siblings);
}
/**
- * Parent collection with all fields of the current form
+ * Sets a new value for the field
*/
- public function formFields(): Fields|null
+ public function fill(mixed $value): static
{
- return $this->formFields;
+ // remember the current state to restore it afterwards
+ $attrs = $this->attrs;
+ $methods = $this->methods;
+ $options = $this->options;
+ $type = $this->type;
+
+ // overwrite the attribute value
+ $this->value = $this->attrs['value'] = $value;
+
+ // reevaluate the value prop
+ $this->applyProp('value', $this->options['props']['value'] ?? $value);
+
+ // reevaluate the computed props
+ $this->applyComputed($this->options['computed'] ?? []);
+
+ // restore the original state
+ $this->attrs = $attrs;
+ $this->methods = $methods;
+ $this->options = $options;
+ $this->type = $type;
+
+ return $this;
}
/**
- * Validates when run for the first time and returns any errors
+ * @deprecated 5.0.0 Use `::siblings() instead
*/
- public function errors(): array
+ public function formFields(): Fields
{
- if ($this->errors === null) {
- $this->validate();
+ return $this->siblings;
+ }
+
+ /**
+ * Checks if the field has a value
+ */
+ public function hasValue(): bool
+ {
+ return ($this->options['save'] ?? true) !== false;
+ }
+
+ /**
+ * Checks if the field is disabled
+ */
+ public function isDisabled(): bool
+ {
+ return $this->disabled === true;
+ }
+
+ /**
+ * Checks if the given value is considered empty
+ */
+ public function isEmptyValue(mixed $value = null): bool
+ {
+ if (
+ isset($this->options['isEmpty']) === true &&
+ $this->options['isEmpty'] instanceof Closure
+ ) {
+ return $this->options['isEmpty']->call($this, $value);
}
- return $this->errors;
- }
-
- /**
- * Checks if the field is empty
- */
- public function isEmpty(mixed ...$args): bool
- {
- $value = match (count($args)) {
- 0 => $this->value(),
- default => $args[0]
- };
-
- if ($empty = $this->options['isEmpty'] ?? null) {
- return $empty->call($this, $value);
- }
-
- return in_array($value, [null, '', []], true);
+ return $this->isEmptyValueFromMixin($value);
}
/**
@@ -343,93 +344,34 @@ class Field extends Component
}
/**
- * Checks if the field is invalid
+ * Returns field api routes
*/
- public function isInvalid(): bool
+ public function routes(): array
{
- return empty($this->errors()) === false;
- }
-
- /**
- * Checks if the field is required
- */
- public function isRequired(): bool
- {
- return $this->required ?? false;
- }
-
- /**
- * Checks if the field is valid
- */
- public function isValid(): bool
- {
- return empty($this->errors()) === true;
- }
-
- /**
- * Returns the Kirby instance
- */
- public function kirby(): App
- {
- return $this->model()->kirby();
- }
-
- /**
- * Returns the parent model
- */
- public function model(): mixed
- {
- return $this->model;
- }
-
- /**
- * 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
- */
- protected function needsValue(): bool
- {
- // check simple conditions first
if (
- $this->save() === false ||
- $this->isRequired() === false ||
- $this->isEmpty() === false
+ isset($this->options['api']) === true &&
+ $this->options['api'] instanceof Closure
) {
- return false;
+ return $this->options['api']->call($this);
}
- // 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->formFields()
- ) {
- foreach ($this->when as $field => $value) {
- $field = $formFields->get($field);
- $inputValue = $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;
+ return [];
}
/**
- * Checks if the field is saveable
+ * Parent collection with all fields of the current form
*/
- public function save(): bool
+ public function siblings(): Fields
{
- return ($this->options['save'] ?? true) !== false;
+ return $this->siblings;
+ }
+
+ /**
+ * Returns all sibling fields for the HasSiblings trait
+ */
+ protected function siblingsCollection(): Fields
+ {
+ return $this->siblings;
}
/**
@@ -441,9 +383,8 @@ class Field extends Component
unset($array['model']);
- $array['hidden'] = $this->isHidden();
- $array['saveable'] = $this->save();
- $array['signature'] = md5(json_encode($array));
+ $array['hidden'] = $this->isHidden();
+ $array['saveable'] = $this->hasValue();
ksort($array);
@@ -454,57 +395,29 @@ class Field extends Component
}
/**
- * Runs the validations defined for the field
+ * Returns the value of the field in a format to be stored by our storage classes
*/
- protected function validate(): void
+ public function toStoredValue(): mixed
{
- $validations = $this->options['validations'] ?? [];
- $this->errors = [];
+ $value = $this->toFormValue();
+ $store = $this->options['save'] ?? true;
- // validate required values
- if ($this->needsValue() === true) {
- $this->errors['required'] = I18n::translate('error.validation.required');
+ if ($store === false) {
+ return null;
}
- foreach ($validations as $key => $validation) {
- if (is_int($key) === true) {
- // predefined validation
- try {
- Validations::$validation($this, $this->value());
- } catch (Exception $e) {
- $this->errors[$validation] = $e->getMessage();
- }
- continue;
- }
-
- if ($validation instanceof Closure) {
- try {
- $validation->call($this, $this->value());
- } catch (Exception $e) {
- $this->errors[$key] = $e->getMessage();
- }
- }
+ if ($store instanceof Closure) {
+ return $store->call($this, $value);
}
- if (
- empty($this->validate) === false &&
- ($this->isEmpty() === false || $this->isRequired() === true)
- ) {
- $rules = A::wrap($this->validate);
- $errors = V::errors($this->value(), $rules);
-
- if (empty($errors) === false) {
- $this->errors = array_merge($this->errors, $errors);
- }
- }
+ return $value;
}
/**
- * Returns the value of the field if saveable
- * otherwise it returns null
+ * Defines all validation rules
*/
- public function value(): mixed
+ protected function validations(): array
{
- return $this->save() ? $this->value : null;
+ return $this->options['validations'] ?? [];
}
}
diff --git a/public/kirby/src/Form/Field/BlocksField.php b/public/kirby/src/Form/Field/BlocksField.php
index 2d66a6b..56b5739 100644
--- a/public/kirby/src/Form/Field/BlocksField.php
+++ b/public/kirby/src/Form/Field/BlocksField.php
@@ -8,6 +8,7 @@ use Kirby\Cms\Blocks as BlocksCollection;
use Kirby\Cms\Fieldset;
use Kirby\Cms\Fieldsets;
use Kirby\Cms\ModelWithContent;
+use Kirby\Data\Json;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\FieldClass;
@@ -47,10 +48,11 @@ class BlocksField extends FieldClass
public function blocksToValues(
array $blocks,
- string $to = 'values'
+ string $to = 'toFormValues'
): array {
$result = [];
$fields = [];
+ $forms = [];
foreach ($blocks as $block) {
try {
@@ -58,12 +60,10 @@ class BlocksField extends FieldClass
// get and cache fields at the same time
$fields[$type] ??= $this->fields($block['type']);
+ $forms[$type] ??= $this->form($fields[$type]);
// overwrite the block content with form values
- $block['content'] = $this->form(
- $fields[$type],
- $block['content']
- )->$to();
+ $block['content'] = $forms[$type]->reset()->fill(input: $block['content'])->$to();
// create id if not exists
$block['id'] ??= Str::uuid();
@@ -101,24 +101,29 @@ class BlocksField extends FieldClass
public function fieldsetGroups(): array|null
{
$groups = $this->fieldsets()->groups();
- return empty($groups) === true ? null : $groups;
+ return $groups === [] ? null : $groups;
}
- public function fill(mixed $value = null): void
+ /**
+ * @psalm-suppress MethodSignatureMismatch
+ * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
+ */
+ public function fill(mixed $value): static
{
$value = BlocksCollection::parse($value);
$blocks = BlocksCollection::factory($value)->toArray();
$this->value = $this->blocksToValues($blocks);
+
+ return $this;
}
- public function form(array $fields, array $input = []): Form
+ public function form(array $fields): Form
{
- return new Form([
- 'fields' => $fields,
- 'model' => $this->model,
- 'strict' => true,
- 'values' => $input,
- ]);
+ return new Form(
+ fields: $fields,
+ model: $this->model,
+ language: 'current'
+ );
}
public function isEmpty(): bool
@@ -198,12 +203,13 @@ class BlocksField extends FieldClass
'action' => function (
string $fieldsetType
) use ($field): array {
- $fields = $field->fields($fieldsetType);
- $defaults = $field->form($fields, [])->data(true);
- $content = $field->form($fields, $defaults)->values();
+ $fields = $field->fields($fieldsetType);
+ $form = $field->form($fields);
+
+ $form->fill(input: $form->defaults());
return Block::factory([
- 'content' => $content,
+ 'content' => $form->toFormValues(),
'type' => $fieldsetType
])->toArray();
}
@@ -221,10 +227,10 @@ class BlocksField extends FieldClass
$fieldApi = $this->clone([
'routes' => $field->api(),
- 'data' => array_merge(
- $this->data(),
- ['field' => $field]
- )
+ 'data' => [
+ ...$this->data(),
+ 'field' => $field
+ ]
]);
return $fieldApi->call(
@@ -237,19 +243,6 @@ class BlocksField extends FieldClass
];
}
- public function store(mixed $value): mixed
- {
- $blocks = $this->blocksToValues((array)$value, 'content');
-
- // returns empty string to avoid storing empty array as string `[]`
- // and to consistency work with `$field->isEmpty()`
- if (empty($blocks) === true) {
- return '';
- }
-
- return $this->valueToJson($blocks, $this->pretty());
- }
-
protected function setDefault(mixed $default = null): void
{
// set id for blocks if not exists
@@ -286,61 +279,80 @@ class BlocksField extends FieldClass
$this->pretty = $pretty;
}
+ public function toStoredValue(bool $default = false): mixed
+ {
+ $value = $this->toFormValue($default);
+ $blocks = $this->blocksToValues((array)$value, 'toStoredValues');
+
+ // returns empty string to avoid storing empty array as string `[]`
+ // and to consistency work with `$field->isEmpty()`
+ if ($blocks === []) {
+ return '';
+ }
+
+ return Json::encode($blocks, pretty: $this->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
- ]
- ]);
+ throw new InvalidArgumentException(
+ key: match ($this->min) {
+ 1 => 'blocks.min.singular',
+ default => 'blocks.min.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
- ]
- ]);
+ throw new InvalidArgumentException(
+ key: match ($this->max) {
+ 1 => 'blocks.max.singular',
+ default => 'blocks.max.plural'
+ },
+ data: ['max' => $this->max]
+ );
}
- $fields = [];
+ $forms = [];
$index = 0;
foreach ($value as $block) {
$index++;
$type = $block['type'];
- try {
- $fieldset = $this->fieldset($type);
- $blockFields = $fields[$type] ?? $fieldset->fields() ?? [];
- } catch (Throwable) {
- // skip invalid blocks
- continue;
+ // create the form for the block
+ // and cache it for later use
+ if (isset($forms[$type]) === false) {
+ try {
+ $fieldset = $this->fieldset($type);
+ $fields = $fieldset->fields() ?? [];
+ $forms[$type] = $this->form($fields);
+ } catch (Throwable) {
+ // skip invalid blocks
+ continue;
+ }
}
- // store the fields for the next round
- $fields[$type] = $blockFields;
-
// overwrite the content with the serialized form
- $form = $this->form($blockFields, $block['content']);
+ $form = $forms[$type]->reset()->fill($block['content']);
+
foreach ($form->fields() as $field) {
$errors = $field->errors();
// rough first validation
- if (empty($errors) === false) {
- throw new InvalidArgumentException([
- 'key' => 'blocks.validation',
- 'data' => [
+ if (count($errors) > 0) {
+ throw new InvalidArgumentException(
+ key:'blocks.validation',
+ data: [
'field' => $field->label(),
'fieldset' => $fieldset->name(),
'index' => $index
]
- ]);
+ );
}
}
}
diff --git a/public/kirby/src/Form/Field/EntriesField.php b/public/kirby/src/Form/Field/EntriesField.php
new file mode 100644
index 0000000..c17d7e8
--- /dev/null
+++ b/public/kirby/src/Form/Field/EntriesField.php
@@ -0,0 +1,211 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://getkirby.com/license
+ * @since 5.0.0
+ */
+class EntriesField extends FieldClass
+{
+ use EmptyState;
+ use Max;
+ use Min;
+
+ protected array $field;
+ protected Form $form;
+ protected bool $sortable = true;
+
+ public function __construct(array $params = [])
+ {
+ parent::__construct($params);
+
+ $this->setEmpty($params['empty'] ?? null);
+ $this->setField($params['field'] ?? null);
+ $this->setMax($params['max'] ?? null);
+ $this->setMin($params['min'] ?? null);
+ $this->setSortable($params['sortable'] ?? true);
+ }
+
+ public function field(): array
+ {
+ return $this->field;
+ }
+
+ public function fieldProps(): array
+ {
+ return $this->form()->fields()->first()->toArray();
+ }
+
+ /**
+ * @psalm-suppress MethodSignatureMismatch
+ * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
+ */
+ public function fill(mixed $value): static
+ {
+ $this->value = Data::decode($value ?? '', 'yaml');
+ return $this;
+ }
+
+ public function form(): Form
+ {
+ return $this->form ??= new Form(
+ fields: [$this->field()],
+ model: $this->model
+ );
+ }
+
+ public function props(): array
+ {
+ return [
+ ...parent::props(),
+ 'empty' => $this->empty(),
+ 'field' => $this->fieldProps(),
+ 'max' => $this->max(),
+ 'min' => $this->min(),
+ 'sortable' => $this->sortable(),
+ ];
+ }
+
+ protected function setField(array|string|null $attrs = null): void
+ {
+ if (is_string($attrs) === true) {
+ $attrs = ['type' => $attrs];
+ }
+
+ $attrs ??= ['type' => 'text'];
+
+ if (in_array($attrs['type'], $this->supports()) === false) {
+ throw new InvalidArgumentException(
+ key: 'entries.supports',
+ data: ['type' => $attrs['type']]
+ );
+ }
+
+ // remove the unsupported props from the entry field
+ unset($attrs['counter'], $attrs['label']);
+
+ $this->field = $attrs;
+ }
+
+ protected function setSortable(bool|null $sortable = true): void
+ {
+ $this->sortable = $sortable;
+ }
+
+ public function sortable(): bool
+ {
+ return $this->sortable;
+ }
+
+ public function supports(): array
+ {
+ return [
+ 'color',
+ 'date',
+ 'email',
+ 'number',
+ 'select',
+ 'slug',
+ 'tel',
+ 'text',
+ 'time',
+ 'url'
+ ];
+ }
+
+ public function toFormValue(): mixed
+ {
+ $form = $this->form();
+ $value = parent::toFormValue() ?? [];
+
+ return A::map(
+ $value,
+ fn ($value) => $form
+ ->reset()
+ ->fill(input: [$value])
+ ->fields()
+ ->first()
+ ->toFormValue()
+ );
+ }
+
+ public function toStoredValue(): mixed
+ {
+ $form = $this->form();
+ $value = parent::toStoredValue();
+
+ return A::map(
+ $value,
+ fn ($value) => $form
+ ->reset()
+ ->submit(input: [$value])
+ ->fields()
+ ->first()
+ ->toStoredValue()
+ );
+ }
+
+ public function validations(): array
+ {
+ return [
+ 'entries' => function ($value) {
+ if ($this->min && count($value) < $this->min) {
+ throw new InvalidArgumentException(
+ key: match ($this->min) {
+ 1 => 'entries.min.singular',
+ default => 'entries.min.plural'
+ },
+ data: ['min' => $this->min]
+ );
+ }
+
+ if ($this->max && count($value) > $this->max) {
+ throw new InvalidArgumentException(
+ key: match ($this->max) {
+ 1 => 'entries.max.singular',
+ default => 'entries.max.plural'
+ },
+ data: ['max' => $this->max]
+ );
+ }
+
+ $form = $this->form();
+
+ foreach ($value as $index => $val) {
+ $form->reset()->submit(input: [$val]);
+
+ foreach ($form->fields() as $field) {
+ $errors = $field->errors();
+
+ if ($errors !== []) {
+ throw new InvalidArgumentException(
+ key: 'entries.validation',
+ data: [
+ 'field' => $this->label() ?? Str::ucfirst($this->name()),
+ 'index' => $index + 1
+ ]
+ );
+ }
+ }
+ }
+ }
+ ];
+ }
+}
diff --git a/public/kirby/src/Form/Field/LayoutField.php b/public/kirby/src/Form/Field/LayoutField.php
index d92c352..e920788 100644
--- a/public/kirby/src/Form/Field/LayoutField.php
+++ b/public/kirby/src/Form/Field/LayoutField.php
@@ -7,6 +7,8 @@ use Kirby\Cms\Blueprint;
use Kirby\Cms\Fieldset;
use Kirby\Cms\Layout;
use Kirby\Cms\Layouts;
+use Kirby\Data\Data;
+use Kirby\Data\Json;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\Form;
use Kirby\Toolkit\Str;
@@ -28,14 +30,19 @@ class LayoutField extends BlocksField
parent::__construct($params);
}
- public function fill(mixed $value = null): void
+ /**
+ * @psalm-suppress MethodSignatureMismatch
+ * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
+ */
+ public function fill(mixed $value): static
{
- $value = $this->valueFromJson($value);
+ $attrs = $this->attrsForm();
+ $value = Data::decode($value, type: 'json', fail: false);
$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();
+ $layouts[$layoutIndex]['attrs'] = $attrs->reset()->fill($layout['attrs'])->toFormValues();
}
foreach ($layout['columns'] as $columnIndex => $column) {
@@ -44,18 +51,16 @@ class LayoutField extends BlocksField
}
$this->value = $layouts;
+
+ return $this;
}
- public function attrsForm(array $input = []): Form
+ public function attrsForm(): Form
{
- $settings = $this->settings();
-
- return new Form([
- 'fields' => $settings?->fields() ?? [],
- 'model' => $this->model,
- 'strict' => true,
- 'values' => $input,
- ]);
+ return new Form(
+ fields: $this->settings()?->fields() ?? [],
+ model: $this->model
+ );
}
public function layouts(): array|null
@@ -96,7 +101,7 @@ class LayoutField extends BlocksField
// remove the row if layout not available for the pasted layout field
$columns = array_column($layout['columns'], 'width');
- if (in_array($columns, $this->layouts()) === false) {
+ if (in_array($columns, $this->layouts(), true) === false) {
unset($layouts[$layoutIndex]);
continue;
}
@@ -122,13 +127,12 @@ class LayoutField extends BlocksField
public function props(): array
{
- $settings = $this->settings();
-
- return array_merge(parent::props(), [
+ return [
+ ...parent::props(),
'layouts' => $this->layouts(),
'selector' => $this->selector(),
- 'settings' => $settings?->toArray()
- ]);
+ 'settings' => $this->settings()?->toArray()
+ ];
}
public function routes(): array
@@ -142,13 +146,14 @@ class LayoutField extends BlocksField
'action' => function () use ($field): array {
$request = App::instance()->request();
- $input = $request->get('attrs') ?? [];
- $defaults = $field->attrsForm($input)->data(true);
- $attrs = $field->attrsForm($defaults)->values();
- $columns = $request->get('columns') ?? ['1/1'];
+ $columns = $request->get('columns') ?? ['1/1'];
+ $form = $field->attrsForm();
+
+ $form->fill(input: $form->defaults());
+ $form->submit(input: $request->get('attrs') ?? []);
return Layout::factory([
- 'attrs' => $attrs,
+ 'attrs' => $form->toFormValues(),
'columns' => array_map(fn ($width) => [
'blocks' => [],
'id' => Str::uuid(),
@@ -182,10 +187,10 @@ class LayoutField extends BlocksField
$fieldApi = $this->clone([
'routes' => $field->api(),
- 'data' => array_merge(
- $this->data(),
- ['field' => $field]
- )
+ 'data' => [
+ ...$this->data(),
+ 'field' => $field
+ ]
]);
return $fieldApi->call(
@@ -267,19 +272,21 @@ class LayoutField extends BlocksField
return $this->settings;
}
- public function store(mixed $value): mixed
+ public function toStoredValue(bool $default = false): mixed
{
+ $attrs = $this->attrsForm();
+ $value = $this->toFormValue($default);
$value = Layouts::factory($value, ['parent' => $this->model])->toArray();
// returns empty string to avoid storing empty array as string `[]`
// and to consistency work with `$field->isEmpty()`
- if (empty($value) === true) {
+ if ($value === []) {
return '';
}
foreach ($value as $layoutIndex => $layout) {
if ($this->settings !== null) {
- $value[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->content();
+ $value[$layoutIndex]['attrs'] = $attrs->reset()->fill($layout['attrs'])->toStoredValues();
}
foreach ($layout['columns'] as $columnIndex => $column) {
@@ -287,32 +294,31 @@ class LayoutField extends BlocksField
}
}
- return $this->valueToJson($value, $this->pretty());
+ return Json::encode($value, pretty: $this->pretty());
}
public function validations(): array
{
return [
'layout' => function ($value) {
- $fields = [];
+ $attrsForm = $this->attrsForm();
+ $blockForms = [];
$layoutIndex = 0;
foreach ($value as $layout) {
$layoutIndex++;
// validate settings form
- $form = $this->attrsForm($layout['attrs'] ?? []);
+ $form = $attrsForm->reset()->fill($layout['attrs'] ?? []);
foreach ($form->fields() as $field) {
$errors = $field->errors();
- if (empty($errors) === false) {
- throw new InvalidArgumentException([
- 'key' => 'layout.validation.settings',
- 'data' => [
- 'index' => $layoutIndex
- ]
- ]);
+ if (count($errors) > 0) {
+ throw new InvalidArgumentException(
+ key:'layout.validation.settings',
+ data: ['index' => $layoutIndex]
+ );
}
}
@@ -324,34 +330,34 @@ class LayoutField extends BlocksField
$blockIndex++;
$blockType = $block['type'];
- try {
- $fieldset = $this->fieldset($blockType);
- $blockFields = $fields[$blockType] ?? $this->fields($blockType) ?? [];
- } catch (Throwable) {
- // skip invalid blocks
- continue;
+ if (isset($blockForms[$blockType]) === false) {
+ try {
+ $fieldset = $this->fieldset($blockType);
+ $fields = $this->fields($blockType) ?? [];
+ $blockForms[$blockType] = $this->form($fields);
+ } catch (Throwable) {
+ // skip invalid blocks
+ continue;
+ }
}
- // store the fields for the next round
- $fields[$blockType] = $blockFields;
-
// overwrite the content with the serialized form
- $form = $this->form($blockFields, $block['content']);
+ $form = $blockForms[$blockType]->reset()->fill($block['content']);
foreach ($form->fields() as $field) {
$errors = $field->errors();
// rough first validation
- if (empty($errors) === false) {
- throw new InvalidArgumentException([
- 'key' => 'layout.validation.block',
- 'data' => [
+ if (count($errors) > 0) {
+ throw new InvalidArgumentException(
+ key: 'layout.validation.block',
+ data: [
'blockIndex' => $blockIndex,
'field' => $field->label(),
'fieldset' => $fieldset->name(),
'layoutIndex' => $layoutIndex
]
- ]);
+ );
}
}
}
diff --git a/public/kirby/src/Form/FieldClass.php b/public/kirby/src/Form/FieldClass.php
index 613122c..1c5d2ef 100644
--- a/public/kirby/src/Form/FieldClass.php
+++ b/public/kirby/src/Form/FieldClass.php
@@ -2,15 +2,9 @@
namespace Kirby\Form;
-use Closure;
-use Exception;
-use Kirby\Cms\App;
use Kirby\Cms\HasSiblings;
-use Kirby\Cms\ModelWithContent;
-use Kirby\Data\Data;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
-use Throwable;
/**
* Abstract field class to be used instead
@@ -22,27 +16,29 @@ use Throwable;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
+ *
+ * @use \Kirby\Cms\HasSiblings<\Kirby\Form\Fields>
*/
abstract class FieldClass
{
use HasSiblings;
+ use Mixin\Api;
+ use Mixin\Model;
+ use Mixin\Translatable;
+ use Mixin\Validation;
+ use Mixin\Value;
+ use Mixin\When;
protected string|null $after;
protected bool $autofocus;
protected string|null $before;
- protected mixed $default;
protected bool $disabled;
protected string|null $help;
protected string|null $icon;
protected string|null $label;
- protected ModelWithContent $model;
protected string|null $name;
protected string|null $placeholder;
- protected bool $required;
protected Fields $siblings;
- protected bool $translate;
- protected mixed $value = null;
- protected array|null $when;
protected string|null $width;
public function __construct(
@@ -56,7 +52,7 @@ abstract class FieldClass
$this->setHelp($params['help'] ?? null);
$this->setIcon($params['icon'] ?? null);
$this->setLabel($params['label'] ?? null);
- $this->setModel($params['model'] ?? App::instance()->site());
+ $this->setModel($params['model'] ?? null);
$this->setName($params['name'] ?? null);
$this->setPlaceholder($params['placeholder'] ?? null);
$this->setRequired($params['required'] ?? false);
@@ -84,11 +80,6 @@ abstract class FieldClass
return $this->stringTemplate($this->after);
}
- public function api(): array
- {
- return $this->routes();
- }
-
public function autofocus(): bool
{
return $this->autofocus;
@@ -99,32 +90,6 @@ abstract class FieldClass
return $this->stringTemplate($this->before);
}
- /**
- * @deprecated 3.5.0
- * @todo remove when the general field class setup has been refactored
- *
- * Returns the field data
- * in a format to be stored
- * in Kirby's content fields
- */
- public function data(bool $default = false): mixed
- {
- 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(): mixed
- {
- if (is_string($this->default) === false) {
- return $this->default;
- }
-
- return $this->stringTemplate($this->default);
- }
-
/**
* Returns optional dialog routes for the field
*/
@@ -149,23 +114,6 @@ abstract class FieldClass
return [];
}
- /**
- * Runs all validations and returns an array of
- * error messages
- */
- public function errors(): array
- {
- return $this->validate();
- }
-
- /**
- * Setter for the value
- */
- public function fill(mixed $value = null): void
- {
- $this->value = $value;
- }
-
/**
* Optional help text below the field
*/
@@ -203,55 +151,11 @@ abstract class FieldClass
return $this->disabled;
}
- public function isEmpty(): bool
- {
- return $this->isEmptyValue($this->value());
- }
-
- public function isEmptyValue(mixed $value = null): bool
- {
- return in_array($value, [null, '', []], true);
- }
-
public function isHidden(): bool
{
return false;
}
- /**
- * Checks if the field is invalid
- */
- 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
- */
- public function isValid(): bool
- {
- return empty($this->errors()) === true;
- }
-
- /**
- * Returns the Kirby instance
- */
- public function kirby(): App
- {
- return $this->model->kirby();
- }
-
/**
* The field label can be set as string or associative array with translations
*/
@@ -262,14 +166,6 @@ abstract class FieldClass
);
}
- /**
- * Returns the parent model
- */
- public function model(): ModelWithContent
- {
- return $this->model;
- }
-
/**
* Returns the field name
*/
@@ -278,48 +174,6 @@ abstract class FieldClass
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
- */
- 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()
- ) {
- foreach ($this->when as $field => $value) {
- $field = $formFields->get($field);
- $inputValue = $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
*/
@@ -355,7 +209,7 @@ abstract class FieldClass
'name' => $this->name(),
'placeholder' => $this->placeholder(),
'required' => $this->isRequired(),
- 'saveable' => $this->isSaveable(),
+ 'saveable' => $this->hasValue(),
'translate' => $this->translate(),
'type' => $this->type(),
'when' => $this->when(),
@@ -363,31 +217,6 @@ abstract class FieldClass
];
}
- /**
- * If `true`, the field has to be filled in correctly to be saved.
- */
- public function required(): bool
- {
- return $this->required;
- }
-
- /**
- * Routes for the field API
- */
- public function routes(): array
- {
- return [];
- }
-
- /**
- * @deprecated 3.5.0
- * @todo remove when the general field class setup has been refactored
- */
- public function save(): bool
- {
- return $this->isSaveable();
- }
-
protected function setAfter(array|string|null $after = null): void
{
$this->after = $this->i18n($after);
@@ -403,11 +232,6 @@ abstract class FieldClass
$this->before = $this->i18n($before);
}
- protected function setDefault(mixed $default = null): void
- {
- $this->default = $default;
- }
-
protected function setDisabled(bool $disabled = false): void
{
$this->disabled = $disabled;
@@ -428,14 +252,9 @@ abstract class FieldClass
$this->label = $this->i18n($label);
}
- protected function setModel(ModelWithContent $model): void
- {
- $this->model = $model;
- }
-
protected function setName(string|null $name = null): void
{
- $this->name = $name;
+ $this->name = strtolower($name ?? $this->type());
}
protected function setPlaceholder(array|string|null $placeholder = null): void
@@ -443,29 +262,11 @@ abstract class FieldClass
$this->placeholder = $this->i18n($placeholder);
}
- protected function setRequired(bool $required = false): void
- {
- $this->required = $required;
- }
-
protected function setSiblings(Fields|null $siblings = null): void
{
$this->siblings = $siblings ?? new Fields([$this]);
}
- protected function setTranslate(bool $translate = true): void
- {
- $this->translate = $translate;
- }
-
- /**
- * Setter for the when condition
- */
- protected function setWhen(array|null $when = null): void
- {
- $this->when = $when;
- }
-
/**
* Setter for the field width
*/
@@ -475,7 +276,7 @@ abstract class FieldClass
}
/**
- * Returns all sibling fields
+ * Returns all sibling fields for the HasSiblings trait
*/
protected function siblingsCollection(): Fields
{
@@ -494,30 +295,12 @@ abstract class FieldClass
return null;
}
- /**
- * Converts the given value to a value
- * that can be stored in the text file
- */
- public function store(mixed $value): mixed
- {
- return $value;
- }
-
- /**
- * Should the field be translatable?
- */
- public function translate(): bool
- {
- return $this->translate;
- }
-
/**
* Converts the field to a plain array
*/
public function toArray(): array
{
$props = $this->props();
- $props['signature'] = md5(json_encode($props));
ksort($props);
@@ -532,109 +315,6 @@ abstract class FieldClass
return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class)));
}
- /**
- * Runs the validations defined for the field
- */
- 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 ($validation instanceof Closure) {
- try {
- $validation->call($this, $value);
- } catch (Exception $e) {
- $errors[$key] = $e->getMessage();
- }
- }
- }
-
- return $errors;
- }
-
- /**
- * Defines all validation rules
- * @codeCoverageIgnore
- */
- protected function validations(): array
- {
- return [];
- }
-
- /**
- * Returns the value of the field if saveable
- * otherwise it returns null
- */
- public function value(bool $default = false): mixed
- {
- if ($this->isSaveable() === false) {
- return null;
- }
-
- if ($default === true && $this->isEmpty() === true) {
- return $this->default();
- }
-
- return $this->value;
- }
-
- protected function valueFromJson(mixed $value): array
- {
- try {
- return Data::decode($value, 'json');
- } catch (Throwable) {
- return [];
- }
- }
-
- protected function valueFromYaml(mixed $value): array
- {
- return Data::decode($value, 'yaml');
- }
-
- protected function valueToJson(
- array|null $value = null,
- bool $pretty = false
- ): string {
- $constants = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
-
- if ($pretty === true) {
- $constants |= JSON_PRETTY_PRINT;
- }
-
- return json_encode($value, $constants);
- }
-
- protected function valueToYaml(array|null $value = null): string
- {
- return Data::encode($value, 'yaml');
- }
-
- /**
- * Conditions when the field will be shown
- */
- public function when(): array|null
- {
- return $this->when;
- }
-
/**
* Returns the width of the field in
* the Panel grid
diff --git a/public/kirby/src/Form/Fields.php b/public/kirby/src/Form/Fields.php
index 95c8fec..7787f48 100644
--- a/public/kirby/src/Form/Fields.php
+++ b/public/kirby/src/Form/Fields.php
@@ -3,7 +3,14 @@
namespace Kirby\Form;
use Closure;
+use Kirby\Cms\App;
+use Kirby\Cms\Language;
+use Kirby\Cms\ModelWithContent;
+use Kirby\Exception\InvalidArgumentException;
+use Kirby\Exception\NotFoundException;
+use Kirby\Toolkit\A;
use Kirby\Toolkit\Collection;
+use Kirby\Toolkit\Str;
/**
* A collection of Field objects
@@ -13,27 +20,309 @@ use Kirby\Toolkit\Collection;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
+ *
+ * @extends \Kirby\Toolkit\Collection<\Kirby\Form\Field|\Kirby\Form\FieldClass>
*/
class Fields extends Collection
{
+ protected Language $language;
+ protected ModelWithContent $model;
+ protected array $passthrough = [];
+
+ public function __construct(
+ array $fields = [],
+ ModelWithContent|null $model = null,
+ Language|string|null $language = null
+ ) {
+ $this->model = $model ?? App::instance()->site();
+ $this->language = Language::ensure($language ?? 'current');
+
+ foreach ($fields as $name => $field) {
+ $this->__set($name, $field);
+ }
+ }
+
/**
* Internal setter for each object in the Collection.
* This takes care of validation and of setting
* the collection prop on each object correctly.
*
- * @param object|array $field
+ * @param \Kirby\Form\Field|\Kirby\Form\FieldClass|array $field
*/
public function __set(string $name, $field): void
{
if (is_array($field) === true) {
// use the array key as name if the name is not set
- $field['name'] ??= $name;
+ $field['model'] ??= $this->model;
+ $field['name'] ??= $name;
$field = Field::factory($field['type'], $field, $this);
}
parent::__set($field->name(), $field);
}
+ /**
+ * Returns an array with the default value of each field
+ *
+ * @since 5.0.0
+ */
+ public function defaults(): array
+ {
+ return $this->toArray(fn ($field) => $field->default());
+ }
+
+ /**
+ * An array of all found in all fields errors
+ */
+ public function errors(): array
+ {
+ $errors = [];
+
+ foreach ($this->data as $name => $field) {
+ $fieldErrors = $field->errors();
+
+ if ($fieldErrors !== []) {
+ $errors[$name] = [
+ 'label' => $field->label(),
+ 'message' => $fieldErrors
+ ];
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Get the field object by name
+ * and handle nested fields correctly
+ *
+ * @since 5.0.0
+ * @throws \Kirby\Exception\NotFoundException
+ */
+ public function field(string $name): Field|FieldClass
+ {
+ if ($field = $this->find($name)) {
+ return $field;
+ }
+
+ throw new NotFoundException(
+ message: 'The field could not be found'
+ );
+ }
+
+ /**
+ * Sets the value for each field with a matching key in the input array
+ *
+ * @since 5.0.0
+ */
+ public function fill(
+ array $input,
+ bool $passthrough = true
+ ): static {
+ if ($passthrough === true) {
+ $this->passthrough($input);
+ }
+
+ foreach ($input as $name => $value) {
+ if (!$field = $this->get($name)) {
+ continue;
+ }
+
+ // don't change the value of non-value field
+ if ($field->hasValue() === false) {
+ continue;
+ }
+
+ // resolve closure values
+ if ($value instanceof Closure) {
+ $value = $value($field->toFormValue());
+ }
+
+ $field->fill($value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Find a field by key/name
+ */
+ public function findByKey(string $key): Field|FieldClass|null
+ {
+ if (str_contains($key, '+')) {
+ return $this->findByKeyRecursive($key);
+ }
+
+ return parent::findByKey($key);
+ }
+
+ /**
+ * Find fields in nested forms recursively
+ */
+ public function findByKeyRecursive(string $key): Field|FieldClass|null
+ {
+ $fields = $this;
+ $names = Str::split($key, '+');
+ $index = 0;
+ $count = count($names);
+ $field = null;
+
+ foreach ($names as $name) {
+ $index++;
+
+ // search for the field by name
+ $field = $fields->get($name);
+
+ // if the field cannot be found,
+ // there's no point in going further
+ if ($field === null) {
+ return null;
+ }
+
+ // there are more parts in the key
+ if ($index < $count) {
+ $form = $field->form();
+
+ // the search can only continue for
+ // fields with valid nested forms
+ if ($form instanceof Form === false) {
+ return null;
+ }
+
+ $fields = $form->fields();
+ }
+ }
+
+ return $field;
+ }
+
+ /**
+ * Creates a new Fields instance for the given model and language
+ *
+ * @since 5.0.0
+ */
+ public static function for(
+ ModelWithContent $model,
+ Language|string|null $language = null
+ ): static {
+ return new static(
+ fields: $model->blueprint()->fields(),
+ model: $model,
+ language: $language,
+ );
+ }
+
+ /**
+ * Returns the language of the fields
+ *
+ * @since 5.0.0
+ */
+ public function language(): Language
+ {
+ return $this->language;
+ }
+
+ /**
+ * Adds values to the passthrough array
+ * which will be added to the form data
+ * if the field does not exist
+ *
+ * @since 5.0.0
+ */
+ public function passthrough(array|null $values = null): static|array
+ {
+ // use passthrough method as getter if the value is null
+ if ($values === null) {
+ return $this->passthrough;
+ }
+
+ foreach ($values as $key => $value) {
+ $key = strtolower($key);
+
+ // check if the field exists and don't passthrough
+ // values for existing fields
+ if ($this->get($key) !== null) {
+ continue;
+ }
+
+ // resolve closure values
+ if ($value instanceof Closure) {
+ $value = $value($this->passthrough[$key] ?? null);
+ }
+
+ $this->passthrough[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Resets the value of each field
+ *
+ * @since 5.0.0
+ */
+ public function reset(): static
+ {
+ // reset the passthrough values
+ $this->passthrough = [];
+
+ // reset the values of each field
+ foreach ($this->data as $field) {
+ $field->fill(null);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sets the value for each field with a matching key in the input array
+ * but only if the field is not disabled
+ *
+ * @since 5.0.0
+ * @param bool $passthrough If true, values for undefined fields will be submitted
+ * @param bool $force If true, values for fields that cannot be submitted (e.g. disabled or untranslatable fields) will be submitted
+ */
+ public function submit(
+ array $input,
+ bool $passthrough = true,
+ bool $force = false
+ ): static {
+ $language = $this->language();
+
+ if ($passthrough === true) {
+ $this->passthrough($input);
+ }
+
+ foreach ($input as $name => $value) {
+ if (!$field = $this->get($name)) {
+ continue;
+ }
+
+ // don't submit fields without a value
+ if ($force === true && $field->hasValue() === false) {
+ continue;
+ }
+
+ // don't submit fields that are not submittable
+ if ($force === false && $field->isSubmittable($language) === false) {
+ continue;
+ }
+
+ // resolve closure values
+ if ($value instanceof Closure) {
+ $value = $value($field->toFormValue());
+ }
+
+ // submit the value to the field
+ // the field class might override this method
+ // to handle submitted values differently
+ $field->submit($value);
+ }
+
+ // reset the errors cache
+ return $this;
+ }
+
/**
* Converts the fields collection to an
* array and also does that for every
@@ -41,12 +330,103 @@ class Fields extends Collection
*/
public function toArray(Closure|null $map = null): array
{
- $array = [];
+ return A::map($this->data, $map ?? fn ($field) => $field->toArray());
+ }
- foreach ($this as $field) {
- $array[$field->name()] = $field->toArray();
+ /**
+ * Returns an array with the form value of each field
+ * (e.g. used as data for Panel Vue components)
+ *
+ * @since 5.0.0
+ */
+ public function toFormValues(): array
+ {
+ return $this->toValues(
+ fn ($field) => $field->toFormValue(),
+ fn ($field) => $field->hasValue()
+ );
+ }
+
+ /**
+ * Returns an array with the props of each field
+ * for the frontend
+ *
+ * @since 5.0.0
+ */
+ public function toProps(): array
+ {
+ $fields = $this->data;
+ $props = [];
+ $language = $this->language();
+ $permissions = $this->model->permissions()->can('update');
+
+ foreach ($fields as $name => $field) {
+ $props[$name] = $field->toArray();
+
+ // the field should be disabled in the form if the user
+ // has no update permissions for the model or if the field
+ // is not translatable into the current language
+ if ($permissions === false || $field->isTranslatable($language) === false) {
+ $props[$name]['disabled'] = true;
+ }
+
+ // the value should not be included in the props
+ // we pass on the values to the frontend via the model
+ // view props to make them globally available for the view.
+ unset($props[$name]['value']);
}
- return $array;
+ return $props;
+ }
+
+ /**
+ * Returns an array with the stored value of each field
+ * (e.g. used for saving to content storage)
+ *
+ * @since 5.0.0
+ */
+ public function toStoredValues(): array
+ {
+ return $this->toValues(
+ fn ($field) => $field->toStoredValue(),
+ fn ($field) => $field->isStorable($this->language())
+ );
+ }
+
+ /**
+ * Returns an array with the values of each field
+ * and adds passthrough values if they don't exist
+ * @unstable
+ */
+ protected function toValues(Closure $method, Closure $filter): array
+ {
+ $values = $this->filter($filter)->toArray($method);
+
+ foreach ($this->passthrough as $key => $value) {
+ if (isset($values[$key]) === false) {
+ $values[$key] = $value;
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Checks for errors in all fields and throws an
+ * exception if there are any
+ *
+ * @since 5.0.0
+ * @throws \Kirby\Exception\InvalidArgumentException
+ */
+ public function validate(): void
+ {
+ $errors = $this->errors();
+
+ if ($errors !== []) {
+ throw new InvalidArgumentException(
+ fallback: 'Invalid form with errors',
+ details: $errors
+ );
+ }
}
}
diff --git a/public/kirby/src/Form/Form.php b/public/kirby/src/Form/Form.php
index adec4f3..a75906a 100644
--- a/public/kirby/src/Form/Form.php
+++ b/public/kirby/src/Form/Form.php
@@ -2,15 +2,11 @@
namespace Kirby\Form;
-use Closure;
-use Kirby\Cms\App;
use Kirby\Cms\File;
+use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Data\Data;
-use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\A;
-use Kirby\Toolkit\Str;
-use Throwable;
/**
* The main form class, that is being
@@ -26,88 +22,37 @@ use Throwable;
*/
class Form
{
- /**
- * An array of all found errors
- */
- protected array|null $errors = null;
-
/**
* Fields in the form
*/
- protected Fields|null $fields;
-
- /**
- * All values of form
- */
- protected array $values = [];
+ protected Fields $fields;
/**
* Form constructor
*/
- public function __construct(array $props)
- {
- $fields = $props['fields'] ?? [];
- $values = $props['values'] ?? [];
- $input = $props['input'] ?? [];
- $strict = $props['strict'] ?? false;
- $inject = $props;
+ public function __construct(
+ array $props = [],
+ array $fields = [],
+ ModelWithContent|null $model = null,
+ Language|string|null $language = null
+ ) {
+ if ($props !== []) {
+ $this->legacyConstruct(...$props);
+ return;
+ }
- // prepare field properties for multilang setups
- $fields = static::prepareFieldsForLanguage(
- $fields,
- $props['language'] ?? null
+ $this->fields = new Fields(
+ fields: $fields,
+ model: $model,
+ language: $language
);
-
- // lowercase all value names
- $values = array_change_key_case($values);
- $input = array_change_key_case($input);
-
- unset($inject['fields'], $inject['values'], $inject['input']);
-
- $this->fields = new Fields();
- $this->values = [];
-
- foreach ($fields as $name => $props) {
- // inject stuff from the form constructor (model, etc.)
- $props = array_merge($inject, $props);
-
- // inject the name
- $props['name'] = $name = strtolower($name);
-
- // check if the field is disabled and
- // overwrite the field value if not set
- $props['value'] = match ($props['disabled'] ?? false) {
- true => $values[$name] ?? null,
- default => $input[$name] ?? $values[$name] ?? null
- };
-
- try {
- $field = Field::factory($props['type'], $props, $this->fields);
- } catch (Throwable $e) {
- $field = static::exceptionField($e, $props);
- }
-
- if ($field->save() !== false) {
- $this->values[$name] = $field->value();
- }
-
- $this->fields->append($name, $field);
- }
-
- if ($strict !== true) {
- // use all given values, no matter
- // if there's a field or not.
- $input = array_merge($values, $input);
-
- foreach ($input as $key => $value) {
- $this->values[$key] ??= $value;
- }
- }
}
/**
* Returns the data required to write to the content file
* Doesn't include default and null values
+ *
+ * @deprecated 5.0.0 Use `::toStoredValues()` instead
*/
public function content(): array
{
@@ -117,71 +62,54 @@ class Form
/**
* Returns data for all fields in the form
*
- * @param false $defaults
+ * @deprecated 5.0.0 Use `::toStoredValues()` instead
*/
public function data($defaults = false, bool $includeNulls = true): array
{
- $data = $this->values;
+ $data = [];
+ $language = $this->fields->language();
foreach ($this->fields as $field) {
- if ($field->save() === false || $field->unset() === true) {
+ if ($field->isStorable($language) === false) {
if ($includeNulls === true) {
$data[$field->name()] = null;
- } else {
- unset($data[$field->name()]);
}
- } else {
- $data[$field->name()] = $field->data($defaults);
+
+ continue;
+ }
+
+ if ($defaults === true && $field->isEmpty() === true) {
+ $field->fill($field->default());
+ }
+
+ $data[$field->name()] = $field->toStoredValue();
+ }
+
+ foreach ($this->fields->passthrough() as $key => $value) {
+ if (isset($data[$key]) === false) {
+ $data[$key] = $value;
}
}
return $data;
}
+ /**
+ * Returns an array with the default value of each field
+ *
+ * @since 5.0.0
+ */
+ public function defaults(): array
+ {
+ return $this->fields->defaults();
+ }
+
/**
* An array of all found errors
*/
public function errors(): array
{
- if ($this->errors !== null) {
- return $this->errors;
- }
-
- $this->errors = [];
-
- foreach ($this->fields as $field) {
- if (empty($field->errors()) === false) {
- $this->errors[$field->name()] = [
- 'label' => $field->label(),
- 'message' => $field->errors()
- ];
- }
- }
-
- return $this->errors;
- }
-
- /**
- * Shows the error with the field
- */
- public static function exceptionField(
- Throwable $exception,
- array $props = []
- ): Field {
- $message = $exception->getMessage();
-
- if (App::instance()->option('debug') === true) {
- $message .= ' in file: ' . $exception->getFile();
- $message .= ' line: ' . $exception->getLine();
- }
-
- $props = array_merge($props, [
- 'label' => 'Error in "' . $props['name'] . '" field.',
- 'theme' => 'negative',
- 'text' => strip_tags($message),
- ]);
-
- return Field::factory('info', $props);
+ return $this->fields->errors();
}
/**
@@ -192,81 +120,59 @@ class Form
*/
public function field(string $name): Field|FieldClass
{
- $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();
- }
-
- continue;
- }
-
- 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;
+ return $this->fields->field($name);
}
/**
* Returns form fields
*/
- public function fields(): Fields|null
+ public function fields(): Fields
{
return $this->fields;
}
+ /**
+ * Sets the value for each field with a matching key in the input array
+ *
+ * @since 5.0.0
+ */
+ public function fill(
+ array $input,
+ bool $passthrough = true
+ ): static {
+ $this->fields->fill(
+ input: $input,
+ passthrough: $passthrough
+ );
+ return $this;
+ }
+
+ /**
+ * Creates a new Form instance for the given model with the fields
+ * from the blueprint and the values from the content
+ */
public static function for(
ModelWithContent $model,
- array $props = []
+ array $props = [],
+ Language|string|null $language = null,
): static {
- // get the original model data
- $original = $model->content($props['language'] ?? null)->toArray();
- $values = $props['values'] ?? [];
-
- // convert closures to values
- foreach ($values as $key => $value) {
- if ($value instanceof Closure) {
- $values[$key] = $value($original[$key] ?? null);
- }
+ if ($props !== []) {
+ return static::legacyFor(
+ $model,
+ ...$props
+ );
}
- // set a few defaults
- $props['values'] = array_merge($original, $values);
- $props['fields'] ??= [];
- $props['model'] = $model;
+ $form = new static(
+ fields: $model->blueprint()->fields(),
+ model: $model,
+ language: $language
+ );
- // search for the blueprint
- if (
- method_exists($model, 'blueprint') === true &&
- $blueprint = $model->blueprint()
- ) {
- $props['fields'] = $blueprint->fields();
- }
+ // fill the form with the latest content of the model
+ $form->fill(input: $model->content($form->language())->toArray());
- $ignoreDisabled = $props['ignoreDisabled'] ?? false;
-
- // REFACTOR: this could be more elegant
- if ($ignoreDisabled === true) {
- $props['fields'] = array_map(function ($field) {
- $field['disabled'] = false;
- return $field;
- }, $props['fields']);
- }
-
- return new static($props);
+ return $form;
}
/**
@@ -282,43 +188,116 @@ class Form
*/
public function isValid(): bool
{
- return empty($this->errors()) === true;
+ return $this->fields->errors() === [];
}
/**
- * Disables fields in secondary languages when
- * they are configured to be untranslatable
+ * Returns the language of the form
+ *
+ * @since 5.0.0
*/
- protected static function prepareFieldsForLanguage(
- array $fields,
- string|null $language = null
- ): array {
- $kirby = App::instance(null, true);
+ public function language(): Language
+ {
+ return $this->fields->language();
+ }
- // only modify the fields if we have a valid Kirby multilang instance
- if ($kirby?->multilang() !== true) {
- return $fields;
+ /**
+ * Legacy constructor to support the old props array
+ *
+ * @deprecated 5.0.0 Use the new constructor with named parameters instead
+ */
+ protected function legacyConstruct(
+ array $fields = [],
+ ModelWithContent|null $model = null,
+ Language|string|null $language = null,
+ array $values = [],
+ array $input = [],
+ bool $strict = false
+ ): void {
+ $this->__construct(
+ fields: $fields,
+ model: $model,
+ language: $language
+ );
+
+ $this->fill(
+ input: $values,
+ passthrough: $strict === false
+ );
+
+ $this->submit(
+ input: $input,
+ passthrough: $strict === false
+ );
+ }
+
+ /**
+ * Legacy for method to support the old props array
+ *
+ * @deprecated 5.0.0 Use `::for()` with named parameters instead
+ */
+ protected static function legacyFor(
+ ModelWithContent $model,
+ Language|string|null $language = null,
+ bool $strict = false,
+ array|null $input = [],
+ array|null $values = [],
+ bool $ignoreDisabled = false
+ ): static {
+ $form = static::for(
+ model: $model,
+ language: $language,
+ );
+
+ $form->fill(
+ input: $values ?? [],
+ passthrough: $strict === false
+ );
+
+ $form->submit(
+ input: $input ?? [],
+ passthrough: $strict === false
+ );
+
+ return $form;
+ }
+
+ /**
+ * Adds values to the passthrough array
+ * which will be added to the form data
+ * if the field does not exist
+ *
+ * @since 5.0.0
+ */
+ public function passthrough(
+ array|null $values = null
+ ): static|array {
+ if ($values === null) {
+ return $this->fields->passthrough();
}
- $language ??= $kirby->language()->code();
+ $this->fields->passthrough(
+ values: $values
+ );
- if ($language !== $kirby->defaultLanguage()->code()) {
- foreach ($fields as $fieldName => $fieldProps) {
- // switch untranslatable fields to readonly
- if (($fieldProps['translate'] ?? true) === false) {
- $fields[$fieldName]['unset'] = true;
- $fields[$fieldName]['disabled'] = true;
- }
- }
- }
+ return $this;
+ }
- return $fields;
+ /**
+ * Resets the value of each field
+ *
+ * @since 5.0.0
+ */
+ public function reset(): static
+ {
+ $this->fields->reset();
+ return $this;
}
/**
* Converts the data of fields to strings
*
- * @param false $defaults
+ * @deprecated 5.0.0 Use `::toStoredValues()` instead
*/
public function strings($defaults = false): array
{
@@ -331,25 +310,91 @@ class Form
);
}
+ /**
+ * Sets the value for each field with a matching key in the input array
+ * but only if the field is not disabled
+ *
+ * @since 5.0.0
+ * @param bool $passthrough If true, values for undefined fields will be submitted
+ * @param bool $force If true, values for fields that cannot be submitted (e.g. disabled or untranslatable fields) will be submitted
+ */
+ public function submit(
+ array $input,
+ bool $passthrough = true,
+ bool $force = false
+ ): static {
+ $this->fields->submit(
+ input: $input,
+ passthrough: $passthrough,
+ force: $force
+ );
+ return $this;
+ }
+
/**
* Converts the form to a plain array
*/
public function toArray(): array
{
$array = [
- 'errors' => $this->errors(),
- 'fields' => $this->fields->toArray(fn ($item) => $item->toArray()),
+ 'errors' => $this->fields->errors(),
+ 'fields' => $this->fields->toArray(),
'invalid' => $this->isInvalid()
];
return $array;
}
+ /**
+ * Returns an array with the form value of each field
+ * (e.g. used as data for Panel Vue components)
+ *
+ * @since 5.0.0
+ */
+ public function toFormValues(): array
+ {
+ return $this->fields->toFormValues();
+ }
+
+ /**
+ * Returns an array with the props of each field
+ * for the frontend
+ *
+ * @since 5.0.0
+ */
+ public function toProps(): array
+ {
+ return $this->fields->toProps();
+ }
+
+ /**
+ * Returns an array with the stored value of each field
+ * (e.g. used for saving to content storage)
+ *
+ * @since 5.0.0
+ */
+ public function toStoredValues(): array
+ {
+ return $this->fields->toStoredValues();
+ }
+
+ /**
+ * Validates the form and throws an exception if there are any errors
+ *
+ * @throws \Kirby\Exception\InvalidArgumentException
+ */
+ public function validate(): void
+ {
+ $this->fields->validate();
+ }
+
/**
* Returns form values
+ *
+ * @deprecated 5.0.0 Use `::toFormValues()` instead
*/
public function values(): array
{
- return $this->values;
+ return $this->fields->toFormValues();
}
}
diff --git a/public/kirby/src/Form/Mixin/Api.php b/public/kirby/src/Form/Mixin/Api.php
new file mode 100644
index 0000000..d648249
--- /dev/null
+++ b/public/kirby/src/Form/Mixin/Api.php
@@ -0,0 +1,19 @@
+routes();
+ }
+
+ /**
+ * Routes for the field API
+ */
+ public function routes(): array
+ {
+ return [];
+ }
+}
diff --git a/public/kirby/src/Form/Mixin/Min.php b/public/kirby/src/Form/Mixin/Min.php
index aa57af5..9f5977e 100644
--- a/public/kirby/src/Form/Mixin/Min.php
+++ b/public/kirby/src/Form/Mixin/Min.php
@@ -8,6 +8,11 @@ trait Min
public function min(): int|null
{
+ // set min to at least 1, if required
+ if ($this->required === true) {
+ return $this->min ?? 1;
+ }
+
return $this->min;
}
@@ -15,4 +20,14 @@ trait Min
{
$this->min = $min;
}
+
+ public function isRequired(): bool
+ {
+ // set required to true if min is set
+ if ($this->min) {
+ return true;
+ }
+
+ return $this->required;
+ }
}
diff --git a/public/kirby/src/Form/Mixin/Model.php b/public/kirby/src/Form/Mixin/Model.php
new file mode 100644
index 0000000..69b4c19
--- /dev/null
+++ b/public/kirby/src/Form/Mixin/Model.php
@@ -0,0 +1,35 @@
+model->kirby();
+ }
+
+ /**
+ * Returns the parent model
+ */
+ public function model(): ModelWithContent
+ {
+ return $this->model;
+ }
+
+ /**
+ * Sets the parent model
+ */
+ protected function setModel(ModelWithContent|null $model = null): void
+ {
+ $this->model = $model ?? App::instance()->site();
+ }
+}
diff --git a/public/kirby/src/Form/Mixin/Translatable.php b/public/kirby/src/Form/Mixin/Translatable.php
new file mode 100644
index 0000000..bc36a65
--- /dev/null
+++ b/public/kirby/src/Form/Mixin/Translatable.php
@@ -0,0 +1,47 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://opensource.org/licenses/MIT
+ */
+trait Translatable
+{
+ protected bool $translate = true;
+
+ /**
+ * Should the field be translatable into the given language?
+ *
+ * @since 5.0.0
+ */
+ public function isTranslatable(Language $language): bool
+ {
+ if ($this->translate() === false && $language->isDefault() === false) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Set the translatable status
+ */
+ protected function setTranslate(bool $translate = true): void
+ {
+ $this->translate = $translate;
+ }
+
+ /**
+ * Should the field be translatable?
+ */
+ public function translate(): bool
+ {
+ return $this->translate;
+ }
+}
diff --git a/public/kirby/src/Form/Mixin/Validation.php b/public/kirby/src/Form/Mixin/Validation.php
new file mode 100644
index 0000000..cd69a0c
--- /dev/null
+++ b/public/kirby/src/Form/Mixin/Validation.php
@@ -0,0 +1,117 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://opensource.org/licenses/MIT
+ */
+trait Validation
+{
+ protected bool $required;
+
+ /**
+ * Runs all validations and returns an array of
+ * error messages
+ */
+ public function errors(): array
+ {
+ $validations = $this->validations();
+ $value = $this->value();
+ $errors = [];
+
+ // validate required values
+ if ($this->needsValue() === true) {
+ $errors['required'] = I18n::translate('error.validation.required');
+ }
+
+ foreach ($validations as $key => $validation) {
+ if (is_int($key) === true) {
+ // predefined validation
+ try {
+ Validations::$validation($this, $value);
+ } catch (Exception $e) {
+ $errors[$validation] = $e->getMessage();
+ }
+ continue;
+ }
+
+ if ($validation instanceof Closure) {
+ try {
+ $validation->call($this, $value);
+ } catch (Exception $e) {
+ $errors[$key] = $e->getMessage();
+ }
+ }
+ }
+
+ if (
+ empty($this->validate) === false &&
+ ($this->isEmpty() === false || $this->isRequired() === true)
+ ) {
+ $rules = A::wrap($this->validate);
+
+ $errors = [
+ ...$errors,
+ ...V::errors($value, $rules)
+ ];
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Checks if the field is required
+ */
+ public function isRequired(): bool
+ {
+ return $this->required;
+ }
+
+ /**
+ * Checks if the field is invalid
+ */
+ public function isInvalid(): bool
+ {
+ return $this->errors() !== [];
+ }
+
+ /**
+ * Checks if the field is valid
+ */
+ public function isValid(): bool
+ {
+ return $this->errors() === [];
+ }
+
+ /**
+ * Getter for the required property
+ */
+ public function required(): bool
+ {
+ return $this->required;
+ }
+
+ protected function setRequired(bool $required = false): void
+ {
+ $this->required = $required;
+ }
+
+ /**
+ * Defines all validation rules
+ */
+ protected function validations(): array
+ {
+ return [];
+ }
+}
diff --git a/public/kirby/src/Form/Mixin/Value.php b/public/kirby/src/Form/Mixin/Value.php
new file mode 100644
index 0000000..3dd3423
--- /dev/null
+++ b/public/kirby/src/Form/Mixin/Value.php
@@ -0,0 +1,212 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://opensource.org/licenses/MIT
+ */
+trait Value
+{
+ protected mixed $default = null;
+ protected mixed $value = null;
+
+ /**
+ * @deprecated 5.0.0 Use `::toStoredValue()` instead to receive
+ * the value in the format that will be needed for content files.
+ *
+ * If you need to get the value with the default as fallback, you should use
+ * the fill method first `$field->fill($field->default())->toStoredValue()`
+ */
+ public function data(bool $default = false): mixed
+ {
+ if ($default === true && $this->isEmpty() === true) {
+ $this->fill($this->default());
+ }
+
+ return $this->toStoredValue();
+ }
+
+ /**
+ * Returns the default value of the field
+ */
+ public function default(): mixed
+ {
+ if (is_string($this->default) === false) {
+ return $this->default;
+ }
+
+ return $this->model->toString($this->default);
+ }
+
+ /**
+ * Sets a new value for the field
+ */
+ public function fill(mixed $value): static
+ {
+ $this->value = $value;
+ return $this;
+ }
+
+ /**
+ * Checks if the field has a value
+ */
+ public function hasValue(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Checks if the field is empty
+ */
+ public function isEmpty(): bool
+ {
+ return $this->isEmptyValue($this->toFormValue());
+ }
+
+ /**
+ * Checks if the given value is considered empty
+ */
+ public function isEmptyValue(mixed $value = null): bool
+ {
+ return in_array($value, [null, '', []], true);
+ }
+
+ /**
+ * Checks if the field can be stored in the given language.
+ */
+ public function isStorable(Language $language): bool
+ {
+ // the field cannot be stored at all if it has no value
+ if ($this->hasValue() === false) {
+ return false;
+ }
+
+ // the field cannot be translated into the given language
+ if ($this->isTranslatable($language) === false) {
+ return false;
+ }
+
+ // We don't need to check if the field is disabled.
+ // A disabled field can still have a value and that value
+ // should still be stored. But that value must not be changed
+ // on submit. That's why we check for the disabled state
+ // in the isSubmittable method.
+
+ return true;
+ }
+
+ /**
+ * A field might have a value, but can still not be submitted
+ * because it is disabled, not translatable into the given
+ * language or not active due to a `when` rule.
+ */
+ public function isSubmittable(Language $language): bool
+ {
+ if ($this->hasValue() === false) {
+ return false;
+ }
+
+ if ($this->isTranslatable($language) === false) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the field needs a value before being saved;
+ * this is the case if all of the following requirements are met:
+ * - The field has a value
+ * - The field is required
+ * - The field is currently empty
+ * - The field is not currently inactive because of a `when` rule
+ */
+ protected function needsValue(): bool
+ {
+ if (
+ $this->hasValue() === false ||
+ $this->isRequired() === false ||
+ $this->isEmpty() === false ||
+ $this->isActive() === false
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the field is saveable
+ * @deprecated 5.0.0 Use `::hasValue()` instead
+ */
+ public function save(): bool
+ {
+ return $this->hasValue();
+ }
+
+ protected function setDefault(mixed $default = null): void
+ {
+ $this->default = $default;
+ }
+
+ /**
+ * Submits a new value for the field.
+ * Fields can overwrite this method to provide custom
+ * submit logic. This is useful if the field component
+ * sends data that needs to be processed before being
+ * stored.
+ *
+ * @since 5.0.0
+ */
+ public function submit(mixed $value): static
+ {
+ return $this->fill($value);
+ }
+
+ /**
+ * Returns the value of the field in a format to be used in forms
+ * (e.g. used as data for Panel Vue components)
+ */
+ public function toFormValue(): mixed
+ {
+ if ($this->hasValue() === false) {
+ return null;
+ }
+
+ return $this->value;
+ }
+
+ /**
+ * Returns the value of the field in a format
+ * to be stored by our storage classes
+ */
+ public function toStoredValue(): mixed
+ {
+ return $this->toFormValue();
+ }
+
+ /**
+ * Returns the value of the field if it has a value
+ * otherwise it returns null
+ *
+ * @see `self::toFormValue()`
+ * @todo might get deprecated or reused later. Use `self::toFormValue()` instead.
+ *
+ * If you need the form value with the default as fallback, you should use
+ * the fill method first `$field->fill($field->default())->toFormValue()`
+ */
+ public function value(bool $default = false): mixed
+ {
+ if ($default === true && $this->isEmpty() === true) {
+ $this->fill($this->default());
+ }
+
+ return $this->toFormValue();
+ }
+}
diff --git a/public/kirby/src/Form/Mixin/When.php b/public/kirby/src/Form/Mixin/When.php
new file mode 100644
index 0000000..1bc18c9
--- /dev/null
+++ b/public/kirby/src/Form/Mixin/When.php
@@ -0,0 +1,58 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier
+ * @license https://opensource.org/licenses/MIT
+ */
+trait When
+{
+ protected array|null $when = null;
+
+ /**
+ * Checks if the field is currently active
+ * or hidden because of a `when` condition
+ */
+ public function isActive(): bool
+ {
+ if ($this->when === null || $this->when === []) {
+ return true;
+ }
+
+ $siblings = $this->siblings();
+
+ foreach ($this->when as $field => $value) {
+ $field = $siblings->get($field);
+ $input = $field?->value() ?? '';
+
+ // if the input data doesn't match the requested `when` value,
+ // that means that this field is not required and can be saved
+ // (*all* `when` conditions must be met for this field to be required)
+ if ($input !== $value) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Setter for the `when` condition
+ */
+ protected function setWhen(array|null $when = null): void
+ {
+ $this->when = $when;
+ }
+
+ /**
+ * Returns the `when` condition of the field
+ */
+ public function when(): array|null
+ {
+ return $this->when;
+ }
+}
diff --git a/public/kirby/src/Form/Validations.php b/public/kirby/src/Form/Validations.php
index cf1235a..80b7737 100644
--- a/public/kirby/src/Form/Validations.php
+++ b/public/kirby/src/Form/Validations.php
@@ -24,11 +24,11 @@ class Validations
*/
public static function boolean($field, $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
if (is_bool($value) === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.boolean'
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.boolean'
+ );
}
}
@@ -42,10 +42,10 @@ class Validations
*/
public static function date(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
if (V::date($value) !== true) {
throw new InvalidArgumentException(
- V::message('date', $value)
+ message: V::message('date', $value)
);
}
}
@@ -60,10 +60,10 @@ class Validations
*/
public static function email(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
if (V::email($value) === false) {
throw new InvalidArgumentException(
- V::message('email', $value)
+ message: V::message('email', $value)
);
}
}
@@ -79,12 +79,12 @@ class Validations
public static function max(Field|FieldClass $field, mixed $value): bool
{
if (
- $field->isEmpty($value) === false &&
+ $field->isEmptyValue($value) === false &&
$field->max() !== null
) {
if (V::max($value, $field->max()) === false) {
throw new InvalidArgumentException(
- V::message('max', $value, $field->max())
+ message: V::message('max', $value, $field->max())
);
}
}
@@ -100,12 +100,12 @@ class Validations
public static function maxlength(Field|FieldClass $field, mixed $value): bool
{
if (
- $field->isEmpty($value) === false &&
+ $field->isEmptyValue($value) === false &&
$field->maxlength() !== null
) {
if (V::maxLength($value, $field->maxlength()) === false) {
throw new InvalidArgumentException(
- V::message('maxlength', $value, $field->maxlength())
+ message: V::message('maxlength', $value, $field->maxlength())
);
}
}
@@ -121,12 +121,12 @@ class Validations
public static function min(Field|FieldClass $field, mixed $value): bool
{
if (
- $field->isEmpty($value) === false &&
+ $field->isEmptyValue($value) === false &&
$field->min() !== null
) {
if (V::min($value, $field->min()) === false) {
throw new InvalidArgumentException(
- V::message('min', $value, $field->min())
+ message: V::message('min', $value, $field->min())
);
}
}
@@ -142,12 +142,12 @@ class Validations
public static function minlength(Field|FieldClass $field, mixed $value): bool
{
if (
- $field->isEmpty($value) === false &&
+ $field->isEmptyValue($value) === false &&
$field->minlength() !== null
) {
if (V::minLength($value, $field->minlength()) === false) {
throw new InvalidArgumentException(
- V::message('minlength', $value, $field->minlength())
+ message: V::message('minlength', $value, $field->minlength())
);
}
}
@@ -162,7 +162,7 @@ class Validations
*/
public static function pattern(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
if ($pattern = $field->pattern()) {
// ensure that that pattern needs to match the whole
// input value from start to end, not just a partial match
@@ -171,7 +171,7 @@ class Validations
if (V::match($value, '/' . $pattern . '/i') === false) {
throw new InvalidArgumentException(
- V::message('match')
+ message: V::message('match')
);
}
}
@@ -188,13 +188,13 @@ class Validations
public static function required(Field|FieldClass $field, mixed $value): bool
{
if (
+ $field->hasValue() === true &&
$field->isRequired() === true &&
- $field->save() === true &&
- $field->isEmpty($value) === true
+ $field->isEmptyValue($value) === true
) {
- throw new InvalidArgumentException([
- 'key' => 'validation.required'
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.required'
+ );
}
return true;
@@ -207,13 +207,13 @@ class Validations
*/
public static function option(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
$values = array_column($field->options(), 'value');
if (in_array($value, $values, true) !== true) {
- throw new InvalidArgumentException([
- 'key' => 'validation.option'
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.option'
+ );
}
}
@@ -227,13 +227,13 @@ class Validations
*/
public static function options(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
$values = array_column($field->options(), 'value');
foreach ($value as $val) {
if (in_array($val, $values, true) === false) {
- throw new InvalidArgumentException([
- 'key' => 'validation.option'
- ]);
+ throw new InvalidArgumentException(
+ key: 'validation.option'
+ );
}
}
}
@@ -248,10 +248,10 @@ class Validations
*/
public static function time(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
if (V::time($value) !== true) {
throw new InvalidArgumentException(
- V::message('time', $value)
+ message: V::message('time', $value)
);
}
}
@@ -266,10 +266,10 @@ class Validations
*/
public static function url(Field|FieldClass $field, mixed $value): bool
{
- if ($field->isEmpty($value) === false) {
+ if ($field->isEmptyValue($value) === false) {
if (V::url($value) === false) {
throw new InvalidArgumentException(
- V::message('url', $value)
+ message: V::message('url', $value)
);
}
}
diff --git a/public/kirby/src/Http/Cookie.php b/public/kirby/src/Http/Cookie.php
index a44b752..468a311 100644
--- a/public/kirby/src/Http/Cookie.php
+++ b/public/kirby/src/Http/Cookie.php
@@ -25,12 +25,10 @@ class Cookie
/**
* Set a new cookie
*
- *
- *
- * cookie::set('mycookie', 'hello', ['lifetime' => 60]);
+ * ```php
* // expires in 1 hour
- *
- *
+ * Cookie::set('mycookie', 'hello', ['lifetime' => 60]);
+ * ```
*
* @param string $key The name of the cookie
* @param string $value The cookie content
@@ -92,12 +90,10 @@ class Cookie
/**
* Stores a cookie forever
*
- *
- *
- * cookie::forever('mycookie', 'hello');
+ * ```php
* // never expires
- *
- *
+ * Cookie::forever('mycookie', 'hello');
+ * ```
*
* @param string $key The name of the cookie
* @param string $value The cookie content
@@ -119,10 +115,10 @@ class Cookie
/**
* Get a cookie value
*
- *
- * cookie::get('mycookie', 'peter');
+ * ```php
* // sample output: 'hello' or if the cookie is not set 'peter'
- *
+ * Cookie::get('mycookie', 'peter');
+ * ```
*
* @param string|null $key The name of the cookie
* @param string|null $default The default value, which should be returned
@@ -171,7 +167,7 @@ class Cookie
protected static function parse(string $string): string|null
{
// if no hash-value separator is present, we can't parse the value
- if (strpos($string, '+') === false) {
+ if (str_contains($string, '+') === false) {
return null;
}
@@ -197,12 +193,10 @@ class Cookie
/**
* Remove a cookie
*
- *
- *
- * cookie::remove('mycookie');
+ * ```php
* // mycookie is now gone
- *
- *
+ * Cookie::remove('mycookie');
+ * ```
*
* @param string $key The name of the cookie
* @return bool true: the cookie has been removed,
diff --git a/public/kirby/src/Http/Environment.php b/public/kirby/src/Http/Environment.php
index c7b1f05..dd8eee5 100644
--- a/public/kirby/src/Http/Environment.php
+++ b/public/kirby/src/Http/Environment.php
@@ -112,7 +112,7 @@ class Environment
/**
* Returns the server's IP address
- * @see ::ip
+ * @see self::ip()
*/
public function address(): string|null
{
@@ -156,13 +156,13 @@ class Environment
array|null $options = null,
array|null $info = null
): array {
- $defaults = [
+ $options = [
'cli' => null,
- 'allowed' => null
+ 'allowed' => null,
+ ...$options ?? []
];
- $info ??= $_SERVER;
- $options = array_merge($defaults, $options ?? []);
+ $info ??= $_SERVER;
$this->info = static::sanitize($info);
$this->cli = $this->detectCli($options['cli']);
@@ -178,11 +178,11 @@ class Environment
if ($options['allowed'] === '*' || $options['allowed'] === ['*']) {
$this->detectAuto(true);
- // fixed environments
+ // fixed environments
} elseif (empty($options['allowed']) === false) {
$this->detectAllowed($options['allowed']);
- // secure auto-detection
+ // secure auto-detection
} else {
$this->detectAuto();
}
@@ -211,7 +211,9 @@ class Environment
$baseUrl = A::first($allowed);
if (is_string($baseUrl) === false) {
- throw new InvalidArgumentException('Invalid allow list setup for base URLs');
+ throw new InvalidArgumentException(
+ message: 'Invalid allow list setup for base URLs'
+ );
}
$uri = new Uri($baseUrl, ['slash' => false]);
@@ -248,7 +250,9 @@ class Environment
}
}
- throw new InvalidArgumentException('The environment is not allowed');
+ throw new InvalidArgumentException(
+ message: 'The environment is not allowed'
+ );
}
/**
@@ -330,7 +334,7 @@ class Environment
$term = getenv('TERM');
if (
- substr($sapi, 0, 3) === 'cgi' &&
+ str_starts_with($sapi, 'cgi') === true &&
$term &&
$term !== 'unknown'
) {
@@ -529,7 +533,7 @@ class Environment
$protocols = ['https', 'https, http'];
- return in_array(strtolower($protocol), $protocols) === true;
+ return in_array(strtolower($protocol), $protocols, true) === true;
}
/**
@@ -634,13 +638,13 @@ class Environment
/**
* Gets a value from the server environment array
*
- *
- * $server->get('document_root');
+ * ```php
* // sample output: /var/www/kirby
+ * $server->get('document_root');
*
- * $server->get();
* // returns the whole server array
- *
+ * $server->get();
+ * ```
*
* @param string|false|null $key The key to look for. Pass `false` or `null`
* to return the entire server array.
@@ -771,13 +775,13 @@ class Environment
$ips = array_unique(array_filter($ips));
// no known ip? Better not assume it's local
- if (empty($ips) === true) {
+ if ($ips === []) {
return false;
}
// stop as soon as a non-local ip is found
foreach ($ips as $ip) {
- if (in_array($ip, ['::1', '127.0.0.1']) === false) {
+ if (in_array($ip, ['::1', '127.0.0.1'], true) === false) {
return false;
}
}
@@ -810,18 +814,24 @@ class Environment
}
// load the config for the host
- if (empty($host) === false) {
+ if (
+ empty($host) === false &&
+ F::exists($path = $root . '/config.' . $host . '.php', $root) === true
+ ) {
$configHost = F::load(
- file: $root . '/config.' . $host . '.php',
+ file: $path,
fallback: [],
allowOutput: false
);
}
// load the config for the server IP
- if (empty($addr) === false) {
+ if (
+ empty($addr) === false &&
+ F::exists($path = $root . '/config.' . $addr . '.php', $root) === true
+ ) {
$configAddr = F::load(
- file: $root . '/config.' . $addr . '.php',
+ file: $path,
fallback: [],
allowOutput: false
);
diff --git a/public/kirby/src/Http/Header.php b/public/kirby/src/Http/Header.php
index 857798c..2a7d2cd 100644
--- a/public/kirby/src/Http/Header.php
+++ b/public/kirby/src/Http/Header.php
@@ -287,15 +287,14 @@ class Header
*/
public static function download(array $params = []): void
{
- $defaults = [
+ $options = [
'name' => 'download',
'size' => false,
'mime' => 'application/force-download',
- 'modified' => time()
+ 'modified' => time(),
+ ...$params
];
- $options = array_merge($defaults, $params);
-
header('Pragma: public');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $options['modified']) . ' GMT');
diff --git a/public/kirby/src/Http/Params.php b/public/kirby/src/Http/Params.php
index 4067a0f..4a37a81 100644
--- a/public/kirby/src/Http/Params.php
+++ b/public/kirby/src/Http/Params.php
@@ -4,6 +4,7 @@ namespace Kirby\Http;
use Kirby\Toolkit\Obj;
use Kirby\Toolkit\Str;
+use Stringable;
/**
* A wrapper around a URL params
@@ -16,7 +17,7 @@ use Kirby\Toolkit\Str;
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
-class Params extends Obj
+class Params extends Obj implements Stringable
{
public static string|null $separator = null;
@@ -48,7 +49,7 @@ class Params extends Obj
$slash = false;
if (is_string($path) === true) {
- $slash = substr($path, -1, 1) === '/';
+ $slash = str_ends_with($path, '/') === true;
$path = Str::split($path, '/');
}
@@ -57,7 +58,7 @@ class Params extends Obj
$separator = static::separator();
foreach ($path as $index => $p) {
- if (strpos($p, $separator) === false) {
+ if (str_contains($p, $separator) === false) {
continue;
}
@@ -137,7 +138,7 @@ class Params extends Obj
}
}
- if (empty($params) === true) {
+ if ($params === []) {
return '';
}
diff --git a/public/kirby/src/Http/Path.php b/public/kirby/src/Http/Path.php
index 02d2b05..bd12cad 100644
--- a/public/kirby/src/Http/Path.php
+++ b/public/kirby/src/Http/Path.php
@@ -14,6 +14,8 @@ use Kirby\Toolkit\Str;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
+ *
+ * @extends \Kirby\Toolkit\Collection
- *
+ * ```php
* $dimensions = new Dimensions(1200, 768);
* $dimensions->fit(500);
*
@@ -79,8 +79,7 @@ class Dimensions
*
* echo $dimensions->height();
* // output: 320
- *
- *
+ * ```
*
* @param int $box the max width and/or height
* @param bool $force If true, the dimensions will be
@@ -121,8 +120,7 @@ class Dimensions
/**
* Recalculates the width and height to fit the given height
*
- *
- *
+ * ```php
* $dimensions = new Dimensions(1200, 768);
* $dimensions->fitHeight(500);
*
@@ -131,8 +129,7 @@ class Dimensions
*
* echo $dimensions->height();
* // output: 500
- *
- *
+ * ```
*
* @param int|null $fit the max height
* @param bool $force If true, the dimensions will be
@@ -179,8 +176,7 @@ class Dimensions
/**
* Recalculates the width and height to fit the given width
*
- *
- *
+ * ```php
* $dimensions = new Dimensions(1200, 768);
* $dimensions->fitWidth(500);
*
@@ -189,8 +185,7 @@ class Dimensions
*
* echo $dimensions->height();
* // output: 320
- *
- *
+ * ```
*
* @param int|null $fit the max width
* @param bool $force If true, the dimensions will be
@@ -238,14 +233,21 @@ class Dimensions
/**
* Detect the dimensions for an image file
*/
- public static function forImage(string $root): static
+ public static function forImage(Image $image): static
{
- if (file_exists($root) === false) {
+ if ($image->exists() === false) {
return new static(0, 0);
}
- $size = getimagesize($root);
- return new static($size[0] ?? 0, $size[1] ?? 1);
+ $orientation = $image->exif()->orientation();
+ $size = $image->imagesize();
+
+ return match ($orientation) {
+ // 5-8 = rotated
+ 5, 6, 7, 8 => new static($size[1] ?? 1, $size[0] ?? 0),
+ // 1 = normal; 2-4 = flipped
+ default => new static($size[0] ?? 0, $size[1] ?? 1)
+ };
}
/**
@@ -329,13 +331,11 @@ class Dimensions
/**
* Calculates and returns the ratio
*
- *
- *
+ * ```php
* $dimensions = new Dimensions(1200, 768);
* echo $dimensions->ratio();
* // output: 1.5625
- *
- *
+ * ```
*/
public function ratio(): float
{
diff --git a/public/kirby/src/Image/Exif.php b/public/kirby/src/Image/Exif.php
index f32afd0..ddaaafb 100644
--- a/public/kirby/src/Image/Exif.php
+++ b/public/kirby/src/Image/Exif.php
@@ -20,25 +20,27 @@ class Exif
*/
protected array $data = [];
- protected Camera|null $camera = null;
- protected Location|null $location = null;
- protected string|null $timestamp = null;
- protected string|null $exposure = null;
protected string|null $aperture = null;
- protected string|null $iso = null;
+ protected Camera|null $camera = null;
+ protected string|null $exposure = null;
protected string|null $focalLength = null;
protected bool|null $isColor = null;
+ protected string|null $iso = null;
+ protected Location|null $location = null;
+ protected string|null $timestamp = null;
+ protected int $orientation;
public function __construct(
protected Image $image
) {
- $this->data = $this->read();
- $this->timestamp = $this->parseTimestamp();
- $this->exposure = $this->data['ExposureTime'] ?? null;
- $this->iso = $this->data['ISOSpeedRatings'] ?? null;
- $this->focalLength = $this->parseFocalLength();
+ $this->data = $this->read($image->root());
$this->aperture = $this->computed()['ApertureFNumber'] ?? null;
+ $this->exposure = $this->data['ExposureTime'] ?? null;
+ $this->focalLength = $this->parseFocalLength();
$this->isColor = V::accepted($this->computed()['IsColor'] ?? null);
+ $this->iso = $this->data['ISOSpeedRatings'] ?? null;
+ $this->orientation = $this->data['Orientation'] ?? 1;
+ $this->timestamp = $this->parseTimestamp();
}
/**
@@ -124,7 +126,7 @@ class Exif
/**
* Read the exif data of the image object if possible
*/
- protected function read(): array
+ public static function read(string $root): array
{
// @codeCoverageIgnoreStart
if (function_exists('exif_read_data') === false) {
@@ -132,7 +134,7 @@ class Exif
}
// @codeCoverageIgnoreEnd
- $data = @exif_read_data($this->image->root());
+ $data = @exif_read_data($root);
return is_array($data) ? $data : [];
}
@@ -144,6 +146,14 @@ class Exif
return $this->data['COMPUTED'] ?? [];
}
+ /**
+ * Returns the exif orientation
+ */
+ public function orientation(): int
+ {
+ return $this->orientation;
+ }
+
/**
* Return the timestamp when the picture has been taken
*/
@@ -192,9 +202,10 @@ class Exif
*/
public function __debugInfo(): array
{
- return array_merge($this->toArray(), [
+ return [
+ ...$this->toArray(),
'camera' => $this->camera(),
'location' => $this->location()
- ]);
+ ];
}
}
diff --git a/public/kirby/src/Image/Image.php b/public/kirby/src/Image/Image.php
index f012a1e..6a24082 100644
--- a/public/kirby/src/Image/Image.php
+++ b/public/kirby/src/Image/Image.php
@@ -84,8 +84,8 @@ class Image extends File
'image/jp2',
'image/png',
'image/webp'
- ])) {
- return $this->dimensions = Dimensions::forImage($this->root);
+ ], true)) {
+ return $this->dimensions = Dimensions::forImage($this);
}
if ($this->extension() === 'svg') {
@@ -136,7 +136,9 @@ class Image extends File
return Html::img($url, $attr);
}
- throw new LogicException('Calling Image::html() requires that the URL property is not null');
+ throw new LogicException(
+ message: 'Calling Image::html() requires that the URL property is not null'
+ );
}
/**
@@ -176,7 +178,7 @@ class Image extends File
*/
public function isResizable(): bool
{
- return in_array($this->extension(), static::$resizableTypes) === true;
+ return in_array($this->extension(), static::$resizableTypes, true) === true;
}
/**
@@ -185,7 +187,7 @@ class Image extends File
*/
public function isViewable(): bool
{
- return in_array($this->extension(), static::$viewableTypes) === true;
+ return in_array($this->extension(), static::$viewableTypes, true) === true;
}
/**
@@ -210,10 +212,11 @@ class Image extends File
*/
public function toArray(): array
{
- $array = array_merge(parent::toArray(), [
+ $array = [
+ ...parent::toArray(),
'dimensions' => $this->dimensions()->toArray(),
'exif' => $this->exif()->toArray(),
- ]);
+ ];
ksort($array);
diff --git a/public/kirby/src/Image/Location.php b/public/kirby/src/Image/Location.php
index 0eddb68..6894704 100644
--- a/public/kirby/src/Image/Location.php
+++ b/public/kirby/src/Image/Location.php
@@ -2,6 +2,8 @@
namespace Kirby\Image;
+use Stringable;
+
/**
* Returns the latitude and longitude values
* for exif location data if available
@@ -12,7 +14,7 @@ namespace Kirby\Image;
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
-class Location
+class Location implements Stringable
{
protected float|null $lat = null;
protected float|null $lng = null;
@@ -62,7 +64,7 @@ class Location
*/
protected function gps(array $coord, string $hemi): float
{
- $degrees = count($coord) > 0 ? $this->num($coord[0]) : 0;
+ $degrees = $coord !== [] ? $this->num($coord[0]) : 0;
$minutes = count($coord) > 1 ? $this->num($coord[1]) : 0;
$seconds = count($coord) > 2 ? $this->num($coord[2]) : 0;
diff --git a/public/kirby/src/Image/QrCode.php b/public/kirby/src/Image/QrCode.php
index d675f41..5619ad7 100644
--- a/public/kirby/src/Image/QrCode.php
+++ b/public/kirby/src/Image/QrCode.php
@@ -7,6 +7,7 @@ use GdImage;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
+use Stringable;
/**
* Creates a QR code
@@ -47,7 +48,7 @@ use Kirby\Filesystem\F;
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*/
-class QrCode
+class QrCode implements Stringable
{
public function __construct(public string $data)
{
@@ -102,7 +103,7 @@ class QrCode
// create image baseplate
$image = imagecreatetruecolor($size, $size);
- $allocateColor = function (string $hex) use ($image) {
+ $allocateColor = static function (string $hex) use ($image) {
$hex = preg_replace('/[^0-9A-Fa-f]/', '', $hex);
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
@@ -192,7 +193,9 @@ class QrCode
'png' => imagepng($this->toImage(...$args), $file),
'svg' => F::write($file, $this->toSvg(...$args)),
'webp' => imagewebp($this->toImage(...$args), $file),
- default => throw new InvalidArgumentException('Cannot write QR code as ' . $format)
+ default => throw new InvalidArgumentException(
+ message: 'Cannot write QR code as ' . $format
+ )
};
}
@@ -445,13 +448,13 @@ class QrCode
0 => $this->encodeNumeric($data, $group),
1 => $this->encodeAlphanum($data, $group),
2 => $this->encodeBinary($data, $group),
- default => throw new LogicException('Invalid QR mode') // @codeCoverageIgnore
+ default => throw new LogicException(message: 'Invalid QR mode') // @codeCoverageIgnore
};
- $code = array_merge($code, array_fill(0, 4, 0));
+ $code = [...$code, ...array_fill(0, 4, 0)];
if ($remainder = count($code) % 8) {
- $code = array_merge($code, array_fill(0, 8 - $remainder, 0));
+ $code = [...$code, ...array_fill(0, 8 - $remainder, 0)];
}
// convert from bit level to byte level
@@ -835,7 +838,7 @@ class QrCode
5 => !(((($row * $column) % 2) + (($row * $column) % 3))),
6 => !(((($row * $column) % 2) + (($row * $column) % 3)) % 2),
7 => !(((($row + $column) % 2) + (($row * $column) % 3)) % 2),
- default => throw new LogicException('Invalid QR mask') // @codeCoverageIgnore
+ default => throw new LogicException(message: 'Invalid QR mask') // @codeCoverageIgnore
};
}
@@ -1050,213 +1053,213 @@ class QrCode
*/
protected const CAPACITY = [
[
- [ 41, 25, 17],
- [ 34, 20, 14],
- [ 27, 16, 11],
- [ 17, 10, 7]
+ [41, 25, 17],
+ [34, 20, 14],
+ [27, 16, 11],
+ [17, 10, 7]
],
[
- [ 77, 47, 32],
- [ 63, 38, 26],
- [ 48, 29, 20],
- [ 34, 20, 14]
+ [77, 47, 32],
+ [63, 38, 26],
+ [48, 29, 20],
+ [34, 20, 14]
],
[
- [ 127, 77, 53],
- [ 101, 61, 42],
- [ 77, 47, 32],
- [ 58, 35, 24]
+ [127, 77, 53],
+ [101, 61, 42],
+ [77, 47, 32],
+ [58, 35, 24]
],
[
- [ 187, 114, 78],
- [ 149, 90, 62],
- [ 111, 67, 46],
- [ 82, 50, 34]
+ [187, 114, 78],
+ [149, 90, 62],
+ [111, 67, 46],
+ [82, 50, 34]
],
[
- [ 255, 154, 106],
- [ 202, 122, 84],
- [ 144, 87, 60],
- [ 106, 64, 44]
+ [255, 154, 106],
+ [202, 122, 84],
+ [144, 87, 60],
+ [106, 64, 44]
],
[
- [ 322, 195, 134],
- [ 255, 154, 106],
- [ 178, 108, 74],
- [ 139, 84, 58]
+ [322, 195, 134],
+ [255, 154, 106],
+ [178, 108, 74],
+ [139, 84, 58]
],
[
- [ 370, 224, 154],
- [ 293, 178, 122],
- [ 207, 125, 86],
- [ 154, 93, 64]
+ [370, 224, 154],
+ [293, 178, 122],
+ [207, 125, 86],
+ [154, 93, 64]
],
[
- [ 461, 279, 192],
- [ 365, 221, 152],
- [ 259, 157, 108],
- [ 202, 122, 84]
+ [461, 279, 192],
+ [365, 221, 152],
+ [259, 157, 108],
+ [202, 122, 84]
],
[
- [ 552, 335, 230],
- [ 432, 262, 180],
- [ 312, 189, 130],
- [ 235, 143, 98]],
+ [552, 335, 230],
+ [432, 262, 180],
+ [312, 189, 130],
+ [235, 143, 98]],
[
- [ 652, 395, 271],
- [ 513, 311, 213],
- [ 364, 221, 151],
- [ 288, 174, 119]
+ [652, 395, 271],
+ [513, 311, 213],
+ [364, 221, 151],
+ [288, 174, 119]
],
[
- [ 772, 468, 321],
- [ 604, 366, 251],
- [ 427, 259, 177],
- [ 331, 200, 137]
+ [772, 468, 321],
+ [604, 366, 251],
+ [427, 259, 177],
+ [331, 200, 137]
],
[
- [ 883, 535, 367],
- [ 691, 419, 287],
- [ 489, 296, 203],
- [ 374, 227, 155]
+ [883, 535, 367],
+ [691, 419, 287],
+ [489, 296, 203],
+ [374, 227, 155]
],
[
- [1022, 619, 425],
- [ 796, 483, 331],
- [ 580, 352, 241],
- [ 427, 259, 177]
+ [1022, 619, 425],
+ [796, 483, 331],
+ [580, 352, 241],
+ [427, 259, 177]
],
[
- [1101, 667, 458],
- [ 871, 528, 362],
- [ 621, 376, 258],
- [ 468, 283, 194]
+ [1101, 667, 458],
+ [871, 528, 362],
+ [621, 376, 258],
+ [468, 283, 194]
],
[
- [1250, 758, 520],
- [ 991, 600, 412],
- [ 703, 426, 292],
- [ 530, 321, 220]
+ [1250, 758, 520],
+ [991, 600, 412],
+ [703, 426, 292],
+ [530, 321, 220]
],
[
- [1408, 854, 586],
- [1082, 656, 450],
- [ 775, 470, 322],
- [ 602, 365, 250]
+ [1408, 854, 586],
+ [1082, 656, 450],
+ [775, 470, 322],
+ [602, 365, 250]
],
[
- [1548, 938, 644],
- [1212, 734, 504],
- [ 876, 531, 364],
- [ 674, 408, 280]
+ [1548, 938, 644],
+ [1212, 734, 504],
+ [876, 531, 364],
+ [674, 408, 280]
],
[
- [1725, 1046, 718],
- [1346, 816, 560],
- [ 948, 574, 394],
- [ 746, 452, 310]
+ [1725, 1046, 718],
+ [1346, 816, 560],
+ [948, 574, 394],
+ [746, 452, 310]
],
[
- [1903, 1153, 792],
- [1500, 909, 624],
- [1063, 644, 442],
- [ 813, 493, 338]
+ [1903, 1153, 792],
+ [1500, 909, 624],
+ [1063, 644, 442],
+ [813, 493, 338]
],
[
- [2061, 1249, 858],
- [1600, 970, 666],
- [1159, 702, 482],
- [ 919, 557, 382]
+ [2061, 1249, 858],
+ [1600, 970, 666],
+ [1159, 702, 482],
+ [919, 557, 382]
],
[
- [2232, 1352, 929],
- [1708, 1035, 711],
- [1224, 742, 509],
- [ 969, 587, 403]
+ [2232, 1352, 929],
+ [1708, 1035, 711],
+ [1224, 742, 509],
+ [969, 587, 403]
],
[
[2409, 1460, 1003],
- [1872, 1134, 779],
- [1358, 823, 565],
- [1056, 640, 439]
+ [1872, 1134, 779],
+ [1358, 823, 565],
+ [1056, 640, 439]
],
[
[2620, 1588, 1091],
- [2059, 1248, 857],
- [1468, 890, 611],
- [1108, 672, 461]
+ [2059, 1248, 857],
+ [1468, 890, 611],
+ [1108, 672, 461]
],
[
[2812, 1704, 1171],
- [2188, 1326, 911],
- [1588, 963, 661],
- [1228, 744, 511]
+ [2188, 1326, 911],
+ [1588, 963, 661],
+ [1228, 744, 511]
],
[
[3057, 1853, 1273],
- [2395, 1451, 997],
- [1718, 1041, 715],
- [1286, 779, 535]
+ [2395, 1451, 997],
+ [1718, 1041, 715],
+ [1286, 779, 535]
],
[
[3283, 1990, 1367],
[2544, 1542, 1059],
- [1804, 1094, 751],
- [1425, 864, 593]
+ [1804, 1094, 751],
+ [1425, 864, 593]
],
[
[3517, 2132, 1465],
[2701, 1637, 1125],
- [1933, 1172, 805],
- [1501, 910, 625]
+ [1933, 1172, 805],
+ [1501, 910, 625]
],
[
[3669, 2223, 1528],
[2857, 1732, 1190],
- [2085, 1263, 868],
- [1581, 958, 658]
+ [2085, 1263, 868],
+ [1581, 958, 658]
],
[
[3909, 2369, 1628],
[3035, 1839, 1264],
- [2181, 1322, 908],
- [1677, 1016, 698]
+ [2181, 1322, 908],
+ [1677, 1016, 698]
],
[
[4158, 2520, 1732],
[3289, 1994, 1370],
- [2358, 1429, 982],
- [1782, 1080, 742]
+ [2358, 1429, 982],
+ [1782, 1080, 742]
],
[
[4417, 2677, 1840],
[3486, 2113, 1452],
[2473, 1499, 1030],
- [1897, 1150, 790]
+ [1897, 1150, 790]
],
[
[4686, 2840, 1952],
[3693, 2238, 1538],
[2670, 1618, 1112],
- [2022, 1226, 842]
+ [2022, 1226, 842]
],
[
[4965, 3009, 2068],
[3909, 2369, 1628],
[2805, 1700, 1168],
- [2157, 1307, 898]
+ [2157, 1307, 898]
],
[
[5253, 3183, 2188],
[4134, 2506, 1722],
[2949, 1787, 1228],
- [2301, 1394, 958]
+ [2301, 1394, 958]
],
[
[5529, 3351, 2303],
[4343, 2632, 1809],
[3081, 1867, 1283],
- [2361, 1431, 983]
+ [2361, 1431, 983]
],
[
[5836, 3537, 2431],
@@ -1303,166 +1306,166 @@ class QrCode
* );
*/
protected const EC_PARAMS = [
- [ 19, 7, 1, 19, 0, 0],
- [ 16, 10, 1, 16, 0, 0],
- [ 13, 13, 1, 13, 0, 0],
- [ 9, 17, 1, 9, 0, 0],
- [ 34, 10, 1, 34, 0, 0],
- [ 28, 16, 1, 28, 0, 0],
- [ 22, 22, 1, 22, 0, 0],
- [ 16, 28, 1, 16, 0, 0],
- [ 55, 15, 1, 55, 0, 0],
- [ 44, 26, 1, 44, 0, 0],
- [ 34, 18, 2, 17, 0, 0],
- [ 26, 22, 2, 13, 0, 0],
- [ 80, 20, 1, 80, 0, 0],
- [ 64, 18, 2, 32, 0, 0],
- [ 48, 26, 2, 24, 0, 0],
- [ 36, 16, 4, 9, 0, 0],
- [ 108, 26, 1, 108, 0, 0],
- [ 86, 24, 2, 43, 0, 0],
- [ 62, 18, 2, 15, 2, 16],
- [ 46, 22, 2, 11, 2, 12],
- [ 136, 18, 2, 68, 0, 0],
- [ 108, 16, 4, 27, 0, 0],
- [ 76, 24, 4, 19, 0, 0],
- [ 60, 28, 4, 15, 0, 0],
- [ 156, 20, 2, 78, 0, 0],
- [ 124, 18, 4, 31, 0, 0],
- [ 88, 18, 2, 14, 4, 15],
- [ 66, 26, 4, 13, 1, 14],
- [ 194, 24, 2, 97, 0, 0],
- [ 154, 22, 2, 38, 2, 39],
- [ 110, 22, 4, 18, 2, 19],
- [ 86, 26, 4, 14, 2, 15],
- [ 232, 30, 2, 116, 0, 0],
- [ 182, 22, 3, 36, 2, 37],
- [ 132, 20, 4, 16, 4, 17],
- [ 100, 24, 4, 12, 4, 13],
- [ 274, 18, 2, 68, 2, 69],
- [ 216, 26, 4, 43, 1, 44],
- [ 154, 24, 6, 19, 2, 20],
- [ 122, 28, 6, 15, 2, 16],
- [ 324, 20, 4, 81, 0, 0],
- [ 254, 30, 1, 50, 4, 51],
- [ 180, 28, 4, 22, 4, 23],
- [ 140, 24, 3, 12, 8, 13],
- [ 370, 24, 2, 92, 2, 93],
- [ 290, 22, 6, 36, 2, 37],
- [ 206, 26, 4, 20, 6, 21],
- [ 158, 28, 7, 14, 4, 15],
- [ 428, 26, 4, 107, 0, 0],
- [ 334, 22, 8, 37, 1, 38],
- [ 244, 24, 8, 20, 4, 21],
- [ 180, 22, 12, 11, 4, 12],
- [ 461, 30, 3, 115, 1, 116],
- [ 365, 24, 4, 40, 5, 41],
- [ 261, 20, 11, 16, 5, 17],
- [ 197, 24, 11, 12, 5, 13],
- [ 523, 22, 5, 87, 1, 88],
- [ 415, 24, 5, 41, 5, 42],
- [ 295, 30, 5, 24, 7, 25],
- [ 223, 24, 11, 12, 7, 13],
- [ 589, 24, 5, 98, 1, 99],
- [ 453, 28, 7, 45, 3, 46],
- [ 325, 24, 15, 19, 2, 20],
- [ 253, 30, 3, 15, 13, 16],
- [ 647, 28, 1, 107, 5, 108],
- [ 507, 28, 10, 46, 1, 47],
- [ 367, 28, 1, 22, 15, 23],
- [ 283, 28, 2, 14, 17, 15],
- [ 721, 30, 5, 120, 1, 121],
- [ 563, 26, 9, 43, 4, 44],
- [ 397, 28, 17, 22, 1, 23],
- [ 313, 28, 2, 14, 19, 15],
- [ 795, 28, 3, 113, 4, 114],
- [ 627, 26, 3, 44, 11, 45],
- [ 445, 26, 17, 21, 4, 22],
- [ 341, 26, 9, 13, 16, 14],
- [ 861, 28, 3, 107, 5, 108],
- [ 669, 26, 3, 41, 13, 42],
- [ 485, 30, 15, 24, 5, 25],
- [ 385, 28, 15, 15, 10, 16],
- [ 932, 28, 4, 116, 4, 117],
- [ 714, 26, 17, 42, 0, 0],
- [ 512, 28, 17, 22, 6, 23],
- [ 406, 30, 19, 16, 6, 17],
- [1006, 28, 2, 111, 7, 112],
- [ 782, 28, 17, 46, 0, 0],
- [ 568, 30, 7, 24, 16, 25],
- [ 442, 24, 34, 13, 0, 0],
- [1094, 30, 4, 121, 5, 122],
- [ 860, 28, 4, 47, 14, 48],
- [ 614, 30, 11, 24, 14, 25],
- [ 464, 30, 16, 15, 14, 16],
- [1174, 30, 6, 117, 4, 118],
- [ 914, 28, 6, 45, 14, 46],
- [ 664, 30, 11, 24, 16, 25],
- [ 514, 30, 30, 16, 2, 17],
- [1276, 26, 8, 106, 4, 107],
- [1000, 28, 8, 47, 13, 48],
- [ 718, 30, 7, 24, 22, 25],
- [ 538, 30, 22, 15, 13, 16],
- [1370, 28, 10, 114, 2, 115],
- [1062, 28, 19, 46, 4, 47],
- [ 754, 28, 28, 22, 6, 23],
- [ 596, 30, 33, 16, 4, 17],
- [1468, 30, 8, 122, 4, 123],
- [1128, 28, 22, 45, 3, 46],
- [ 808, 30, 8, 23, 26, 24],
- [ 628, 30, 12, 15, 28, 16],
- [1531, 30, 3, 117, 10, 118],
- [1193, 28, 3, 45, 23, 46],
- [ 871, 30, 4, 24, 31, 25],
- [ 661, 30, 11, 15, 31, 16],
- [1631, 30, 7, 116, 7, 117],
- [1267, 28, 21, 45, 7, 46],
- [ 911, 30, 1, 23, 37, 24],
- [ 701, 30, 19, 15, 26, 16],
- [1735, 30, 5, 115, 10, 116],
- [1373, 28, 19, 47, 10, 48],
- [ 985, 30, 15, 24, 25, 25],
- [ 745, 30, 23, 15, 25, 16],
- [1843, 30, 13, 115, 3, 116],
- [1455, 28, 2, 46, 29, 47],
- [1033, 30, 42, 24, 1, 25],
- [ 793, 30, 23, 15, 28, 16],
- [1955, 30, 17, 115, 0, 0],
- [1541, 28, 10, 46, 23, 47],
- [1115, 30, 10, 24, 35, 25],
- [ 845, 30, 19, 15, 35, 16],
- [2071, 30, 17, 115, 1, 116],
- [1631, 28, 14, 46, 21, 47],
- [1171, 30, 29, 24, 19, 25],
- [ 901, 30, 11, 15, 46, 16],
- [2191, 30, 13, 115, 6, 116],
- [1725, 28, 14, 46, 23, 47],
- [1231, 30, 44, 24, 7, 25],
- [ 961, 30, 59, 16, 1, 17],
- [2306, 30, 12, 121, 7, 122],
- [1812, 28, 12, 47, 26, 48],
- [1286, 30, 39, 24, 14, 25],
- [ 986, 30, 22, 15, 41, 16],
- [2434, 30, 6, 121, 14, 122],
- [1914, 28, 6, 47, 34, 48],
- [1354, 30, 46, 24, 10, 25],
- [1054, 30, 2, 15, 64, 16],
- [2566, 30, 17, 122, 4, 123],
- [1992, 28, 29, 46, 14, 47],
- [1426, 30, 49, 24, 10, 25],
- [1096, 30, 24, 15, 46, 16],
- [2702, 30, 4, 122, 18, 123],
- [2102, 28, 13, 46, 32, 47],
- [1502, 30, 48, 24, 14, 25],
- [1142, 30, 42, 15, 32, 16],
- [2812, 30, 20, 117, 4, 118],
- [2216, 28, 40, 47, 7, 48],
- [1582, 30, 43, 24, 22, 25],
- [1222, 30, 10, 15, 67, 16],
- [2956, 30, 19, 118, 6, 119],
- [2334, 28, 18, 47, 31, 48],
- [1666, 30, 34, 24, 34, 25],
- [1276, 30, 20, 15, 61, 16],
+ [19, 7, 1, 19, 0, 0],
+ [16, 10, 1, 16, 0, 0],
+ [13, 13, 1, 13, 0, 0],
+ [9, 17, 1, 9, 0, 0],
+ [34, 10, 1, 34, 0, 0],
+ [28, 16, 1, 28, 0, 0],
+ [22, 22, 1, 22, 0, 0],
+ [16, 28, 1, 16, 0, 0],
+ [55, 15, 1, 55, 0, 0],
+ [44, 26, 1, 44, 0, 0],
+ [34, 18, 2, 17, 0, 0],
+ [26, 22, 2, 13, 0, 0],
+ [80, 20, 1, 80, 0, 0],
+ [64, 18, 2, 32, 0, 0],
+ [48, 26, 2, 24, 0, 0],
+ [36, 16, 4, 9, 0, 0],
+ [108, 26, 1, 108, 0, 0],
+ [86, 24, 2, 43, 0, 0],
+ [62, 18, 2, 15, 2, 16],
+ [46, 22, 2, 11, 2, 12],
+ [136, 18, 2, 68, 0, 0],
+ [108, 16, 4, 27, 0, 0],
+ [76, 24, 4, 19, 0, 0],
+ [60, 28, 4, 15, 0, 0],
+ [156, 20, 2, 78, 0, 0],
+ [124, 18, 4, 31, 0, 0],
+ [88, 18, 2, 14, 4, 15],
+ [66, 26, 4, 13, 1, 14],
+ [194, 24, 2, 97, 0, 0],
+ [154, 22, 2, 38, 2, 39],
+ [110, 22, 4, 18, 2, 19],
+ [86, 26, 4, 14, 2, 15],
+ [232, 30, 2, 116, 0, 0],
+ [182, 22, 3, 36, 2, 37],
+ [132, 20, 4, 16, 4, 17],
+ [100, 24, 4, 12, 4, 13],
+ [274, 18, 2, 68, 2, 69],
+ [216, 26, 4, 43, 1, 44],
+ [154, 24, 6, 19, 2, 20],
+ [122, 28, 6, 15, 2, 16],
+ [324, 20, 4, 81, 0, 0],
+ [254, 30, 1, 50, 4, 51],
+ [180, 28, 4, 22, 4, 23],
+ [140, 24, 3, 12, 8, 13],
+ [370, 24, 2, 92, 2, 93],
+ [290, 22, 6, 36, 2, 37],
+ [206, 26, 4, 20, 6, 21],
+ [158, 28, 7, 14, 4, 15],
+ [428, 26, 4, 107, 0, 0],
+ [334, 22, 8, 37, 1, 38],
+ [244, 24, 8, 20, 4, 21],
+ [180, 22, 12, 11, 4, 12],
+ [461, 30, 3, 115, 1, 116],
+ [365, 24, 4, 40, 5, 41],
+ [261, 20, 11, 16, 5, 17],
+ [197, 24, 11, 12, 5, 13],
+ [523, 22, 5, 87, 1, 88],
+ [415, 24, 5, 41, 5, 42],
+ [295, 30, 5, 24, 7, 25],
+ [223, 24, 11, 12, 7, 13],
+ [589, 24, 5, 98, 1, 99],
+ [453, 28, 7, 45, 3, 46],
+ [325, 24, 15, 19, 2, 20],
+ [253, 30, 3, 15, 13, 16],
+ [647, 28, 1, 107, 5, 108],
+ [507, 28, 10, 46, 1, 47],
+ [367, 28, 1, 22, 15, 23],
+ [283, 28, 2, 14, 17, 15],
+ [721, 30, 5, 120, 1, 121],
+ [563, 26, 9, 43, 4, 44],
+ [397, 28, 17, 22, 1, 23],
+ [313, 28, 2, 14, 19, 15],
+ [795, 28, 3, 113, 4, 114],
+ [627, 26, 3, 44, 11, 45],
+ [445, 26, 17, 21, 4, 22],
+ [341, 26, 9, 13, 16, 14],
+ [861, 28, 3, 107, 5, 108],
+ [669, 26, 3, 41, 13, 42],
+ [485, 30, 15, 24, 5, 25],
+ [385, 28, 15, 15, 10, 16],
+ [932, 28, 4, 116, 4, 117],
+ [714, 26, 17, 42, 0, 0],
+ [512, 28, 17, 22, 6, 23],
+ [406, 30, 19, 16, 6, 17],
+ [1006, 28, 2, 111, 7, 112],
+ [782, 28, 17, 46, 0, 0],
+ [568, 30, 7, 24, 16, 25],
+ [442, 24, 34, 13, 0, 0],
+ [1094, 30, 4, 121, 5, 122],
+ [860, 28, 4, 47, 14, 48],
+ [614, 30, 11, 24, 14, 25],
+ [464, 30, 16, 15, 14, 16],
+ [1174, 30, 6, 117, 4, 118],
+ [914, 28, 6, 45, 14, 46],
+ [664, 30, 11, 24, 16, 25],
+ [514, 30, 30, 16, 2, 17],
+ [1276, 26, 8, 106, 4, 107],
+ [1000, 28, 8, 47, 13, 48],
+ [718, 30, 7, 24, 22, 25],
+ [538, 30, 22, 15, 13, 16],
+ [1370, 28, 10, 114, 2, 115],
+ [1062, 28, 19, 46, 4, 47],
+ [754, 28, 28, 22, 6, 23],
+ [596, 30, 33, 16, 4, 17],
+ [1468, 30, 8, 122, 4, 123],
+ [1128, 28, 22, 45, 3, 46],
+ [808, 30, 8, 23, 26, 24],
+ [628, 30, 12, 15, 28, 16],
+ [1531, 30, 3, 117, 10, 118],
+ [1193, 28, 3, 45, 23, 46],
+ [871, 30, 4, 24, 31, 25],
+ [661, 30, 11, 15, 31, 16],
+ [1631, 30, 7, 116, 7, 117],
+ [1267, 28, 21, 45, 7, 46],
+ [911, 30, 1, 23, 37, 24],
+ [701, 30, 19, 15, 26, 16],
+ [1735, 30, 5, 115, 10, 116],
+ [1373, 28, 19, 47, 10, 48],
+ [985, 30, 15, 24, 25, 25],
+ [745, 30, 23, 15, 25, 16],
+ [1843, 30, 13, 115, 3, 116],
+ [1455, 28, 2, 46, 29, 47],
+ [1033, 30, 42, 24, 1, 25],
+ [793, 30, 23, 15, 28, 16],
+ [1955, 30, 17, 115, 0, 0],
+ [1541, 28, 10, 46, 23, 47],
+ [1115, 30, 10, 24, 35, 25],
+ [845, 30, 19, 15, 35, 16],
+ [2071, 30, 17, 115, 1, 116],
+ [1631, 28, 14, 46, 21, 47],
+ [1171, 30, 29, 24, 19, 25],
+ [901, 30, 11, 15, 46, 16],
+ [2191, 30, 13, 115, 6, 116],
+ [1725, 28, 14, 46, 23, 47],
+ [1231, 30, 44, 24, 7, 25],
+ [961, 30, 59, 16, 1, 17],
+ [2306, 30, 12, 121, 7, 122],
+ [1812, 28, 12, 47, 26, 48],
+ [1286, 30, 39, 24, 14, 25],
+ [986, 30, 22, 15, 41, 16],
+ [2434, 30, 6, 121, 14, 122],
+ [1914, 28, 6, 47, 34, 48],
+ [1354, 30, 46, 24, 10, 25],
+ [1054, 30, 2, 15, 64, 16],
+ [2566, 30, 17, 122, 4, 123],
+ [1992, 28, 29, 46, 14, 47],
+ [1426, 30, 49, 24, 10, 25],
+ [1096, 30, 24, 15, 46, 16],
+ [2702, 30, 4, 122, 18, 123],
+ [2102, 28, 13, 46, 32, 47],
+ [1502, 30, 48, 24, 14, 25],
+ [1142, 30, 42, 15, 32, 16],
+ [2812, 30, 20, 117, 4, 118],
+ [2216, 28, 40, 47, 7, 48],
+ [1582, 30, 43, 24, 22, 25],
+ [1222, 30, 10, 15, 67, 16],
+ [2956, 30, 19, 118, 6, 119],
+ [2334, 28, 18, 47, 31, 48],
+ [1666, 30, 34, 24, 34, 25],
+ [1276, 30, 20, 15, 61, 16],
];
protected const EC_POLYNOMIALS = [
diff --git a/public/kirby/src/Option/Option.php b/public/kirby/src/Option/Option.php
index 7de4b42..bc5f281 100644
--- a/public/kirby/src/Option/Option.php
+++ b/public/kirby/src/Option/Option.php
@@ -2,10 +2,8 @@
namespace Kirby\Option;
-use Kirby\Blueprint\Factory;
-use Kirby\Blueprint\NodeIcon;
-use Kirby\Blueprint\NodeText;
use Kirby\Cms\ModelWithContent;
+use Kirby\Toolkit\I18n;
/**
* Option for select fields, radio fields, etc.
@@ -18,14 +16,16 @@ use Kirby\Cms\ModelWithContent;
*/
class Option
{
+ public string|array $text;
+
public function __construct(
public string|int|float|null $value,
public bool $disabled = false,
- public NodeIcon|null $icon = null,
- public NodeText|null $info = null,
- public NodeText|null $text = null
+ public string|null $icon = null,
+ public string|array|null $info = null,
+ string|array|null $text = null
) {
- $this->text ??= new NodeText(['en' => $this->value]);
+ $this->text = $text ?? ['en' => $this->value];
}
public static function factory(string|int|float|array|null $props): static
@@ -34,11 +34,25 @@ class Option
$props = ['value' => $props];
}
- $props = Factory::apply($props, [
- 'icon' => NodeIcon::class,
- 'info' => NodeText::class,
- 'text' => NodeText::class
- ]);
+ // Normalize info to be an array
+ if (isset($props['info']) === true) {
+ $props['info'] = match (true) {
+ is_array($props['info']) => $props['info'],
+ $props['info'] === null,
+ $props['info'] === false => null,
+ default => ['en' => $props['info']]
+ };
+ }
+
+ // Normalize text to be an array
+ if (isset($props['text']) === true) {
+ $props['text'] = match (true) {
+ is_array($props['text']) => $props['text'],
+ $props['text'] === null,
+ $props['text'] === false => null,
+ default => ['en' => $props['text']]
+ };
+ }
return new static(...$props);
}
@@ -53,11 +67,14 @@ class Option
*/
public function render(ModelWithContent $model): array
{
+ $info = I18n::translate($this->info, $this->info);
+ $text = I18n::translate($this->text, $this->text);
+
return [
'disabled' => $this->disabled,
- 'icon' => $this->icon?->render($model),
- 'info' => $this->info?->render($model),
- 'text' => $this->text?->render($model),
+ 'icon' => $this->icon,
+ 'info' => $info ? $model->toSafeString($info) : $info,
+ 'text' => $text ? $model->toSafeString($text) : $text,
'value' => $this->value
];
}
diff --git a/public/kirby/src/Option/Options.php b/public/kirby/src/Option/Options.php
index de16a14..871a1cc 100644
--- a/public/kirby/src/Option/Options.php
+++ b/public/kirby/src/Option/Options.php
@@ -2,8 +2,9 @@
namespace Kirby\Option;
-use Kirby\Blueprint\Collection;
+use Kirby\Cms\Collection;
use Kirby\Cms\ModelWithContent;
+use Kirby\Toolkit\A;
/**
* Collection of possible options for
@@ -14,11 +15,11 @@ use Kirby\Cms\ModelWithContent;
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
+ *
+ * @extends \Kirby\Cms\Collection<\Kirby\Option\Option>
*/
class Options extends Collection
{
- public const TYPE = Option::class;
-
public function __construct(array $objects = [])
{
foreach ($objects as $object) {
@@ -26,6 +27,18 @@ class Options extends Collection
}
}
+ /**
+ * The Kirby Collection class only shows the key to
+ * avoid huge trees when dumping, but for the options
+ * collections this is really not useful
+ *
+ * @codeCoverageIgnore
+ */
+ public function __debugInfo(): array
+ {
+ return A::map($this->data, fn ($item) => (array)$item);
+ }
+
public static function factory(array $items = []): static
{
$collection = new static();
@@ -52,6 +65,12 @@ class Options extends Collection
public function render(ModelWithContent $model): array
{
- return array_values(parent::render($model));
+ $options = [];
+
+ foreach ($this->data as $key => $option) {
+ $options[$key] = $option->render($model);
+ }
+
+ return array_values($options);
}
}
diff --git a/public/kirby/src/Option/OptionsApi.php b/public/kirby/src/Option/OptionsApi.php
index 3c83538..2470e02 100644
--- a/public/kirby/src/Option/OptionsApi.php
+++ b/public/kirby/src/Option/OptionsApi.php
@@ -115,7 +115,9 @@ class OptionsApi extends OptionsProvider
// @codeCoverageIgnoreStart
if ($data === null) {
- throw new NotFoundException('Options could not be loaded from API: ' . $model->toSafeString($this->url));
+ throw new NotFoundException(
+ message: 'Options could not be loaded from API: ' . $model->toSafeString($this->url)
+ );
}
// @codeCoverageIgnoreEnd
diff --git a/public/kirby/src/Option/OptionsQuery.php b/public/kirby/src/Option/OptionsQuery.php
index 9ffdbaa..85257aa 100644
--- a/public/kirby/src/Option/OptionsQuery.php
+++ b/public/kirby/src/Option/OptionsQuery.php
@@ -163,9 +163,11 @@ class OptionsQuery extends OptionsProvider
}
if ($result instanceof Collection === false) {
- $type = is_object($result) === true ? get_class($result) : gettype($result);
+ $type = is_object($result) === true ? $result::class : gettype($result);
- throw new InvalidArgumentException('Invalid query result data: ' . $type);
+ throw new InvalidArgumentException(
+ message: 'Invalid query result data: ' . $type
+ );
}
// create options array
diff --git a/public/kirby/src/Panel/Assets.php b/public/kirby/src/Panel/Assets.php
index 49fd44f..bb861cb 100644
--- a/public/kirby/src/Panel/Assets.php
+++ b/public/kirby/src/Panel/Assets.php
@@ -3,6 +3,7 @@
namespace Kirby\Panel;
use Kirby\Cms\App;
+use Kirby\Cms\Helpers;
use Kirby\Cms\Url;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
@@ -26,7 +27,7 @@ use Kirby\Toolkit\A;
*/
class Assets
{
- protected bool $dev;
+ protected bool $isDev;
protected App $kirby;
protected string $nonce;
protected Plugins $plugins;
@@ -42,12 +43,14 @@ class Assets
$vite = $this->kirby->roots()->panel() . '/.vite-running';
$this->vite = is_file($vite) === true;
- // get the assets from the Vite dev server in dev mode;
+ // Check if Panel is running in dev mode to
+ // get the assets from the Vite dev server;
// dev mode = explicitly enabled in the config AND Vite is running
- $dev = $this->kirby->option('panel.dev', false);
- $this->dev = $dev !== false && $this->vite === true;
+ $this->isDev =
+ $this->kirby->option('panel.dev', false) !== false &&
+ $this->vite === true;
- // get the base URL
+ // Get the base URL
$this->url = $this->url();
}
@@ -64,7 +67,7 @@ class Assets
// during dev mode we do not need to load
// the general stylesheet (as styling will be inlined)
- if ($this->dev === true) {
+ if ($this->isDev === true) {
$css['index'] = null;
}
@@ -108,19 +111,16 @@ class Assets
return [
'css' => $this->css(),
'icons' => $this->favicons(),
+ 'import-maps' => $this->importMaps(),
+ 'js' => $this->js(),
// loader for plugins' index.dev.mjs files – inlined,
// so we provide the code instead of the asset URL
'plugin-imports' => $this->plugins->read('mjs'),
- 'js' => $this->js()
];
}
/**
- * Returns array of favicon icons
- * based on config option
- *
- * @todo Deprecate `url` option in v5, use `href` option instead
- * @todo Deprecate `rel` usage as array key in v5, use `rel` option instead
+ * Returns array of favicon icons based on config option
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
@@ -161,12 +161,16 @@ class Assets
foreach ($icons as $rel => &$icon) {
// TODO: remove this backward compatibility check in v6
if (isset($icon['url']) === true) {
+ Helpers::deprecated('`panel.favicon` option: use `href` instead of `url` attribute');
+
$icon['href'] = $icon['url'];
unset($icon['url']);
}
// TODO: remove this backward compatibility check in v6
if (is_string($rel) === true && isset($icon['rel']) === false) {
+ Helpers::deprecated('`panel.favicon` option: use `rel` attribute instead of passing string as key');
+
$icon['rel'] = $rel;
}
@@ -189,7 +193,9 @@ class Assets
];
}
- throw new InvalidArgumentException('Invalid panel.favicon option');
+ throw new InvalidArgumentException(
+ message: 'Invalid panel.favicon option'
+ );
}
/**
@@ -200,28 +206,34 @@ class Assets
public function icons(): string
{
$dir = $this->kirby->root('panel') . '/';
- $dir .= $this->dev ? 'public' : 'dist';
+ $dir .= $this->isDev ? 'public' : 'dist';
$icons = F::read($dir . '/img/icons.svg');
$icons = preg_replace('//', '', $icons);
return $icons;
}
+ /**
+ * Get all import maps
+ */
+ public function importMaps(): array
+ {
+ return array_filter([
+ 'vue' => $this->vue()
+ ]);
+ }
+
/**
* Get all js files
*/
public function js(): array
{
$js = [
- 'vue' => [
- 'nonce' => $this->nonce,
- 'src' => $this->vue(),
- ],
'vendor' => [
'nonce' => $this->nonce,
'src' => $this->url . '/js/vendor.min.js',
'type' => 'module'
],
- 'pluginloader' => [
+ 'plugin-registry' => [
'nonce' => $this->nonce,
'src' => $this->url . '/js/plugins.js',
'type' => 'module'
@@ -234,27 +246,25 @@ class Assets
...A::map($this->custom('panel.js'), fn ($src) => [
'nonce' => $this->nonce,
'src' => $src,
- 'type' => 'module'
+ 'type' => 'module',
+ 'defer' => true
]),
'index' => [
- 'nonce' => $this->nonce,
- 'src' => $this->url . '/js/index.min.js',
- 'type' => 'module'
+ 'src' => $this->url . '/js/index.min.js',
+ 'type' => 'module'
],
];
- // during dev mode, add vite client and adapt
+ // During dev mode, add vite client and adapt
// path to `index.js` - vendor does not need
// to be loaded in dev mode
- if ($this->dev === true) {
- // load the non-minified index.js, remove vendor script and
- // development version of Vue
- $js['vendor']['src'] = null;
+ if ($this->isDev === true) {
+ // Load the non-minified index.js, remove vendor script
$js['index']['src'] = $this->url . '/src/index.js';
- $js['vue']['src'] = $this->vue(production: false);
+ $js['vendor'] = null;
- // add vite dev client
+ // Add vite dev client
$js['vite'] = [
'nonce' => $this->nonce,
'src' => $this->url . '/@vite/client',
@@ -262,7 +272,7 @@ class Assets
];
}
- return array_filter($js, fn ($js) => empty($js['src']) === false);
+ return array_filter($js);
}
/**
@@ -291,7 +301,9 @@ class Assets
// copy assets to the dist folder
if (Dir::copy($panelRoot, $versionRoot) !== true) {
- throw new Exception('Panel assets could not be linked');
+ throw new Exception(
+ message: 'Panel assets could not be linked'
+ );
}
return true;
@@ -303,12 +315,13 @@ class Assets
public function url(): string
{
// vite is not running, use production assets
- if ($this->dev === false) {
+ if ($this->isDev === false) {
return $this->kirby->url('media') . '/panel/' . $this->kirby->versionHash();
}
// explicitly configured base URL
$dev = $this->kirby->option('panel.dev');
+
if (is_string($dev) === true) {
return $dev;
}
@@ -326,14 +339,17 @@ class Assets
* Get the correct Vue script URL depending on dev mode
* and the enabled/disabled template compiler
*/
- public function vue(bool $production = true): string
+ public function vue(): string
{
- $script = $this->kirby->option('panel.vue.compiler', true) === true ? 'vue' : 'vue.runtime';
-
- if ($production === false) {
- return $this->url . '/node_modules/vue/dist/' . $script . '.js';
+ // During dev mode, load the dev version of Vue
+ if ($this->isDev === true) {
+ return $this->url . '/node_modules/vue/dist/vue.esm.browser.js';
}
- return $this->url . '/js/' . $script . '.min.js';
+ if ($this->kirby->option('panel.vue.compiler', true) === true) {
+ return $this->url . '/js/vue.esm.browser.min.js';
+ }
+
+ return $this->url . '/js/vue.runtime.esm.min.js';
}
}
diff --git a/public/kirby/src/Panel/ChangesDialog.php b/public/kirby/src/Panel/ChangesDialog.php
index 7053626..37a133e 100644
--- a/public/kirby/src/Panel/ChangesDialog.php
+++ b/public/kirby/src/Panel/ChangesDialog.php
@@ -2,70 +2,77 @@
namespace Kirby\Panel;
-use Kirby\Cms\App;
-use Kirby\Cms\Find;
-use Kirby\Http\Uri;
-use Kirby\Toolkit\Escape;
-use Throwable;
+use Kirby\Cms\Collection;
+use Kirby\Content\Changes;
+/**
+ * Manages the Panel dialog for content changes in
+ * pages, users and files
+ * @since 5.0.0
+ *
+ * @package Kirby Panel
+ * @author Bastian Allgeier
import DateTime from './DateTime.vue';
import { useDesignToLightStore } from '../../../stores/designToLight';
+import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { computed } from 'vue';
const { images, step, uri } = defineProps({
@@ -52,6 +50,7 @@ const { images, step, uri } = defineProps({
});
const { isDesignToLightStep } = useDesignToLightStore();
+const { allVariations } = useVirtualSampleStore();
const commentsCount = computed(() => {
let count = 0;
@@ -62,8 +61,8 @@ const commentsCount = computed(() => {
}
} else {
if (step.files?.dynamic) {
- for (const track of step.files.dynamic) {
- for (const file of track.files) {
+ for (const variation of allVariations) {
+ for (const file of variation.files) {
count += file?.comments?.length || 0;
}
}
diff --git a/src/components/project/cards/VirtualSample.vue b/src/components/project/cards/VirtualSample.vue
index 0e73a20..e46aa3c 100644
--- a/src/components/project/cards/VirtualSample.vue
+++ b/src/components/project/cards/VirtualSample.vue
@@ -9,8 +9,13 @@
diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue
index 2313fad..7594a1e 100644
--- a/src/components/project/virtual-sample/DynamicView.vue
+++ b/src/components/project/virtual-sample/DynamicView.vue
@@ -2,18 +2,15 @@