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 counts 0, 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.
Sve podstranice će također biti obrisane.", + "page.delete.confirm.title": "Napiši naslov stranice kao potvrdu ove akcije", + "page.duplicate.appendix": "Kopiraj", + "page.duplicate.files": "Kopiraj datoteke", + "page.duplicate.pages": "Kopiraj stranice", + "page.move": "Premjesti stranicu", + "page.sort": "Promijeni poziciju", + "page.status": "Status", + "page.status.draft": "Skica", + "page.status.draft.description": "Stranica je u izradi, te je vidljiva jedino prijavljenim urednicima", + "page.status.listed": "Javno", + "page.status.listed.description": "Stranica je javno dostupna", + "page.status.unlisted": "Neizlistano", + "page.status.unlisted.description": "Stranica je dostupna putem direktnog URL-a", + + "pages": "Stranice", + "pages.delete.confirm.selected": "Da li stvarno želite da obrišete odabrane stranice? Ova akcija se ne može poništiti.", + "pages.empty": "Još nema stranica...", + "pages.status.draft": "Skica", + "pages.status.listed": "Javno", + "pages.status.unlisted": "Neizlistano", + + "pagination.page": "Strana", + + "password": "Šifra", + "paste": "Zalijepi", + "paste.after": "Zalijepi nakon", + "paste.success": "{count} zalijepljeno!", + "pixel": "Piksel", + "plugin": "Plugin", + "plugins": "Plugini", + "prev": "Previous", + "preview": "Pregled", + + "publish": "Objavi", + "published": "Javno", + + "remove": "Remove", + "rename": "Preimenuj", + "renew": "Obnovi", + "replace": "Zamijeni", + "replace.with": "Zamijeni sa", + "retry": "Pokušaj ponovo", + "revert": "Revert", + "revert.confirm": "Da li stvarno želite obrisati sve nespremljene promjene?", + + "role": "Uloga", + "role.admin.description": "Administrator ima sva prava", + "role.admin.title": "Administrator", + "role.all": "Sve", + "role.empty": "Za ovu ulogu ne postoje korisnici", + "role.description.placeholder": "Bez opisa", + "role.nobody.description": "Ovo je pomoćna uloga bez ikakvih prava", + "role.nobody.title": "Niko", + + "save": "Spremi", + "saved": "Spremljeno", + "search": "Traži", + "searching": "Traženje", + "search.min": "Unesi {min} znakova za pretraživanje", + "search.all": "Prikaži svih {count} rezultata", + "search.results.none": "Nema rezultata", + + "section.invalid": "Ova sekcija je nevažeća", + "section.required": "Ova sekcija je potrebna", + + "security": "Sigurnost", + "select": "Odaberi", + "server": "Server", + "settings": "Postavke", + "show": "Prikaži", + "site.blueprint": "Stranica još uvijek nema blueprint. Možete definirati postavke u /site/blueprints/site.yml", + "size": "Veličina", + "slug": "URL nastavak", + "sort": "Sortiranje", + "sort.drag": "Prevuci za sortiranje ...", + "split": "Podijeli", + + "stats.empty": "Nema izvještaja", + "status": "Status", + + "system.info.copy": "Kopiraj info", + "system.info.copied": "Sistemske info kopirane", + "system.issues.content": "Čini se da je content folder izložen", + "system.issues.eol.kirby": "Vaša instalirana Kirby verzija je dostigla end-of-life i neće primati daljnja sigurnosna ažuriranja", + "system.issues.eol.plugin": "Vaša instalirana verzija plugina { plugin } je dostigla end-of-life i neće primati daljnja sigurnosna ažuriranja", + "system.issues.eol.php": "Vaša instalirana PHP verzija { release } je dostigla end-of-life i neće primati daljnja sigurnosna ažuriranja", + "system.issues.debug": "Debugiranje se mora isključiti u produkciji", + "system.issues.git": "Čini se da je .git folder izložen", + "system.issues.https": "Preporučujemo HTTPS za sve vaše stranice", + "system.issues.kirby": "Čini se da je kirby folder izložen", + "system.issues.local": "Stranica radi lokalno uz opuštene signurnosne provjere", + "system.issues.site": "Čini se da je site folder izložen", + "system.issues.vue.compiler": "Vue template compiler je omogućen", + "system.issues.vulnerability.kirby": "Vaša instalacija je možda pogođena slijedećim sigurnosnim propustom ({ severity } stepen): { description }", + "system.issues.vulnerability.plugin": "Vaša instalacija je možda pogođena slijedećim sigurnosnim propustom u pluginu {plugin} ({ severity } stepen): { description }", + "system.updateStatus": "Ažuriraj status", + "system.updateStatus.error": "Nije moguće provjeriti za ažuriranja", + "system.updateStatus.not-vulnerable": "Nema poznatih sigurnosnih propusta", + "system.updateStatus.security-update": "Besplatno sigurnosno ažiriranje { version } dostupno", + "system.updateStatus.security-upgrade": "Nadogradnja { version } sa sigurnosnim popravkama dostupna", + "system.updateStatus.unreleased": "Neobjavljena verzija", + "system.updateStatus.up-to-date": "Ažurirano", + "system.updateStatus.update": "Besplatno ažuriranje { version } dostupno", + "system.updateStatus.upgrade": "Nadogradnja { version } dostupna", + + "tel": "Telefon", + "tel.placeholder": "+38761222333", + "template": "Predložak", + + "theme": "Tema", + "theme.light": "Svjetla upaljena", + "theme.dark": "Svjetla ugašena", + "theme.automatic": "Uskladi sa sistemskim postavkama", + + "title": "Naslov", + "today": "Danas", + + "toolbar.button.clear": "Očisti formatiranje", + "toolbar.button.code": "Kod", + "toolbar.button.bold": "Podebljano", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Naslovi", + "toolbar.button.heading.1": "Naslov 1", + "toolbar.button.heading.2": "Naslov 2", + "toolbar.button.heading.3": "Naslov 3", + "toolbar.button.heading.4": "Naslov 4", + "toolbar.button.heading.5": "Naslov 5", + "toolbar.button.heading.6": "Naslov 6", + "toolbar.button.italic": "Kurziv", + "toolbar.button.file": "Datoteka", + "toolbar.button.file.select": "Odaberi datoteku", + "toolbar.button.file.upload": "Uploadaj datoteku", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Paragraf", + "toolbar.button.strike": "Precrtano", + "toolbar.button.sub": "Podpis", + "toolbar.button.sup": "Nadpis", + "toolbar.button.ol": "Uređena list", + "toolbar.button.underline": "Podvučeno", + "toolbar.button.ul": "Označena lista", + + "translation.author": "Faris Mujakić", + "translation.direction": "ltr", + "translation.name": "Bosanski", + "translation.locale": "bs_BA", + + "type": "Tip", + + "upload": "Uploadaj", + "upload.error.cantMove": "Uploadana datoteka se ne može premjestiti", + "upload.error.cantWrite": "Greška prilikom pisanja datoteke na disk", + "upload.error.default": "Datoteka se ne može uploadati", + "upload.error.extension": "Upload zaustavljen od strane ekstenzije", + "upload.error.formSize": "Uploadana datoteka premašuje MAX_FILE_SIZE direktivu navedenu u formi", + "upload.error.iniPostSize": "Uploadana datoteka premašuje post_max_size direktivu u php.ini", + "upload.error.iniSize": "Uploadana datoteka premašuje upload_max_filesize direktivu u php.ini", + "upload.error.noFile": "Datoteka nije uploadana", + "upload.error.noFiles": "Datoteke nisu uploadane", + "upload.error.partial": "Datoteka je djelimično uploadana", + "upload.error.tmpDir": "Nedostaje privremeni folder", + "upload.errors": "Greška", + "upload.progress": "Slanje...", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Korisnik", + "user.blueprint": "Možete definirati dodatne sekcije i polja forme za ovu ulogu korisnika u /site/blueprints/users/{role}.yml", + "user.changeEmail": "Promijeni email", + "user.changeLanguage": "Promijeni jezik", + "user.changeName": "Preimenuj ovog korisnika", + "user.changePassword": "Promijeni šifru", + "user.changePassword.current": "Trenutna šifra", + "user.changePassword.new": "Nova šifra", + "user.changePassword.new.confirm": "Potvrdi novu šifru...", + "user.changeRole": "Promijeni ulogu", + "user.changeRole.select": "Odaberi novu ulogu", + "user.create": "Dodaj novog korisnika", + "user.delete": "Obriši ovog korisnika", + "user.delete.confirm": "Da li stvarno želite obrisati
{email}?", + + "users": "Korisnici", + + "version": "Verzija", + "version.changes": "Promijenjena verzija", + "version.compare": "Uporedi verzije", + "version.current": "Trenutna verzija", + "version.latest": "Zadnja verzija", + "versionInformation": "Informacije o verziji", + + "view": "Pregled", + "view.account": "Tvoj račun", + "view.installation": "Instalacija", + "view.languages": "Jezici", + "view.resetPassword": "Resetiraj šifru", + "view.site": "Stranica", + "view.system": "Sistem", + "view.users": "Korisnici", + + "welcome": "Dobrodošli", + "year": "Godina", + "yes": "da" +} diff --git a/public/kirby/i18n/translations/ca.json b/public/kirby/i18n/translations/ca.json index 6a0a49a..bd43a3c 100644 --- a/public/kirby/i18n/translations/ca.json +++ b/public/kirby/i18n/translations/ca.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/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 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/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.
Alle subpagina's zullen ook worden verwijderd.", - "page.delete.confirm.title": "Voeg een paginatitel in om te bevestigen", + "page.delete.confirm.title": "Voer de paginatitel in om te bevestigen", "page.duplicate.appendix": "Kopiëren", "page.duplicate.files": "Kopieer bestanden", "page.duplicate.pages": "Kopieer pagina's", @@ -604,7 +609,7 @@ "page.status.unlisted.description": "Deze pagina is alleen bereikbaar via URL", "pages": "Pagina’s", - "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.delete.confirm.selected": "Weet je zeker dat je de geselecteerde pagina's wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", "pages.empty": "Nog geen pagina's", "pages.status.draft": "Concepten", "pages.status.listed": "Gepubliceerd", @@ -622,7 +627,7 @@ "prev": "Vorige", "preview": "Voorbeeld", - "publish": "Publish", + "publish": "Publiceren", "published": "Gepubliceerd", "remove": "Verwijder", @@ -644,9 +649,9 @@ "role.nobody.title": "Niemand", "save": "Opslaan", - "saved": "Saved", + "saved": "Opgeslagen", "search": "Zoeken", - "searching": "Searching", + "searching": "Zoeken", "search.min": "Voer {min} tekens in om te zoeken", "search.all": "Laat alle {count} resultaten zien", "search.results.none": "Geen resultaten", @@ -679,9 +684,9 @@ "system.issues.git": "De .git map lijkt zichtbaar te zijn", "system.issues.https": "We raden HTTPS aan voor al je sites", "system.issues.kirby": "De kirby map lijkt zichtbaar te zijn", - "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.local": "De site draait lokaal met versoepelde beveiligingscontroles", "system.issues.site": "De site map lijkt zichtbaar te zijn", - "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vue.compiler": "De Vue template compiler is ingeschakeld", "system.issues.vulnerability.kirby": "De installatie is mogelijk getroffen door de volgende kwetsbaarheid ({ severity } ernst): { description }", "system.issues.vulnerability.plugin": "De installatie is mogelijk getroffen door de volgende kwetsbaarheid in plugin { plugin } ({ severity } ernst): { description }", "system.updateStatus": "Update status", @@ -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": "Thema", + "theme.light": "Lichten aan", + "theme.dark": "Lichten uit", + "theme.automatic": "Systeemstandaard gebruiken", "title": "Titel", "today": "Vandaag", @@ -761,7 +766,7 @@ "user.changeLanguage": "Taal veranderen", "user.changeName": "Gebruiker hernoemen", "user.changePassword": "Wachtwoord wijzigen", - "user.changePassword.current": "Your current password", + "user.changePassword.current": "Je huidige wachtwoord", "user.changePassword.new": "Nieuw wachtwoord", "user.changePassword.new.confirm": "Bevestig het nieuwe wachtwoord...", "user.changeRole": "Verander rol", @@ -773,13 +778,13 @@ "users": "Gebruikers", "version": "Kirby-versie", - "version.changes": "Changed version", - "version.compare": "Compare versions", + "version.changes": "Gewijzigde versie", + "version.compare": "Vergelijk versies", "version.current": "Huidige versie", "version.latest": "Laatste versie", "versionInformation": "Versie informatie", - "view": "View", + "view": "Bekijk", "view.account": "Jouw account", "view.installation": "Installatie", "view.languages": "Talen", diff --git a/public/kirby/i18n/translations/pl.json b/public/kirby/i18n/translations/pl.json index ae5a73b..8583879 100644 --- a/public/kirby/i18n/translations/pl.json +++ b/public/kirby/i18n/translations/pl.json @@ -20,9 +20,9 @@ "coordinates": "Współrzędne", "copy": "Kopiuj", "copy.all": "Skopiuj wszystko", - "copy.success": "{count} skopiowanych!", + "copy.success": "Skopiowane", "copy.success.multiple": "{count} skopiowanych!", - "copy.url": "Copy URL", + "copy.url": "Skopiuj URL", "create": "Utwórz", "custom": "Niestandardowe", @@ -92,16 +92,16 @@ "error.cache.type.invalid": "Nieprawidłowy typ pamięci podręcznej „{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": "Ta wersja jest zablokowana i nie można jej usunąć", + "error.content.lock.move": "Ta wersja jest zablokowana i nie można jej przenieść", + "error.content.lock.publish": "Ta wersja jest już opublikowana", + "error.content.lock.replace": "Ta wersja jest zablokowana i nie można jej zastąpić", + "error.content.lock.update": "Ta wersja jest zablokowana i nie można jej zaktualizować", - "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.max.plural": "Nie można dodać więcej niż {max} elementy/-ów", + "error.entries.max.singular": "Nie można dodać więcej niż jednego elementu", + "error.entries.min.plural": "Musisz dodać co najmniej {min} elementy/-ów", + "error.entries.min.singular": "Musisz dodać co najmniej jeden element", "error.entries.supports": "\"{type}\" field type is not supported for the entries field", "error.entries.validation": "Wystąpił błąd w polu \"{field}\" w wierszu {index}", @@ -394,17 +394,17 @@ "file.sort": "Zmień pozycję", "files": "Pliki", - "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.delete.confirm.selected": "Czy na pewno chcesz usunąć wybrane pliki? Tej czynności nie można cofnąć.", "files.empty": "Nie ma jeszcze żadnych plików", "filter": "Filtr", - "form.discard": "Discard changes", + "form.discard": "Odrzuć zmiany", "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.unsaved": "Bieżące zmiany nie zostały jeszcze zapisane", + "form.preview": "Podejrzyj zmiany", + "form.preview.draft": "Podejrzyj szkic", "hide": "Ukryj", "hour": "Godzina", @@ -449,7 +449,12 @@ "language.variables.empty": "Nie ma jeszcze żadnych tłumaczeń", "language.variable.delete.confirm": "Czy na pewno chcesz usunąć zmienną przypisaną do klucza {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": "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
{filename}?", + "file.focus.placeholder": "Postavite fokusnu tačku", + "file.focus.reset": "Uklonite fokusnu tačku", + "file.focus.title": "Fokusirajte", + "file.sort": "Promena pozicije", + + "files": "Fajlovi", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Još nema fajlova", + + "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": "Sakriti", + "hour": "Čas", + "hue": "Nijansa", + "import": "Uvoz", + "info": "Info", + "insert": "Ubaci", + "insert.after": "Ubaciti posle", + "insert.before": "Ubaciti pre", + "install": "Instaliraj", + + "installation": "Instalacija", + "installation.completed": "Panel je instaliran", + "installation.disabled": "Instalater panela je podrazumevano onemogućen na javnim serverima. Molimo vas pokrenite instalater na lokalnoj mašini ili ga omogućite pomoću 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 counts 0, 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 */ class Collection extends BaseCollection { use HasMethods; /** - * Stores the parent object, which is needed - * in some collections to get the finder methods right. - * - * @var object + * @var \Kirby\Cms\Pagination|null */ - protected $parent; + protected $pagination; /** - * Magic getter function + * Creates a new Collection with the given objects * - * @param string $key - * @param mixed $arguments - * @return mixed + * @param object|null $parent Stores the parent object, + * which is needed in some collections + * to get the finder methods right */ + public function __construct( + iterable $objects = [], + protected object|null $parent = null + ) { + foreach ($objects as $object) { + $this->add($object); + } + } + public function __call(string $key, $arguments) { // collection methods @@ -50,21 +59,6 @@ class Collection extends BaseCollection } } - /** - * Creates a new Collection with the given objects - * - * @param array $objects - * @param object|null $parent - */ - public function __construct($objects = [], $parent = null) - { - $this->parent = $parent; - - foreach ($objects as $object) { - $this->add($object); - } - } - /** * Internal setter for each object in the Collection; * override from the Toolkit Collection is needed to @@ -72,7 +66,7 @@ class Collection extends BaseCollection * child classes can override it again to add validation * and custom behavior depending on the object type * - * @param object $object + * @param TValue $object */ public function __set(string $id, $object): void { @@ -84,7 +78,7 @@ class Collection extends BaseCollection * override from the Toolkit Collection is needed to * make the CMS collections case-sensitive */ - public function __unset($id) + public function __unset(string $id) { unset($this->data[$id]); } @@ -94,12 +88,13 @@ class Collection extends BaseCollection * an entire second collection to the * current collection * - * @param mixed $object + * @param static|TValue|array $object + * @return $this */ - public function add($object) + public function add($object): static { if ($object instanceof self) { - $this->data = array_merge($this->data, $object->data); + $this->data = [...$this->data, ...$object->data]; } elseif ( is_object($object) === true && method_exists($object, 'id') === true @@ -115,12 +110,15 @@ class Collection extends BaseCollection /** * Appends an element to the data array * - * @param mixed ...$args - * @param mixed $key Optional collection key, will be determined from the item if not given - * @param mixed $item - * @return \Kirby\Cms\Collection + * ```php + * $collection->append($object); + * $collection->append('key', $object); + * ``` + * + * @param string|TValue ...$args + * @return $this */ - public function append(...$args) + public function append(...$args): static { if (count($args) === 1) { // try to determine the key from the provided item @@ -140,8 +138,7 @@ class Collection extends BaseCollection /** * Find a single element by an attribute and its value * - * @param mixed $value - * @return mixed|null + * @return TValue|null */ public function findBy(string $attribute, $value) { @@ -158,28 +155,31 @@ class Collection extends BaseCollection * Groups the items by a given field or callback. Returns a collection * with an item for each group and a collection for each group. * - * @param string|Closure $field + * @param string|\Closure $field * @param bool $caseInsensitive Ignore upper/lowercase for group names - * @return \Kirby\Cms\Collection * @throws \Kirby\Exception\Exception */ - public function group($field, bool $caseInsensitive = true) - { - if (is_string($field) === true) { - $groups = new Collection([], $this->parent()); + public function group( + $field, + bool $caseInsensitive = true + ): self { + $groups = new self(parent: $this->parent()); + if (is_string($field) === true) { foreach ($this->data as $key => $item) { $value = $this->getAttribute($item, $field); // make sure that there's always a proper value to group by if (!$value) { - throw new InvalidArgumentException('Invalid grouping value for key: ' . $key); + throw new InvalidArgumentException( + message: 'Invalid grouping value for key: ' . $key + ); } $value = (string)$value; // ignore upper/lowercase for group names - if ($caseInsensitive === true) { + if ($caseInsensitive) { $value = Str::lower($value); } @@ -195,14 +195,17 @@ class Collection extends BaseCollection return $groups; } - return parent::group($field, $caseInsensitive); + // use the parent method but unwrap the Toolkit collection + // and rewrap it as a Cms\Collection instance + $groups->data = parent::group($field, $caseInsensitive)->data; + return $groups; } /** * Checks if the given object or id * is in the collection * - * @param string|object $key + * @param string|TValue $key */ public function has($key): bool { @@ -218,7 +221,7 @@ class Collection extends BaseCollection * The method will automatically detect objects * or ids and then search accordingly. * - * @param string|object $needle + * @param string|TValue $needle */ public function indexOf($needle): int|false { @@ -232,10 +235,10 @@ class Collection extends BaseCollection /** * Returns a Collection without the given element(s) * - * @param mixed ...$keys any number of keys, passed as individual arguments - * @return \Kirby\Cms\Collection + * @param string|array|object ...$keys any number of keys, + * passed as individual arguments */ - public function not(...$keys) + public function not(string|array|object ...$keys): static { $collection = $this->clone(); @@ -259,10 +262,9 @@ class Collection extends BaseCollection /** * Add pagination and return a sliced set of data. * - * @param mixed ...$arguments * @return $this|static */ - public function paginate(...$arguments) + public function paginate(...$arguments): static { $this->pagination = Pagination::for($this, ...$arguments); @@ -274,11 +276,9 @@ class Collection extends BaseCollection } /** - * Get the pagination object - * - * @return \Kirby\Cms\Pagination|null + * Get the previously added pagination object */ - public function pagination() + public function pagination(): Pagination|null { return $this->pagination; } @@ -286,7 +286,7 @@ class Collection extends BaseCollection /** * Returns the parent model */ - public function parent() + public function parent(): object|null { return $this->parent; } @@ -294,12 +294,15 @@ class Collection extends BaseCollection /** * Prepends an element to the data array * - * @param mixed ...$args - * @param mixed $key Optional collection key, will be determined from the item if not given - * @param mixed $item - * @return \Kirby\Cms\Collection + * ```php + * $collection->prepend($object); + * $collection->prepend('key', $object); + * ``` + * + * @param string|TValue ...$args + * @return $this */ - public function prepend(...$args) + public function prepend(...$args): static { if (count($args) === 1) { // try to determine the key from the provided item @@ -320,10 +323,8 @@ class Collection extends BaseCollection * Runs a combination of filter, sort, not, * offset, limit, search and paginate on the collection. * Any part of the query is optional. - * - * @return static */ - public function query(array $arguments = []) + public function query(array $arguments = []): static { $paginate = $arguments['paginate'] ?? null; $search = $arguments['search'] ?? null; @@ -333,11 +334,13 @@ class Collection extends BaseCollection $result = parent::query($arguments); if (empty($search) === false) { - if (is_array($search) === true) { - $result = $result->search($search['query'] ?? null, $search['options'] ?? []); - } else { - $result = $result->search($search); - } + $result = match (true) { + is_array($search) => $result->search( + $search['query'] ?? null, + $search['options'] ?? [] + ), + default => $result->search($search) + }; } if (empty($paginate) === false) { @@ -350,9 +353,9 @@ class Collection extends BaseCollection /** * Removes an object * - * @param mixed $key the name of the key + * @param string|TValue $key the name of the key */ - public function remove($key) + public function remove(string|object $key): static { if (is_object($key) === true) { $key = $key->id(); @@ -378,6 +381,22 @@ class Collection extends BaseCollection */ public function toArray(Closure|null $map = null): array { - return parent::toArray($map ?? fn ($object) => $object->toArray()); + return parent::toArray( + $map ?? fn ($object) => $object->toArray() + ); + } + + /** + * Updates an object in the collection + * + * @return $this + */ + public function update(string|object $key, $object = null): static + { + if (is_object($key) === true) { + return $this->update($key->id(), $key); + } + + return $this->set($key, $object); } } diff --git a/public/kirby/src/Cms/Collections.php b/public/kirby/src/Cms/Collections.php index beafe8d..ad67fbf 100644 --- a/public/kirby/src/Cms/Collections.php +++ b/public/kirby/src/Cms/Collections.php @@ -41,7 +41,7 @@ class Collections * `$collections->myCollection()` * * @return \Kirby\Toolkit\Collection|null - * @todo 5.0 Add return type declaration + * @todo 6.0 Add return type declaration */ public function __call(string $name, array $arguments = []) { @@ -52,8 +52,8 @@ class Collections * Loads a collection by name if registered * * @return \Kirby\Toolkit\Collection|null - * @todo 5.0 Add deprecation warning when anything else than a Collection is returned - * @todo 6.0 Add PHP return type declaration for `Toolkit\Collection` + * @todo 6.0 Add deprecation warning when anything else than a Collection is returned + * @todo 7.0 Add PHP return type declaration for `Toolkit\Collection` */ public function get(string $name, array $data = []) { @@ -105,10 +105,11 @@ class Collections { $kirby = App::instance(); - // first check for collection file - $file = $kirby->root('collections') . '/' . $name . '.php'; + // first check for collection file in the `collections` root + $root = $kirby->root('collections'); + $file = $root . '/' . $name . '.php'; - if (is_file($file) === true) { + if (F::exists($file, $root) === true) { $collection = F::load($file, allowOutput: false); if ($collection instanceof Closure) { @@ -119,7 +120,12 @@ class Collections // fallback to collections from plugins $collections = $kirby->extensions('collections'); - return $collections[$name] ?? - throw new NotFoundException('The collection cannot be found'); + if ($collection = $collections[$name] ?? null) { + return $collection; + } + + throw new NotFoundException( + message: 'The collection cannot be found' + ); } } diff --git a/public/kirby/src/Cms/ContentLock.php b/public/kirby/src/Cms/ContentLock.php deleted file mode 100644 index 1186a3f..0000000 --- a/public/kirby/src/Cms/ContentLock.php +++ /dev/null @@ -1,222 +0,0 @@ - - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -class ContentLock -{ - protected array $data; - - public function __construct( - protected ModelWithContent $model - ) { - $this->data = $this->kirby()->locks()->get($model); - } - - /** - * Clears the lock unconditionally - */ - protected function clearLock(): bool - { - // if no lock exists, skip - if (isset($this->data['lock']) === false) { - return true; - } - - // remove lock - unset($this->data['lock']); - - return $this->kirby()->locks()->set($this->model, $this->data); - } - - /** - * Sets lock with the current user - * - * @throws \Kirby\Exception\DuplicateException - */ - public function create(): bool - { - // check if model is already locked by another user - if ( - isset($this->data['lock']) === true && - $this->data['lock']['user'] !== $this->user()->id() - ) { - $id = ContentLocks::id($this->model); - throw new DuplicateException($id . ' is already locked'); - } - - $this->data['lock'] = [ - 'user' => $this->user()->id(), - 'time' => time() - ]; - - return $this->kirby()->locks()->set($this->model, $this->data); - } - - /** - * Returns either `false` or array with `user`, `email`, - * `time` and `unlockable` keys - */ - public function get(): array|bool - { - $data = $this->data['lock'] ?? []; - - if (empty($data) === false && $data['user'] !== $this->user()->id()) { - if ($user = $this->kirby()->user($data['user'])) { - $time = (int)($data['time']); - - return [ - 'user' => $user->id(), - 'email' => $user->email(), - 'time' => $time, - 'unlockable' => ($time + 60) <= time() - ]; - } - - // clear lock if user not found - $this->clearLock(); - } - - return false; - } - - /** - * Returns if the model is locked by another user - */ - public function isLocked(): bool - { - $lock = $this->get(); - - if ($lock !== false && $lock['user'] !== $this->user()->id()) { - return true; - } - - return false; - } - - /** - * Returns if the current user's lock has been removed by another user - */ - public function isUnlocked(): bool - { - $data = $this->data['unlock'] ?? []; - - return in_array($this->user()->id(), $data) === true; - } - - /** - * Returns the app instance - */ - protected function kirby(): App - { - return $this->model->kirby(); - } - - /** - * Removes lock of current user - * - * @throws \Kirby\Exception\LogicException - */ - public function remove(): bool - { - // if no lock exists, skip - if (isset($this->data['lock']) === false) { - return true; - } - - // check if lock was set by another user - if ($this->data['lock']['user'] !== $this->user()->id()) { - throw new LogicException([ - 'fallback' => 'The content lock can only be removed by the user who created it. Use unlock instead.', - 'httpCode' => 409 - ]); - } - - return $this->clearLock(); - } - - /** - * Removes unlock information for current user - */ - public function resolve(): bool - { - // if no unlocks exist, skip - if (isset($this->data['unlock']) === false) { - return true; - } - - // remove user from unlock array - $this->data['unlock'] = array_diff( - $this->data['unlock'], - [$this->user()->id()] - ); - - return $this->kirby()->locks()->set($this->model, $this->data); - } - - /** - * Returns the state for the - * form buttons in the frontend - */ - public function state(): string|null - { - return match (true) { - $this->isUnlocked() => 'unlock', - $this->isLocked() => 'lock', - default => null - }; - } - - /** - * Returns a usable lock array - * for the frontend - */ - public function toArray(): array - { - return [ - 'state' => $this->state(), - 'data' => $this->get() - ]; - } - - /** - * Removes current lock and adds lock user to unlock data - */ - public function unlock(): bool - { - // if no lock exists, skip - if (isset($this->data['lock']) === false) { - return true; - } - - // add lock user to unlocked data - $this->data['unlock'] ??= []; - $this->data['unlock'][] = $this->data['lock']['user']; - - return $this->clearLock(); - } - - /** - * Returns currently authenticated user; - * throws exception if none is authenticated - * - * @throws \Kirby\Exception\PermissionException - */ - protected function user(): User - { - return $this->kirby()->user() ?? - throw new AuthException('No user authenticated.'); - } -} diff --git a/public/kirby/src/Cms/ContentLocks.php b/public/kirby/src/Cms/ContentLocks.php deleted file mode 100644 index bdcc6f4..0000000 --- a/public/kirby/src/Cms/ContentLocks.php +++ /dev/null @@ -1,210 +0,0 @@ -, - * Lukas Bestle - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -class ContentLocks -{ - /** - * Data from the `.lock` files - * that have been read so far - * cached by `.lock` file path - */ - protected array $data = []; - - /** - * PHP file handles for all currently - * open `.lock` files - */ - protected array $handles = []; - - /** - * Closes the open file handles - * - * @codeCoverageIgnore - */ - public function __destruct() - { - foreach ($this->handles as $file => $handle) { - $this->closeHandle($file); - } - } - - /** - * Removes the file lock and closes the file handle - * - * @throws \Kirby\Exception\Exception - */ - protected function closeHandle(string $file): void - { - if (isset($this->handles[$file]) === false) { - return; - } - - $handle = $this->handles[$file]; - $result = flock($handle, LOCK_UN) && fclose($handle); - - if ($result !== true) { - throw new Exception('Unexpected file system error.'); // @codeCoverageIgnore - } - - unset($this->handles[$file]); - } - - /** - * Returns the path to a model's lock file - */ - public static function file(ModelWithContent $model): string - { - $root = $model::CLASS_ALIAS === 'file' ? dirname($model->root()) : $model->root(); - return $root . '/.lock'; - } - - /** - * Returns the lock/unlock data for the specified model - */ - public function get(ModelWithContent $model): array - { - $file = static::file($model); - $id = static::id($model); - - // return from cache if file was already loaded - if (isset($this->data[$file]) === true) { - return $this->data[$file][$id] ?? []; - } - - // first get a handle to ensure a file system lock - $handle = $this->handle($file); - - if (is_resource($handle) === true) { - // read data from file - clearstatcache(); - $filesize = filesize($file); - - if ($filesize > 0) { - // always read the whole file - rewind($handle); - $string = fread($handle, $filesize); - $data = Data::decode($string, 'yaml'); - } - } - - $this->data[$file] = $data ?? []; - - return $this->data[$file][$id] ?? []; - } - - /** - * Returns the file handle to a `.lock` file - * - * @param bool $create Whether to create the file if it does not exist - * @return resource|null File handle - * @throws \Kirby\Exception\Exception - */ - protected function handle(string $file, bool $create = false) - { - // check for an already open handle - if (isset($this->handles[$file]) === true) { - return $this->handles[$file]; - } - - // don't create a file if not requested - if (is_file($file) !== true && $create !== true) { - return null; - } - - $handle = @fopen($file, 'c+b'); - if (is_resource($handle) === false) { - throw new Exception('Lock file ' . $file . ' could not be opened.'); // @codeCoverageIgnore - } - - // lock the lock file exclusively to prevent changes by other threads - $result = flock($handle, LOCK_EX); - if ($result !== true) { - throw new Exception('Unexpected file system error.'); // @codeCoverageIgnore - } - - return $this->handles[$file] = $handle; - } - - /** - * Returns model ID used as the key for the data array; - * prepended with a slash because the $site otherwise won't have an ID - */ - public static function id(ModelWithContent $model): string - { - return '/' . $model->id(); - } - - /** - * Sets and writes the lock/unlock data for the specified model - * - * @throws \Kirby\Exception\Exception - */ - public function set(ModelWithContent $model, array $data): bool - { - $file = static::file($model); - $id = static::id($model); - $handle = $this->handle($file, true); - - $this->data[$file][$id] = $data; - - // make sure to unset model id entries, - // if no lock data for the model exists - foreach ($this->data[$file] as $id => $data) { - // there is no data for that model whatsoever - if ( - isset($data['lock']) === false && - (isset($data['unlock']) === false || - count($data['unlock']) === 0) - ) { - unset($this->data[$file][$id]); - - // there is empty unlock data, but still lock data - } elseif ( - isset($data['unlock']) === true && - count($data['unlock']) === 0 - ) { - unset($this->data[$file][$id]['unlock']); - } - } - - // there is no data left in the file whatsoever, delete the file - if (count($this->data[$file]) === 0) { - unset($this->data[$file]); - - // close the file handle, otherwise we can't delete it on Windows - $this->closeHandle($file); - - return F::remove($file); - } - - $yaml = Data::encode($this->data[$file], 'yaml'); - - // delete all file contents first - if (rewind($handle) !== true || ftruncate($handle, 0) !== true) { - throw new Exception('Could not write lock file ' . $file . '.'); // @codeCoverageIgnore - } - - // write the new contents - $result = fwrite($handle, $yaml); - if (is_int($result) === false || $result === 0) { - throw new Exception('Could not write lock file ' . $file . '.'); // @codeCoverageIgnore - } - - return true; - } -} diff --git a/public/kirby/src/Cms/Core.php b/public/kirby/src/Cms/Core.php index 11ca635..bd8f051 100644 --- a/public/kirby/src/Cms/Core.php +++ b/public/kirby/src/Cms/Core.php @@ -6,10 +6,16 @@ use Kirby\Cache\ApcuCache; use Kirby\Cache\FileCache; use Kirby\Cache\MemCached; use Kirby\Cache\MemoryCache; +use Kirby\Cache\RedisCache; use Kirby\Cms\Auth\EmailChallenge; use Kirby\Cms\Auth\TotpChallenge; use Kirby\Form\Field\BlocksField; +use Kirby\Form\Field\EntriesField; use Kirby\Form\Field\LayoutField; +use Kirby\Panel\Ui\FilePreviews\AudioFilePreview; +use Kirby\Panel\Ui\FilePreviews\ImageFilePreview; +use Kirby\Panel\Ui\FilePreviews\PdfFilePreview; +use Kirby\Panel\Ui\FilePreviews\VideoFilePreview; /** * The Core class lists all parts of Kirby @@ -147,6 +153,7 @@ class Core public function caches(): array { return [ + 'changes' => true, 'updates' => true, 'uuid' => true, ]; @@ -162,6 +169,7 @@ class Core 'file' => FileCache::class, 'memcached' => MemCached::class, 'memory' => MemoryCache::class, + 'redis' => RedisCache::class ]; } @@ -244,6 +252,7 @@ class Core 'color' => $this->root . '/fields/color.php', 'date' => $this->root . '/fields/date.php', 'email' => $this->root . '/fields/email.php', + 'entries' => EntriesField::class, 'files' => $this->root . '/fields/files.php', 'gap' => $this->root . '/fields/gap.php', 'headline' => $this->root . '/fields/headline.php', @@ -275,6 +284,19 @@ class Core ]; } + /** + * Returns a map of all default file preview handlers + */ + public function filePreviews(): array + { + return [ + AudioFilePreview::class, + ImageFilePreview::class, + PdfFilePreview::class, + VideoFilePreview::class, + ]; + } + /** * Returns a map of all kirbytag aliases */ @@ -316,33 +338,33 @@ class Core public function roots(): array { return $this->cache['roots'] ??= [ - 'kirby' => fn (array $roots) => dirname(__DIR__, 2), - 'i18n' => fn (array $roots) => $roots['kirby'] . '/i18n', + 'kirby' => fn (array $roots) => dirname(__DIR__, 2), + 'i18n' => fn (array $roots) => $roots['kirby'] . '/i18n', 'i18n:translations' => fn (array $roots) => $roots['i18n'] . '/translations', - 'i18n:rules' => fn (array $roots) => $roots['i18n'] . '/rules', - - 'index' => fn (array $roots) => static::$indexRoot ?? dirname(__DIR__, 3), - 'assets' => fn (array $roots) => $roots['index'] . '/assets', - 'content' => fn (array $roots) => $roots['index'] . '/content', - 'media' => fn (array $roots) => $roots['index'] . '/media', - 'panel' => fn (array $roots) => $roots['kirby'] . '/panel', - 'site' => fn (array $roots) => $roots['index'] . '/site', - 'accounts' => fn (array $roots) => $roots['site'] . '/accounts', - 'blueprints' => fn (array $roots) => $roots['site'] . '/blueprints', - 'cache' => fn (array $roots) => $roots['site'] . '/cache', - 'collections' => fn (array $roots) => $roots['site'] . '/collections', - 'commands' => fn (array $roots) => $roots['site'] . '/commands', - 'config' => fn (array $roots) => $roots['site'] . '/config', - 'controllers' => fn (array $roots) => $roots['site'] . '/controllers', - 'languages' => fn (array $roots) => $roots['site'] . '/languages', - 'license' => fn (array $roots) => $roots['config'] . '/.license', - 'logs' => fn (array $roots) => $roots['site'] . '/logs', - 'models' => fn (array $roots) => $roots['site'] . '/models', - 'plugins' => fn (array $roots) => $roots['site'] . '/plugins', - 'sessions' => fn (array $roots) => $roots['site'] . '/sessions', - 'snippets' => fn (array $roots) => $roots['site'] . '/snippets', - 'templates' => fn (array $roots) => $roots['site'] . '/templates', - 'roles' => fn (array $roots) => $roots['blueprints'] . '/users', + 'i18n:rules' => fn (array $roots) => $roots['i18n'] . '/rules', + 'index' => fn (array $roots) => static::$indexRoot ?? dirname(__DIR__, 3), + 'assets' => fn (array $roots) => $roots['index'] . '/assets', + 'content' => fn (array $roots) => $roots['index'] . '/content', + 'media' => fn (array $roots) => $roots['index'] . '/media', + 'panel' => fn (array $roots) => $roots['kirby'] . '/panel', + 'site' => fn (array $roots) => $roots['index'] . '/site', + 'accounts' => fn (array $roots) => $roots['site'] . '/accounts', + 'blueprints' => fn (array $roots) => $roots['site'] . '/blueprints', + 'cache' => fn (array $roots) => $roots['site'] . '/cache', + 'collections' => fn (array $roots) => $roots['site'] . '/collections', + 'commands' => fn (array $roots) => $roots['site'] . '/commands', + 'config' => fn (array $roots) => $roots['site'] . '/config', + 'controllers' => fn (array $roots) => $roots['site'] . '/controllers', + 'languages' => fn (array $roots) => $roots['site'] . '/languages', + 'licenses' => fn (array $roots) => $roots['site'] . '/licenses', + 'license' => fn (array $roots) => $roots['config'] . '/.license', + 'logs' => fn (array $roots) => $roots['site'] . '/logs', + 'models' => fn (array $roots) => $roots['site'] . '/models', + 'plugins' => fn (array $roots) => $roots['site'] . '/plugins', + 'sessions' => fn (array $roots) => $roots['site'] . '/sessions', + 'snippets' => fn (array $roots) => $roots['site'] . '/snippets', + 'templates' => fn (array $roots) => $roots['site'] . '/templates', + 'roles' => fn (array $roots) => $roots['blueprints'] . '/users', ]; } @@ -388,6 +410,7 @@ class Core public function sectionMixins(): array { return [ + 'batch' => $this->root . '/sections/mixins/batch.php', 'details' => $this->root . '/sections/mixins/details.php', 'empty' => $this->root . '/sections/mixins/empty.php', 'headline' => $this->root . '/sections/mixins/headline.php', diff --git a/public/kirby/src/Cms/Email.php b/public/kirby/src/Cms/Email.php index 18f06a2..d096350 100644 --- a/public/kirby/src/Cms/Email.php +++ b/public/kirby/src/Cms/Email.php @@ -27,7 +27,7 @@ class Email /** * Props for the email object; will be passed to the - * Kirby\Email\Email class + * \Kirby\Email\Email class */ protected array $props; @@ -42,8 +42,7 @@ class Email $this->options = App::instance()->option('email', []); // build a prop array based on preset and props - $preset = $this->preset($preset); - $this->props = array_merge($preset, $props); + $this->props = [...$this->preset($preset), ...$props]; // add transport settings $this->props['transport'] ??= $this->options['transport'] ?? []; @@ -79,10 +78,10 @@ class Email // preset does not exist if (isset($this->options['presets'][$preset]) !== true) { - throw new NotFoundException([ - 'key' => 'email.preset.notFound', - 'data' => ['name' => $preset] - ]); + throw new NotFoundException( + key: 'email.preset.notFound', + data: ['name' => $preset] + ); } return $this->options['presets'][$preset]; @@ -104,20 +103,20 @@ class Email $html = $this->getTemplate($this->props['template'], 'html'); $text = $this->getTemplate($this->props['template'], 'text'); - if ($html->exists()) { - $this->props['body'] = [ - 'html' => $html->render($data) - ]; + if ($html->exists() === true) { + $this->props['body'] = ['html' => $html->render($data)]; - if ($text->exists()) { + if ($text->exists() === true) { $this->props['body']['text'] = $text->render($data); } - // fallback to single email text template - } elseif ($text->exists()) { + // fallback to single email text template + } elseif ($text->exists() === true) { $this->props['body'] = $text->render($data); } else { - throw new NotFoundException('The email template "' . $this->props['template'] . '" cannot be found'); + throw new NotFoundException( + message: 'The email template "' . $this->props['template'] . '" cannot be found' + ); } } } @@ -190,7 +189,9 @@ class Email } } else { // invalid input - throw new InvalidArgumentException('Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection'); + throw new InvalidArgumentException( + message: 'Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection' + ); } } @@ -235,6 +236,11 @@ class Email */ protected function transformUserMultiple(string $prop): void { - $this->props[$prop] = $this->transformModel($prop, User::class, 'name', 'email'); + $this->props[$prop] = $this->transformModel( + $prop, + User::class, + 'name', + 'email' + ); } } diff --git a/public/kirby/src/Cms/Event.php b/public/kirby/src/Cms/Event.php index f4dd6f1..ab834b5 100644 --- a/public/kirby/src/Cms/Event.php +++ b/public/kirby/src/Cms/Event.php @@ -3,8 +3,10 @@ namespace Kirby\Cms; use Closure; +use Kirby\Content\ImmutableMemoryStorage; use Kirby\Exception\InvalidArgumentException; use Kirby\Toolkit\Controller; +use Stringable; /** * The Event object is created whenever the `$kirby->trigger()` @@ -19,14 +21,8 @@ use Kirby\Toolkit\Controller; * @copyright Bastian Allgeier * @license https://getkirby.com/license */ -class Event +class Event implements Stringable { - /** - * The full event name - * (e.g. `page.create:after`) - */ - protected string $name; - /** * The event type * (e.g. `page` in `page.create:after`) @@ -45,19 +41,16 @@ class Event */ protected string|null $state; - /** - * The event arguments - */ - protected array $arguments = []; - /** * Class constructor * - * @param string $name Full event name + * @param string $name Full event name (e.g. `page.create:after`) * @param array $arguments Associative array of named event arguments */ - public function __construct(string $name, array $arguments = []) - { + public function __construct( + protected string $name, + protected array $arguments = [] + ) { // split the event name into `$type.$action:$state` // $action and $state are optional; // if there is more than one dot, $type will be greedy @@ -130,9 +123,11 @@ class Event */ public function call(object|null $bind, Closure $hook): mixed { - // collect the list of possible hook arguments - $data = $this->arguments(); - $data['event'] = $this; + // collect the list of possible event arguments + $data = [ + ...$this->arguments(), + 'event' => $this + ]; // magically call the hook with the arguments it requested $hook = new Controller($hook); @@ -239,13 +234,43 @@ class Event /** * Updates a given argument with a new value * - * @internal + * @unstable * @throws \Kirby\Exception\InvalidArgumentException */ public function updateArgument(string $name, $value): void { if (array_key_exists($name, $this->arguments) !== true) { - throw new InvalidArgumentException('The argument ' . $name . ' does not exist'); + throw new InvalidArgumentException( + message: 'The argument ' . $name . ' does not exist' + ); + } + + // no new value has been supplied by the apply hook + if ($value === null) { + + // To support legacy model modification + // in hooks without return values, we need to + // check the state of the updated argument. + // If the argument is an instance of ModelWithContent + // and the storage is an instance of ImmutableMemoryStorage, + // we can replace the argument with its clone to achieve + // the same effect as if the hook returned the modified model. + $state = $this->arguments[$name]; + + if ($state instanceof ModelWithContent) { + $storage = $state->storage(); + + if ( + $storage instanceof ImmutableMemoryStorage && + $storage->nextModel() !== null + ) { + $this->arguments[$name] = $storage->nextModel(); + } + } + + // Otherwise, there's no need to update the argument + // if no new value is provided + return; } $this->arguments[$name] = $value; diff --git a/public/kirby/src/Cms/Events.php b/public/kirby/src/Cms/Events.php new file mode 100644 index 0000000..1cb87f7 --- /dev/null +++ b/public/kirby/src/Cms/Events.php @@ -0,0 +1,130 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +class Events +{ + protected int $level = 0; + protected array $processed = []; + + public function __construct( + protected App $app + ) { + } + + /** + * Runs the hook and applies the result to the argument + * specified by the $modify parameter. By default, the + * first argument is modified. + */ + public function apply( + string $name, + array $args = [], + string|null $modify = null + ): mixed { + // modify the first argument by default + $modify ??= array_key_first($args); + + return $this->process( + $name, + $args, + // update $modify value after each hook callback + fn ($event, $result) => $event->updateArgument($modify, $result), + // return the modified value + fn ($event) => $event->argument($modify) + ); + } + + /** + * Returns all matching hook handlers for the given event + */ + public function hooks(Event $event): array + { + // get all hooks for the event name + $name = $event->name(); + $hooks = $this->app->extensions('hooks') ?? []; + $result = $hooks[$name] ?? []; + + // get all hooks for the event name wildcards + foreach ($event->nameWildcards() as $wildcard) { + $result = [ + ...$result, + ...$hooks[$wildcard] ?? [] + ]; + } + + return $result; + } + + /** + * Runs the hook + * + * @return ($return is null ? void : mixed) + */ + protected function process( + string $name, + array $args, + Closure|null $afterEach = null, + Closure|null $return = null + ) { + // create the event object and get all hook callbacks for this event + $event = new Event($name, $args); + $hooks = $this->hooks($event); + + $this->level++; + + foreach ($hooks as $hook) { + // skip hooks that have already been processed + if (in_array($hook, $this->processed[$name] ?? []) === true) { + continue; + } + + // mark the hook as processed, to avoid endless loops + $this->processed[$name][] = $hook; + + // bind the Kirby instance to the hook and run it + $result = $event->call($this->app, $hook); + + // run the afterEach callback + if ($afterEach !== null) { + $afterEach($event, $result); + } + } + + $this->level--; + + // reset the protection after the last nesting level has been closed + if ($this->level === 0) { + $this->processed = []; + } + + // run the return callback + if ($return !== null) { + return $return($event); + } + } + + /** + * Runs the hook without modifying the arguments + */ + public function trigger( + string $name, + array $args = [] + ): void { + $this->process($name, $args); + } +} diff --git a/public/kirby/src/Cms/Fieldset.php b/public/kirby/src/Cms/Fieldset.php index 20338f7..bd1a469 100644 --- a/public/kirby/src/Cms/Fieldset.php +++ b/public/kirby/src/Cms/Fieldset.php @@ -16,6 +16,8 @@ use Kirby\Toolkit\Str; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Fieldsets> */ class Fieldset extends Item { @@ -40,7 +42,9 @@ class Fieldset extends Item public function __construct(array $params = []) { if (empty($params['type']) === true) { - throw new InvalidArgumentException('The fieldset type is missing'); + throw new InvalidArgumentException( + message: 'The fieldset type is missing' + ); } $this->type = $params['id'] = $params['type']; @@ -73,10 +77,10 @@ class Fieldset extends Item protected function createFields(array $fields = []): array { $fields = Blueprint::fieldsProps($fields); - $fields = $this->form($fields)->fields()->toArray(); + $fields = $this->form($fields)->fields()->toProps(); // collect all fields - $this->fields = array_merge($this->fields, $fields); + $this->fields = [...$this->fields, ...$fields]; return $fields; } @@ -136,7 +140,7 @@ class Fieldset extends Item return false; } - if (count($this->fields) === 0) { + if ($this->fields === []) { return false; } @@ -153,12 +157,17 @@ class Fieldset extends Item */ public function form(array $fields, array $input = []): Form { - return new Form([ - 'fields' => $fields, - 'model' => $this->parent, - 'strict' => true, - 'values' => $input, - ]); + $form = new Form( + fields: $fields, + model: $this->parent, + ); + + $form->fill( + input: $input, + passthrough: false + ); + + return $form; } public function icon(): string|null diff --git a/public/kirby/src/Cms/Fieldsets.php b/public/kirby/src/Cms/Fieldsets.php index 4f5f5ce..397ca41 100644 --- a/public/kirby/src/Cms/Fieldsets.php +++ b/public/kirby/src/Cms/Fieldsets.php @@ -16,6 +16,8 @@ use Kirby\Toolkit\Str; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\Fieldset> */ class Fieldsets extends Items { @@ -53,7 +55,7 @@ class Fieldsets extends Items // extract groups if ($fieldset['type'] === 'group') { $result = static::createFieldsets($fieldset['fieldsets'] ?? []); - $fieldsets = array_merge($fieldsets, $result['fieldsets']); + $fieldsets = [...$fieldsets, ...$result['fieldsets']]; $label = $fieldset['label'] ?? Str::ucfirst($type); $groups[$type] = [ diff --git a/public/kirby/src/Cms/File.php b/public/kirby/src/Cms/File.php index aa0d7d3..ebb9969 100644 --- a/public/kirby/src/Cms/File.php +++ b/public/kirby/src/Cms/File.php @@ -29,6 +29,8 @@ use Kirby\Toolkit\Str; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Files> */ class File extends ModelWithContent { @@ -77,10 +79,10 @@ class File extends ModelWithContent */ public function __construct(array $props) { - parent::__construct($props); - if (isset($props['filename'], $props['parent']) === false) { - throw new InvalidArgumentException('The filename and parent are required'); + throw new InvalidArgumentException( + message: 'The filename and parent are required' + ); } $this->filename = $props['filename']; @@ -91,7 +93,14 @@ class File extends ModelWithContent $this->root = null; $this->url = $props['url'] ?? null; + // Set blueprint before setting content + // or translations in the parent constructor. + // Otherwise, the blueprint definition cannot be + // used when creating the right field values + // for the content. $this->setBlueprint($props['blueprint'] ?? null); + + parent::__construct($props); } /** @@ -124,10 +133,11 @@ class File extends ModelWithContent */ public function __debugInfo(): array { - return array_merge($this->toArray(), [ + return [ + ...$this->toArray(), 'content' => $this->content(), 'siblings' => $this->siblings(), - ]); + ]; } /** @@ -223,54 +233,38 @@ class File extends ModelWithContent /** * Store the template in addition to the * other content. - * @internal + * @unstable */ public function contentFileData( array $data, string|null $languageCode = null ): array { + $language = Language::ensure($languageCode); + // only add the template in, if the $data array - // doesn't explicitly unsets it - if ( - array_key_exists('template', $data) === false && - $template = $this->template() - ) { + // doesn't explicitly unset it and it was already + // set in the content before + if (array_key_exists('template', $data) === false && $template = $this->template()) { $data['template'] = $template; } + // don't store the template field for the default template + if (($data['template'] ?? null) === 'default') { + unset($data['template']); + } + + // only keep the template and sort fields in the + // default language + if ($language->isDefault() === false) { + unset($data['template'], $data['sort']); + return $data; + } + return $data; } - /** - * Returns the directory in which - * the content file is located - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileDirectory(): string - { - Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return dirname($this->root()); - } - - /** - * Filename for the content file - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileName(): string - { - Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return $this->filename(); - } - /** * Constructs a File object - * @internal */ public static function factory(array $props): static { @@ -298,10 +292,10 @@ class File extends ModelWithContent */ public function html(array $attr = []): string { - return $this->asset()->html(array_merge( - ['alt' => $this->alt()], - $attr - )); + return $this->asset()->html([ + 'alt' => $this->alt(), + ...$attr + ]); } /** @@ -328,31 +322,26 @@ class File extends ModelWithContent } /** - * Checks if the files is accessible. - * This permission depends on the `read` option until v5 + * Checks if the file is accessible to the current user + * This permission depends on the `read` option until v6 */ public function isAccessible(): bool { - // TODO: remove this check when `read` option deprecated in v5 + // TODO: remove this check when `read` option deprecated in v6 if ($this->isReadable() === false) { return false; } - static $accessible = []; - $role = $this->kirby()->user()?->role()->id() ?? '__none__'; - $template = $this->template() ?? '__none__'; - $accessible[$role] ??= []; - - return $accessible[$role][$template] ??= $this->permissions()->can('access'); + return FilePermissions::canFromCache($this, 'access'); } /** * Check if the file can be listable by the current user - * This permission depends on the `read` option until v5 + * This permission depends on the `read` option until v6 */ public function isListable(): bool { - // TODO: remove this check when `read` option deprecated in v5 + // TODO: remove this check when `read` option deprecated in v6 if ($this->isReadable() === false) { return false; } @@ -362,32 +351,36 @@ class File extends ModelWithContent return false; } - static $listable = []; - $role = $this->kirby()->user()?->role()->id() ?? '__none__'; - $template = $this->template() ?? '__none__'; - $listable[$role] ??= []; - - return $listable[$role][$template] ??= $this->permissions()->can('list'); + return FilePermissions::canFromCache($this, 'list'); } /** * Check if the file can be read by the current user * - * @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options. + * @todo Deprecate `read` option in v6 and make the necessary changes for `access` and `list` options. */ public function isReadable(): bool { static $readable = []; - $role = $this->kirby()->user()?->role()->id() ?? '__none__'; + $role = $this->kirby()->role()?->id() ?? '__none__'; $template = $this->template() ?? '__none__'; $readable[$role] ??= []; return $readable[$role][$template] ??= $this->permissions()->can('read'); } + /** + * Returns the absolute path to the media folder + * for the file and its versions + * @since 5.0.0 + */ + public function mediaDir(): string + { + return $this->parent()->mediaDir() . '/' . $this->mediaHash(); + } + /** * Creates a unique media hash - * @internal */ public function mediaHash(): string { @@ -396,16 +389,18 @@ class File extends ModelWithContent /** * Returns the absolute path to the file in the public media folder - * @internal + * + * @param string|null $filename Optional override for the filename */ - public function mediaRoot(): string + public function mediaRoot(string|null $filename = null): string { - return $this->parent()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->filename(); + $filename ??= $this->filename(); + + return $this->mediaDir() . '/' . $filename; } /** * Creates a non-guessable token string for this file - * @internal */ public function mediaToken(): string { @@ -415,11 +410,15 @@ class File extends ModelWithContent /** * Returns the absolute Url to the file in the public media folder - * @internal + * + * @param string|null $filename Optional override for the filename */ - public function mediaUrl(): string + public function mediaUrl(string|null $filename = null): string { - return $this->parent()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->filename(); + $url = $this->parent()->mediaUrl() . '/' . $this->mediaHash(); + $filename ??= $this->filename(); + + return $url . '/' . $filename; } /** @@ -445,7 +444,7 @@ class File extends ModelWithContent */ protected function modifiedContent(string|null $languageCode = null): int { - return $this->storage()->modified('published', $languageCode) ?? 0; + return $this->version('latest')->modified($languageCode ?? 'current') ?? 0; } /** @@ -487,7 +486,6 @@ class File extends ModelWithContent /** * Returns the parent id if a parent exists - * @internal */ public function parentId(): string { @@ -560,7 +558,6 @@ class File extends ModelWithContent /** * Returns the parent Files collection - * @internal */ protected function siblingsCollection(): Files { @@ -602,10 +599,12 @@ class File extends ModelWithContent */ public function toArray(): array { - return array_merge(parent::toArray(), $this->asset()->toArray(), [ + return [ + ...parent::toArray(), + ...$this->asset()->toArray(), 'id' => $this->id(), 'template' => $this->template(), - ]); + ]; } /** @@ -617,12 +616,20 @@ class File extends ModelWithContent } /** - * Simplified File URL that uses the parent - * Page URL and the filename as a more stable - * alternative for the media URLs. + * Clean file URL that uses the parent page URL + * and the filename as a more stable alternative + * for the media URLs if available. The `content.fileRedirects` + * option is used to disable this behavior or enable it + * on a per-file basis. */ public function previewUrl(): string|null { + // check if the clean file URL is accessible, + // otherwise we need to fall back to the media URL + if ($this->kirby()->resolveFile($this) === null) { + return $this->url(); + } + $parent = $this->parent(); $url = Url::to($this->id()); @@ -651,6 +658,7 @@ class File extends ModelWithContent return $url; case 'user': + // there are no clean URL routes for user files return $this->url(); default: return $url; diff --git a/public/kirby/src/Cms/FileActions.php b/public/kirby/src/Cms/FileActions.php index 9c5d2f8..839da7b 100644 --- a/public/kirby/src/Cms/FileActions.php +++ b/public/kirby/src/Cms/FileActions.php @@ -3,10 +3,12 @@ namespace Kirby\Cms; use Closure; +use Kirby\Content\ImmutableMemoryStorage; +use Kirby\Content\MemoryStorage; +use Kirby\Content\VersionCache; use Kirby\Exception\InvalidArgumentException; use Kirby\Exception\LogicException; use Kirby\Filesystem\F; -use Kirby\Form\Form; use Kirby\Uuid\Uuid; use Kirby\Uuid\Uuids; @@ -25,13 +27,6 @@ trait FileActions File $file, string|null $extension = null ): File { - if ( - $extension === null || - $extension === $file->extension() - ) { - return $file; - } - return $file->changeName($file->name(), false, $extension); } @@ -76,18 +71,20 @@ trait FileActions } if ($newFile->exists() === true) { - throw new LogicException('The new file exists and cannot be overwritten'); + throw new LogicException( + message: 'The new file exists and cannot be overwritten' + ); } // rename the main file F::move($oldFile->root(), $newFile->root()); + // hard reset for the version cache + // to avoid broken/overlapping file references + VersionCache::reset(); + // move the content storage versions - foreach ($oldFile->storage()->all() as $version => $lang) { - $content = $oldFile->storage()->read($version, $lang); - $oldFile->storage()->delete($version, $lang); - $newFile->storage()->create($version, $lang, $content); - } + $oldFile->storage()->moveAll(to: $newFile->storage()); // update collections $newFile->parent()->files()->remove($oldFile->id()); @@ -107,10 +104,23 @@ trait FileActions return $this; } + $arguments = [ + 'file' => $this, + 'position' => $sort + ]; + return $this->commit( 'changeSort', - ['file' => $this, 'position' => $sort], - fn ($file, $sort) => $file->save(['sort' => $sort]) + $arguments, + function ($file, $sort) { + // make sure to update the sort in the changes version as well + // otherwise the new sort would be lost as soon as the changes are saved + if ($file->version('changes')->exists() === true) { + $file->version('changes')->update(['sort' => $sort]); + } + + return $file->save(['sort' => $sort]); + } ); } @@ -132,16 +142,6 @@ trait FileActions // convert to new template/blueprint incl. content $file = $oldFile->convertTo($template); - // update template, prefer unset over writing `default` - if ($template === 'default') { - $template = null; - } - - $file = $file->update( - ['template' => $template], - 'default' - ); - // resize the file if configured by new blueprint $create = $file->blueprint()->create(); $file = $file->manipulate($create); @@ -153,10 +153,10 @@ trait FileActions /** * Commits a file action, by following these steps * - * 1. checks the action rules - * 2. sends the before hook + * 1. applies the `before` hook + * 2. checks the action rules * 3. commits the store action - * 4. sends the after hook + * 4. applies the `after` hook * 5. returns the result */ protected function commit( @@ -164,44 +164,27 @@ trait FileActions array $arguments, Closure $callback ): mixed { - $old = $this->hardcopy(); - $kirby = $this->kirby(); - $argumentValues = array_values($arguments); + $commit = new ModelCommit( + model: $this, + action: $action + ); - $this->rules()->$action(...$argumentValues); - $kirby->trigger('file.' . $action . ':before', $arguments); - - $result = $callback(...$argumentValues); - - $argumentsAfter = match ($action) { - 'create' => ['file' => $result], - 'delete' => ['status' => $result, 'file' => $old], - default => ['newFile' => $result, 'oldFile' => $old] - }; - - $kirby->trigger('file.' . $action . ':after', $argumentsAfter); - - $kirby->cache('pages')->flush(); - return $result; + return $commit->call($arguments, $callback); } /** * Copy the file to the given page - * @internal */ public function copy(Page $page): static { F::copy($this->root(), $page->root() . '/' . $this->filename()); - $copy = $page->clone()->file($this->filename()); - foreach ($this->storage()->all() as $version => $lang) { - $content = $this->storage()->read($version, $lang); - $copy->storage()->create($version, $lang, $content); - } + $copy = new static([ + 'parent' => $page, + 'filename' => $this->filename(), + ]); - // ensure the content is re-read after copying it - // @todo find a more elegant way - $copy = $page->clone()->file($this->filename()); + $this->storage()->copyAll(to: $copy->storage()); // overwrite with new UUID (remove old, add new) if (Uuids::enabled() === true) { @@ -221,51 +204,64 @@ trait FileActions * @throws \Kirby\Exception\InvalidArgumentException * @throws \Kirby\Exception\LogicException */ - public static function create(array $props, bool $move = false): File + public static function create(array $props, bool $move = false): static { - if (isset($props['source'], $props['parent']) === false) { - throw new InvalidArgumentException('Please provide the "source" and "parent" props for the File'); - } - - // prefer the filename from the props - $props['filename'] = F::safeName($props['filename'] ?? basename($props['source'])); - - $props['model'] = strtolower($props['template'] ?? 'default'); + $props = static::normalizeProps($props); // create the basic file and a test upload object - $file = static::factory($props); - $upload = $file->asset($props['source']); + $file = File::factory([ + ...$props, + 'content' => null, + 'translations' => null, + ]); - // gather content - $content = $props['content'] ?? []; + $upload = $file->assetFactory($props['source']); + $existing = null; + + // merge the content with the defaults + $props['content'] = [ + ...$file->createDefaultContent(), + ...$props['content'], + ]; + + // reuse the existing content if the uploaded file + // is identical to an existing file + if ($file->exists() === true) { + $existing = $file->parent()->file($file->filename()); + + if ( + $file->sha1() === $upload->sha1() && + $file->template() === $existing->template() + ) { + // read the content of the existing file and use it + $props['content'] = $existing->content()->toArray(); + } + } // make sure that a UUID gets generated // and added to content right away - if ( - Uuids::enabled() === true && - empty($content['uuid']) === true - ) { - // sets the current uuid if it is the exact same file - if ($file->exists() === true) { - $existing = $file->parent()->file($file->filename()); - - if ( - $file->sha1() === $upload->sha1() && - $file->template() === $existing->template() - ) { - // use existing content data if it is the exact same file - $content = $existing->content()->toArray(); - } - } - - $content['uuid'] ??= Uuid::generate(); + if (Uuids::enabled() === true) { + $props['content']['uuid'] ??= Uuid::generate(); } - // create a form for the file - $form = Form::for($file, ['values' => $content]); + // keep the initial storage class + $storage = $file->storage()::class; + + // make sure that the temporary page is stored in memory + $file->changeStorage( + toStorage: MemoryStorage::class, + // when there’s already an existing file, + // we need to make sure that the content is + // copied to memory and the existing content + // storage entry is not deleted by this step + copy: $existing !== null + ); // inject the content - $file = $file->clone(['content' => $form->strings(true)]); + $file->setContent($props['content']); + + // inject the translations + $file->setTranslations($props['translations'] ?? null); // if the format is different from the original, // we need to already rename it so that the correct file rules @@ -274,7 +270,7 @@ trait FileActions // run the hook $arguments = compact('file', 'upload'); - return $file->commit('create', $arguments, function ($file, $upload) use ($create, $move) { + return $file->commit('create', $arguments, function ($file, $upload) use ($create, $move, $storage) { // remove all public versions, lock and clear UUID cache $file->unpublish(); @@ -283,21 +279,18 @@ trait FileActions // overwrite the original if (F::$method($upload->root(), $file->root(), true) !== true) { - throw new LogicException('The file could not be created'); + // @codeCoverageIgnoreStart + throw new LogicException( + message: 'The file could not be created' + ); + // @codeCoverageIgnoreEnd } // resize the file on upload if configured $file = $file->manipulate($create); // store the content if necessary - // (always create files in the default language) - $file->save( - $file->content()->toArray(), - $file->kirby()->defaultLanguage()?->code() - ); - - // add the file to the list of siblings - $file->siblings()->append($file->id(), $file); + $file->changeStorage($storage); // return a fresh clone return $file->clone(); @@ -311,17 +304,23 @@ trait FileActions public function delete(): bool { return $this->commit('delete', ['file' => $this], function ($file) { - // remove all public versions, lock and clear UUID cache - $file->unpublish(); + $old = $file->clone(); - foreach ($file->storage()->all() as $version => $lang) { - $file->storage()->delete($version, $lang); - } + // keep the content in iummtable memory storage + // to still have access to it in after hooks + $file->changeStorage(ImmutableMemoryStorage::class); - F::remove($file->root()); + // clear UUID cache + $file->uuid()?->clear(); - // remove the file from the sibling collection - $file->parent()->files()->remove($file); + // remove all public versions and clear the UUID cache + $old->unpublish(); + + // delete all versions + $old->versions()->delete(); + + // delete the file from disk + F::remove($old->root()); return true; }); @@ -350,6 +349,31 @@ trait FileActions return $file; } + protected static function normalizeProps(array $props): array + { + if (isset($props['source'], $props['parent']) === false) { + throw new InvalidArgumentException( + message: 'Please provide the "source" and "parent" props for the File' + ); + } + + $content = $props['content'] ?? []; + $template = $props['template'] ?? 'default'; + + // prefer the filename from the props + $filename = $props['filename'] ?? null; + $filename ??= basename($props['source']); + $filename = F::safeName($props['filename']); + + return [ + ...$props, + 'content' => $content, + 'filename' => $filename, + 'model' => $props['model'] ?? $template, + 'template' => $template, + ]; + } + /** * Move the file to the public media folder * if it's not already there. @@ -390,7 +414,9 @@ trait FileActions // overwrite the original if (F::$method($upload->root(), $file->root(), true) !== true) { - throw new LogicException('The file could not be created'); + throw new LogicException( + message: 'The file could not be created' + ); } // apply the resizing/crop options from the blueprint @@ -402,23 +428,6 @@ trait FileActions }); } - /** - * Stores the content on disk - * @internal - */ - public function save( - array|null $data = null, - string|null $languageCode = null, - bool $overwrite = false - ): static { - $file = parent::save($data, $languageCode, $overwrite); - - // update model in siblings collection - $file->parent()->files()->set($file->id(), $file); - - return $file; - } - /** * Remove all public versions of this file * @@ -430,9 +439,6 @@ trait FileActions Media::unpublish($this->parent()->mediaRoot(), $this); if ($onlyMedia !== true) { - // remove the lock - $this->lock()?->remove(); - // clear UUID cache $this->uuid()?->clear(); } diff --git a/public/kirby/src/Cms/FileBlueprint.php b/public/kirby/src/Cms/FileBlueprint.php index 1cc50be..b85ef44 100644 --- a/public/kirby/src/Cms/FileBlueprint.php +++ b/public/kirby/src/Cms/FileBlueprint.php @@ -41,6 +41,7 @@ class FileBlueprint extends Blueprint 'list' => null, 'read' => null, 'replace' => null, + 'sort' => null, 'update' => null, ] ); @@ -59,7 +60,7 @@ class FileBlueprint extends Blueprint * file upload or `*` if all MIME types are allowed * * @deprecated 4.2.0 Use `acceptAttribute` instead - * @todo 5.0.0 Remove method + * @todo 6.0.0 Remove method */ public function acceptMime(): string { @@ -82,7 +83,7 @@ class FileBlueprint extends Blueprint if (is_array($accept['extension']) === true) { // determine the main MIME type for each extension $restrictions[] = array_map( - [Mime::class, 'fromExtension'], + Mime::fromExtension(...), $accept['extension'] ); } @@ -93,7 +94,7 @@ class FileBlueprint extends Blueprint foreach ($accept['type'] as $type) { if ($extensions = F::typeToExtensions($type)) { $mimes[] = array_map( - [Mime::class, 'fromExtension'], + Mime::fromExtension(...), $extensions ); } @@ -104,12 +105,11 @@ class FileBlueprint extends Blueprint } if ($restrictions !== []) { - if (count($restrictions) > 1) { - // only return the MIME types that are allowed by all restrictions - $mimes = array_intersect(...$restrictions); - } else { - $mimes = $restrictions[0]; - } + // only return the MIME types that are allowed by all restrictions + $mimes = match (count($restrictions) > 1) { + true => array_intersect(...$restrictions), + false => $restrictions[0] + }; // filter out empty MIME types and duplicates return implode(', ', array_filter(array_unique($mimes))); @@ -190,13 +190,13 @@ class FileBlueprint extends Blueprint protected function normalizeAccept(mixed $accept = null): array { $accept = match (true) { - is_string($accept) => ['mime' => $accept], + is_string($accept) => ['mime' => $accept], // explicitly no restrictions at all - $accept === true => ['mime' => null], + $accept === true => ['mime' => null], // no custom restrictions empty($accept) === true => [], // custom restrictions - default => $accept + default => $accept }; $accept = array_change_key_case($accept); @@ -225,7 +225,7 @@ class FileBlueprint extends Blueprint $this->defaultTypes = true; } - $accept = array_merge($defaults, $accept); + $accept = [...$defaults, ...$accept]; // normalize the MIME, extension and type from strings into arrays if (is_string($accept['mime']) === true) { diff --git a/public/kirby/src/Cms/FileModifications.php b/public/kirby/src/Cms/FileModifications.php index 38629ab..b07de97 100644 --- a/public/kirby/src/Cms/FileModifications.php +++ b/public/kirby/src/Cms/FileModifications.php @@ -129,7 +129,7 @@ trait FileModifications $sizes = $this->kirby()->option('thumbs.srcsets.' . $sizes, []); } - if (is_array($sizes) === false || empty($sizes) === true) { + if (is_array($sizes) === false || $sizes === []) { return null; } @@ -184,11 +184,12 @@ trait FileModifications // fallback to content file options if (($options['crop'] ?? false) === true) { - if ($this instanceof ModelWithContent === true) { - $options['crop'] = $this->focus()->value() ?? 'center'; - } else { - $options['crop'] = 'center'; - } + $options['crop'] = match (true) { + $this instanceof ModelWithContent + => $this->focus()->value() ?? 'center', + default + => 'center' + }; } // fallback to global config options @@ -206,7 +207,9 @@ trait FileModifications $result instanceof File === false && $result instanceof Asset === false ) { - throw new InvalidArgumentException('The file::version component must return a File, FileVersion or Asset object'); + throw new InvalidArgumentException( + message: 'The file::version component must return a File, FileVersion or Asset object' + ); } return $result; diff --git a/public/kirby/src/Cms/FilePermissions.php b/public/kirby/src/Cms/FilePermissions.php index 2f8b777..6c7eee8 100644 --- a/public/kirby/src/Cms/FilePermissions.php +++ b/public/kirby/src/Cms/FilePermissions.php @@ -13,7 +13,15 @@ namespace Kirby\Cms; */ class FilePermissions extends ModelPermissions { - protected string $category = 'files'; + protected const CATEGORY = 'files'; + + /** + * Used to cache once determined permissions in memory + */ + protected static function cacheKey(ModelWithContent|Language $model): string + { + return $model->template() ?? '__none__'; + } protected function canChangeTemplate(): bool { diff --git a/public/kirby/src/Cms/FilePicker.php b/public/kirby/src/Cms/FilePicker.php index 1391110..0ed91bf 100644 --- a/public/kirby/src/Cms/FilePicker.php +++ b/public/kirby/src/Cms/FilePicker.php @@ -22,10 +22,10 @@ class FilePicker extends Picker */ public function defaults(): array { - $defaults = parent::defaults(); - $defaults['text'] = '{{ file.filename }}'; - - return $defaults; + return [ + ...parent::defaults(), + 'text' => '{{ file.filename }}' + ]; } /** @@ -59,7 +59,9 @@ class FilePicker extends Picker $files instanceof User => $files->files(), $files instanceof Files => $files, - default => throw new InvalidArgumentException('Your query must return a set of files') + default => throw new InvalidArgumentException( + message: 'Your query must return a set of files' + ) }; // filter protected and hidden pages diff --git a/public/kirby/src/Cms/FileRules.php b/public/kirby/src/Cms/FileRules.php index ee4b9dd..72149f5 100644 --- a/public/kirby/src/Cms/FileRules.php +++ b/public/kirby/src/Cms/FileRules.php @@ -27,40 +27,43 @@ class FileRules * @throws \Kirby\Exception\DuplicateException If a file with this name exists * @throws \Kirby\Exception\PermissionException If the user is not allowed to rename the file */ - public static function changeName(File $file, string $name): bool + public static function changeName(File $file, string $name): void { - if ($file->permissions()->changeName() !== true) { - throw new PermissionException([ - 'key' => 'file.changeName.permission', - 'data' => ['filename' => $file->filename()] - ]); + if ($file->permissions()->can('changeName') !== true) { + throw new PermissionException( + key: 'file.changeName.permission', + data: ['filename' => $file->filename()] + ); } if (Str::length($name) === 0) { - throw new InvalidArgumentException([ - 'key' => 'file.changeName.empty' - ]); + throw new InvalidArgumentException( + key: 'file.changeName.empty' + ); } $parent = $file->parent(); $duplicate = $parent->files()->not($file)->findBy('filename', $name . '.' . $file->extension()); if ($duplicate) { - throw new DuplicateException([ - 'key' => 'file.duplicate', - 'data' => ['filename' => $duplicate->filename()] - ]); + throw new DuplicateException( + key: 'file.duplicate', + data: ['filename' => $duplicate->filename()] + ); } - - return true; } /** * Validates if the file can be sorted */ - public static function changeSort(File $file, int $sort): bool + public static function changeSort(File $file, int $sort): void { - return true; + if ($file->permissions()->can('sort') !== true) { + throw new PermissionException( + key: 'file.sort.permission', + data: ['filename' => $file->filename()] + ); + } } /** @@ -69,13 +72,13 @@ class FileRules * @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template */ - public static function changeTemplate(File $file, string $template): bool + public static function changeTemplate(File $file, string $template): void { - if ($file->permissions()->changeTemplate() !== true) { - throw new PermissionException([ - 'key' => 'file.changeTemplate.permission', - 'data' => ['id' => $file->id()] - ]); + if ($file->permissions()->can('changeTemplate') !== true) { + throw new PermissionException( + key: 'file.changeTemplate.permission', + data: ['id' => $file->id()] + ); } $blueprints = $file->blueprints(); @@ -84,19 +87,17 @@ class FileRules // option for this file if ( count($blueprints) <= 1 || - in_array($template, array_column($blueprints, 'name')) === false + in_array($template, array_column($blueprints, 'name'), true) === false ) { - throw new LogicException([ - 'key' => 'file.changeTemplate.invalid', - 'data' => [ + throw new LogicException( + key: 'file.changeTemplate.invalid', + data: [ 'id' => $file->id(), 'template' => $template, 'blueprints' => implode(', ', array_column($blueprints, 'name')) ] - ]); + ); } - - return true; } /** @@ -105,7 +106,7 @@ class FileRules * @throws \Kirby\Exception\DuplicateException If a file with the same name exists * @throws \Kirby\Exception\PermissionException If the user is not allowed to create the file */ - public static function create(File $file, BaseFile $upload): bool + public static function create(File $file, BaseFile $upload): void { // We want to ensure that we are not creating duplicate files. // If a file with the same name already exists @@ -121,28 +122,28 @@ class FileRules $file->sha1() === $upload->sha1() && $file->template() === $existing->template() ) { - return true; + return; } // otherwise throw an error for duplicate file - throw new DuplicateException([ - 'key' => 'file.duplicate', - 'data' => [ + throw new DuplicateException( + key: 'file.duplicate', + data: [ 'filename' => $file->filename() ] - ]); + ); } - if ($file->permissions()->create() !== true) { - throw new PermissionException('The file cannot be created'); + if ($file->permissions()->can('create') !== true) { + throw new PermissionException( + message: 'The file cannot be created' + ); } static::validFile($file, $upload->mime()); $upload->match($file->blueprint()->accept()); $upload->validateContents(true); - - return true; } /** @@ -150,13 +151,13 @@ class FileRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the file */ - public static function delete(File $file): bool + public static function delete(File $file): void { - if ($file->permissions()->delete() !== true) { - throw new PermissionException('The file cannot be deleted'); + if ($file->permissions()->can('delete') !== true) { + throw new PermissionException( + message: 'The file cannot be deleted' + ); } - - return true; } /** @@ -165,10 +166,12 @@ class FileRules * @throws \Kirby\Exception\PermissionException If the user is not allowed to replace the file * @throws \Kirby\Exception\InvalidArgumentException If the file type of the new file is different */ - public static function replace(File $file, BaseFile $upload): bool + public static function replace(File $file, BaseFile $upload): void { - if ($file->permissions()->replace() !== true) { - throw new PermissionException('The file cannot be replaced'); + if ($file->permissions()->can('replace') !== true) { + throw new PermissionException( + message: 'The file cannot be replaced' + ); } static::validMime($file, $upload->mime()); @@ -177,16 +180,14 @@ class FileRules (string)$upload->mime() !== (string)$file->mime() && (string)$upload->extension() !== (string)$file->extension() ) { - throw new InvalidArgumentException([ - 'key' => 'file.mime.differs', - 'data' => ['mime' => $file->mime()] - ]); + throw new InvalidArgumentException( + key: 'file.mime.differs', + data: ['mime' => $file->mime()] + ); } $upload->match($file->blueprint()->accept()); $upload->validateContents(true); - - return true; } /** @@ -194,13 +195,13 @@ class FileRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the file */ - public static function update(File $file, array $content = []): bool + public static function update(File $file, array $content = []): void { - if ($file->permissions()->update() !== true) { - throw new PermissionException('The file cannot be updated'); + if ($file->permissions()->can('update') !== true) { + throw new PermissionException( + message: 'The file cannot be updated' + ); } - - return true; } /** @@ -208,16 +209,16 @@ class FileRules * * @throws \Kirby\Exception\InvalidArgumentException If the extension is missing or forbidden */ - public static function validExtension(File $file, string $extension): bool + public static function validExtension(File $file, string $extension): void { // make it easier to compare the extension $extension = strtolower($extension); if (empty($extension) === true) { - throw new InvalidArgumentException([ - 'key' => 'file.extension.missing', - 'data' => ['filename' => $file->filename()] - ]); + throw new InvalidArgumentException( + key: 'file.extension.missing', + data: ['filename' => $file->filename()] + ); } if ( @@ -225,50 +226,45 @@ class FileRules Str::contains($extension, 'phar') !== false || Str::contains($extension, 'pht') !== false ) { - throw new InvalidArgumentException([ - 'key' => 'file.type.forbidden', - 'data' => ['type' => 'PHP'] - ]); + throw new InvalidArgumentException( + key: 'file.type.forbidden', + data: ['type' => 'PHP'] + ); } if (Str::contains($extension, 'htm') !== false) { - throw new InvalidArgumentException([ - 'key' => 'file.type.forbidden', - 'data' => ['type' => 'HTML'] - ]); + throw new InvalidArgumentException( + key: 'file.type.forbidden', + data: ['type' => 'HTML'] + ); } if (V::in($extension, ['exe', App::instance()->contentExtension()]) !== false) { - throw new InvalidArgumentException([ - 'key' => 'file.extension.forbidden', - 'data' => ['extension' => $extension] - ]); + throw new InvalidArgumentException( + key: 'file.extension.forbidden', + data: ['extension' => $extension] + ); } - - return true; } /** * Validates the extension, MIME type and filename * - * @param $mime If not passed, the MIME type is detected from the file, - * if `false`, the MIME type is not validated for performance reasons + * @param string|false|null $mime If not passed, the MIME type is detected from the file, + * if `false`, the MIME type is not validated for performance reasons * @throws \Kirby\Exception\InvalidArgumentException If the extension, MIME type or filename is missing or forbidden */ public static function validFile( File $file, string|false|null $mime = null - ): bool { - $validMime = match ($mime) { - // request to skip the MIME check for performance reasons - false => true, - default => static::validMime($file, $mime ?? $file->mime()) - }; + ): void { + // request to skip the MIME check for performance reasons + if ($mime !== false) { + static::validMime($file, $mime ?? $file->mime()); + } - return - $validMime && - static::validExtension($file, $file->extension()) && - static::validFilename($file, $file->filename()); + static::validExtension($file, $file->extension()); + static::validFilename($file, $file->filename()); } /** @@ -276,35 +272,33 @@ class FileRules * * @throws \Kirby\Exception\InvalidArgumentException If the filename is missing or forbidden */ - public static function validFilename(File $file, string $filename): bool + public static function validFilename(File $file, string $filename): void { // make it easier to compare the filename $filename = strtolower($filename); // check for missing filenames if (empty($filename)) { - throw new InvalidArgumentException([ - 'key' => 'file.name.missing' - ]); + throw new InvalidArgumentException( + key: 'file.name.missing' + ); } // Block htaccess files if (Str::startsWith($filename, '.ht')) { - throw new InvalidArgumentException([ - 'key' => 'file.type.forbidden', - 'data' => ['type' => 'Apache config'] - ]); + throw new InvalidArgumentException( + key: 'file.type.forbidden', + data: ['type' => 'Apache config'] + ); } // Block invisible files if (Str::startsWith($filename, '.')) { - throw new InvalidArgumentException([ - 'key' => 'file.type.forbidden', - 'data' => ['type' => 'invisible'] - ]); + throw new InvalidArgumentException( + key: 'file.type.forbidden', + data: ['type' => 'invisible'] + ); } - - return true; } /** @@ -312,32 +306,30 @@ class FileRules * * @throws \Kirby\Exception\InvalidArgumentException If the MIME type is missing or forbidden */ - public static function validMime(File $file, string|null $mime = null): bool + public static function validMime(File $file, string|null $mime = null): void { // make it easier to compare the mime $mime = strtolower($mime ?? ''); if (empty($mime)) { - throw new InvalidArgumentException([ - 'key' => 'file.mime.missing', - 'data' => ['filename' => $file->filename()] - ]); + throw new InvalidArgumentException( + key: 'file.mime.missing', + data: ['filename' => $file->filename()] + ); } if (Str::contains($mime, 'php')) { - throw new InvalidArgumentException([ - 'key' => 'file.type.forbidden', - 'data' => ['type' => 'PHP'] - ]); + throw new InvalidArgumentException( + key: 'file.type.forbidden', + data: ['type' => 'PHP'] + ); } if (V::in($mime, ['text/html', 'application/x-msdownload'])) { - throw new InvalidArgumentException([ - 'key' => 'file.mime.forbidden', - 'data' => ['mime' => $mime] - ]); + throw new InvalidArgumentException( + key: 'file.mime.forbidden', + data:['mime' => $mime] + ); } - - return true; } } diff --git a/public/kirby/src/Cms/FileVersion.php b/public/kirby/src/Cms/FileVersion.php index 159da71..77db1a9 100644 --- a/public/kirby/src/Cms/FileVersion.php +++ b/public/kirby/src/Cms/FileVersion.php @@ -2,6 +2,7 @@ namespace Kirby\Cms; +use Kirby\Filesystem\Asset; use Kirby\Filesystem\IsFile; /** @@ -18,7 +19,7 @@ class FileVersion use IsFile; protected array $modifications; - protected $original; + protected File|Asset $original; public function __construct(array $props) { @@ -108,10 +109,10 @@ class FileVersion */ public function toArray(): array { - $array = array_merge( - $this->asset()->toArray(), - ['modifications' => $this->modifications()] - ); + $array = [ + ...$this->asset()->toArray(), + 'modifications' => $this->modifications() + ]; ksort($array); diff --git a/public/kirby/src/Cms/Files.php b/public/kirby/src/Cms/Files.php index ed5c76a..a0abf4c 100644 --- a/public/kirby/src/Cms/Files.php +++ b/public/kirby/src/Cms/Files.php @@ -2,9 +2,12 @@ namespace Kirby\Cms; +use Kirby\Exception\Exception; use Kirby\Exception\InvalidArgumentException; +use Kirby\Exception\NotFoundException; use Kirby\Filesystem\F; use Kirby\Uuid\HasUuids; +use Throwable; /** * The `$files` object extends the general @@ -19,6 +22,8 @@ use Kirby\Uuid\HasUuids; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\File> */ class Files extends Collection { @@ -29,12 +34,17 @@ class Files extends Collection */ public static array $methods = []; + /** + * @var \Kirby\Cms\Page|\Kirby\Cms\Site|\Kirby\Cms\User + */ + protected object|null $parent = null; + /** * Adds a single file or * an entire second collection to the * current collection * - * @param \Kirby\Cms\Files|\Kirby\Cms\File|string $object + * @param static|\Kirby\Cms\File|string $object * @return $this * @throws \Kirby\Exception\InvalidArgumentException When no `File` or `Files` object or an ID of an existing file is passed */ @@ -42,23 +52,25 @@ class Files extends Collection { // add a files collection if ($object instanceof self) { - $this->data = array_merge($this->data, $object->data); + $this->data = [...$this->data, ...$object->data]; - // add a file by id + // add a file by id } elseif ( is_string($object) === true && $file = App::instance()->file($object) ) { $this->__set($file->id(), $file); - // add a file object + // add a file object } elseif ($object instanceof File) { $this->__set($object->id(), $object); - // give a useful error message on invalid input; - // silently ignore "empty" values for compatibility with existing setups + // give a useful error message on invalid input; + // silently ignore "empty" values for compatibility with existing setups } elseif (in_array($object, [null, false, true], true) !== true) { - throw new InvalidArgumentException('You must pass a Files or File object or an ID of an existing file to the Files collection'); + throw new InvalidArgumentException( + message: 'You must pass a Files or File object or an ID of an existing file to the Files collection' + ); } return $this; @@ -84,11 +96,48 @@ class Files extends Collection return $this; } + /** + * Deletes the files with the given IDs + * if they exist in the collection + * + * @throws \Kirby\Exception\Exception If not all files could be deleted + */ + public function delete(array $ids): void + { + $exceptions = []; + + // delete all pages and collect errors + foreach ($ids as $id) { + try { + $model = $this->get($id); + + if ($model instanceof File === false) { + throw new NotFoundException( + key: 'file.undefined' + ); + } + + $model->delete(); + } catch (Throwable $e) { + $exceptions[$id] = $e; + } + } + + if ($exceptions !== []) { + throw new Exception( + key: 'file.delete.multiple', + details: $exceptions + ); + } + } + /** * Creates a files collection from an array of props */ - public static function factory(array $files, Page|Site|User $parent): static - { + public static function factory( + array $files, + Page|Site|User $parent + ): static { $collection = new static([], $parent); foreach ($files as $props) { @@ -126,7 +175,7 @@ class Files extends Collection * `null` for the current locale, * `false` to disable number formatting */ - public function niceSize($locale = null): string + public function niceSize(string|false|null $locale = null): string { return F::niceSize($this->size(), $locale); } diff --git a/public/kirby/src/Cms/Find.php b/public/kirby/src/Cms/Find.php index 6059c81..046a749 100644 --- a/public/kirby/src/Cms/Find.php +++ b/public/kirby/src/Cms/Find.php @@ -38,12 +38,10 @@ class Find return $file; } - throw new NotFoundException([ - 'key' => 'file.notFound', - 'data' => [ - 'filename' => $filename - ] - ]); + throw new NotFoundException( + key: 'file.notFound', + data: ['filename' => $filename] + ); } /** @@ -58,12 +56,10 @@ class Find return $language; } - throw new NotFoundException([ - 'key' => 'language.notFound', - 'data' => [ - 'code' => $code - ] - ]); + throw new NotFoundException( + key: 'language.notFound', + data: ['code' => $code] + ); } /** @@ -83,12 +79,10 @@ class Find return $page; } - throw new NotFoundException([ - 'key' => 'page.notFound', - 'data' => [ - 'slug' => $id - ] - ]); + throw new NotFoundException( + key: 'page.notFound', + data: ['slug' => $id] + ); } /** @@ -100,8 +94,11 @@ class Find */ public static function parent(string $path): ModelWithContent { - $path = trim($path, '/'); - $modelType = in_array($path, ['site', 'account']) ? $path : trim(dirname($path), '/'); + $path = trim($path, '/'); + $modelType = match ($path) { + 'site', 'account' => $path, + default => trim(dirname($path), '/') + }; $modelTypes = [ 'site' => 'site', 'users' => 'user', @@ -126,12 +123,14 @@ class Find // and filename 'file' => static::file(...preg_split('$.*\K(/files/)$', $path)), 'user' => $kirby->user(basename($path)), - default => throw new InvalidArgumentException('Invalid model type: ' . $modelType) + default => throw new InvalidArgumentException( + message: 'Invalid model type: ' . $modelType + ) }; - return $model ?? throw new NotFoundException([ - 'key' => $modelName . '.undefined' - ]); + return $model ?? throw new NotFoundException( + key: $modelName . '.undefined' + ); } /** @@ -159,17 +158,15 @@ class Find $kirby->option('api.allowImpersonation', false) ); - return $user ?? throw new NotFoundException([ - 'key' => 'user.undefined' - ]); + return $user ?? throw new NotFoundException( + key: 'user.undefined' + ); } // get a specific user by id - return $kirby->user($id) ?? throw new NotFoundException([ - 'key' => 'user.notFound', - 'data' => [ - 'name' => $id - ] - ]); + return $kirby->user($id) ?? throw new NotFoundException( + key: 'user.notFound', + data: ['name' => $id] + ); } } diff --git a/public/kirby/src/Cms/HasFiles.php b/public/kirby/src/Cms/HasFiles.php index cc6ac65..9553db6 100644 --- a/public/kirby/src/Cms/HasFiles.php +++ b/public/kirby/src/Cms/HasFiles.php @@ -43,10 +43,11 @@ trait HasFiles */ public function createFile(array $props, bool $move = false): File { - $props = array_merge($props, [ + $props = [ + ...$props, 'parent' => $this, 'url' => null - ]); + ]; return File::create($props, $move); } @@ -75,7 +76,7 @@ trait HasFiles return Uuid::for($filename, $this->$in())->model(); } - if (strpos($filename, '/') !== false) { + if (str_contains($filename, '/') === true) { $path = dirname($filename); $filename = basename($filename); diff --git a/public/kirby/src/Cms/HasMethods.php b/public/kirby/src/Cms/HasMethods.php index 9e08d5b..282969f 100644 --- a/public/kirby/src/Cms/HasMethods.php +++ b/public/kirby/src/Cms/HasMethods.php @@ -24,24 +24,24 @@ trait HasMethods /** * Calls a registered method class with the * passed arguments - * @internal * * @throws \Kirby\Exception\BadMethodCallException */ - public function callMethod(string $method, array $args = []): mixed + protected function callMethod(string $method, array $args = []): mixed { $closure = $this->getMethod($method); if ($closure === null) { - throw new BadMethodCallException('The method ' . $method . ' does not exist'); + throw new BadMethodCallException( + message: 'The method ' . $method . ' does not exist' + ); } return $closure->call($this, ...$args); } /** - * Checks if the object has a registered method - * @internal + * Checks if the object has a registered custom method */ public function hasMethod(string $method): bool { diff --git a/public/kirby/src/Cms/HasModels.php b/public/kirby/src/Cms/HasModels.php new file mode 100644 index 0000000..8fd6dab --- /dev/null +++ b/public/kirby/src/Cms/HasModels.php @@ -0,0 +1,53 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +trait HasModels +{ + /** + * Registry with all custom models + */ + public static array $models = []; + + /** + * Adds new models to the registry + * @internal + */ + public static function extendModels(array $models): array + { + return static::$models = [ + ...static::$models, + ...array_change_key_case($models, CASE_LOWER) + ]; + } + + /** + * Creates an object from model if it has been registered + */ + public static function model(string $name, array $props = []): static + { + $name = strtolower($name); + $class = static::$models[$name] ?? null; + $class ??= static::$models['default'] ?? null; + + if ($class !== null) { + $object = new $class($props); + + if ($object instanceof self) { + return $object; + } + } + + return new static($props); + } +} diff --git a/public/kirby/src/Cms/HasSiblings.php b/public/kirby/src/Cms/HasSiblings.php index 86e63e9..7086b08 100644 --- a/public/kirby/src/Cms/HasSiblings.php +++ b/public/kirby/src/Cms/HasSiblings.php @@ -2,6 +2,8 @@ namespace Kirby\Cms; +use Kirby\Toolkit\Collection; + /** * This trait is used by pages, files and users * to handle navigation through parent collections @@ -11,28 +13,81 @@ namespace Kirby\Cms; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @template TCollection of \Kirby\Toolkit\Collection */ trait HasSiblings { + /** + * Checks if there's a next item in the collection + * + * @param TCollection|null $collection + */ + public function hasNext(Collection|null $collection = null): bool + { + return $this->next($collection) !== null; + } + + /** + * Checks if there's a previous item in the collection + * + * @param TCollection|null $collection + */ + public function hasPrev(Collection|null $collection = null): bool + { + return $this->prev($collection) !== null; + } + /** * Returns the position / index in the collection * - * @param \Kirby\Cms\Collection|null $collection + * @param TCollection|null $collection */ - public function indexOf($collection = null): int|false + public function indexOf(Collection|null $collection = null): int|false { $collection ??= $this->siblingsCollection(); return $collection->indexOf($this); } + /** + * Checks if the item is the first in the collection + * + * @param TCollection|null $collection + */ + public function isFirst(Collection|null $collection = null): bool + { + $collection ??= $this->siblingsCollection(); + return $collection->first()->is($this); + } + + /** + * Checks if the item is the last in the collection + * + * @param TCollection|null $collection + */ + public function isLast(Collection|null $collection = null): bool + { + $collection ??= $this->siblingsCollection(); + return $collection->last()->is($this); + } + + /** + * Checks if the item is at a certain position + * + * @param TCollection|null $collection + */ + public function isNth(int $n, Collection|null $collection = null): bool + { + return $this->indexOf($collection) === $n; + } + /** * Returns the next item in the collection if available * @todo `static` return type hint is not 100% accurate because of * quirks in the `Form` classes; would break if enforced * (https://github.com/getkirby/kirby/pull/5175) * - * @param \Kirby\Cms\Collection|null $collection - * + * @param TCollection|null $collection * @return static|null */ public function next($collection = null) @@ -44,11 +99,10 @@ trait HasSiblings /** * Returns the end of the collection starting after the current item * - * @param \Kirby\Cms\Collection|null $collection - * - * @return \Kirby\Cms\Collection + * @param TCollection|null $collection + * @return TCollection */ - public function nextAll($collection = null) + public function nextAll(Collection|null $collection = null): Collection { $collection ??= $this->siblingsCollection(); return $collection->slice($this->indexOf($collection) + 1); @@ -60,11 +114,10 @@ trait HasSiblings * quirks in the `Form` classes; would break if enforced * (https://github.com/getkirby/kirby/pull/5175) * - * @param \Kirby\Cms\Collection|null $collection - * + * @param TCollection|null $collection * @return static|null */ - public function prev($collection = null) + public function prev(Collection|null $collection = null) { $collection ??= $this->siblingsCollection(); return $collection->nth($this->indexOf($collection) - 1); @@ -73,11 +126,10 @@ trait HasSiblings /** * Returns the beginning of the collection before the current item * - * @param \Kirby\Cms\Collection|null $collection - * - * @return \Kirby\Cms\Collection + * @param TCollection|null $collection + * @return TCollection */ - public function prevAll($collection = null) + public function prevAll(Collection|null $collection = null): Collection { $collection ??= $this->siblingsCollection(); return $collection->slice(0, $this->indexOf($collection)); @@ -86,9 +138,9 @@ trait HasSiblings /** * Returns all sibling elements * - * @return \Kirby\Cms\Collection + * @return TCollection */ - public function siblings(bool $self = true) + public function siblings(bool $self = true): Collection { $siblings = $this->siblingsCollection(); @@ -100,54 +152,8 @@ trait HasSiblings } /** - * Checks if there's a next item in the collection - * - * @param \Kirby\Cms\Collection|null $collection + * Returns the collection of siblings + * @return TCollection */ - public function hasNext($collection = null): bool - { - return $this->next($collection) !== null; - } - - /** - * Checks if there's a previous item in the collection - * - * @param \Kirby\Cms\Collection|null $collection - */ - public function hasPrev($collection = null): bool - { - return $this->prev($collection) !== null; - } - - /** - * Checks if the item is the first in the collection - * - * @param \Kirby\Cms\Collection|null $collection - */ - public function isFirst($collection = null): bool - { - $collection ??= $this->siblingsCollection(); - return $collection->first()->is($this); - } - - /** - * Checks if the item is the last in the collection - * - * @param \Kirby\Cms\Collection|null $collection - */ - public function isLast($collection = null): bool - { - $collection ??= $this->siblingsCollection(); - return $collection->last()->is($this); - } - - /** - * Checks if the item is at a certain position - * - * @param \Kirby\Cms\Collection|null $collection - */ - public function isNth(int $n, $collection = null): bool - { - return $this->indexOf($collection) === $n; - } + abstract protected function siblingsCollection(): Collection; } diff --git a/public/kirby/src/Cms/Helpers.php b/public/kirby/src/Cms/Helpers.php index 9e27f42..cdc7699 100644 --- a/public/kirby/src/Cms/Helpers.php +++ b/public/kirby/src/Cms/Helpers.php @@ -28,7 +28,7 @@ class Helpers * Helpers::$deprecations[''] = false; * ``` */ - public static $deprecations = [ + public static array $deprecations = [ // The internal `$model->contentFile*()` methods have been deprecated 'model-content-file' => true, @@ -38,11 +38,12 @@ class Helpers // TODO: switch to true in v6 'plugin-extends-root' => false, - // Passing a single space as value to `Xml::attr()` has been - // deprecated. In a future version, passing a single space won't - // render an empty value anymore but a single space. - // To render an empty value, please pass an empty string. - 'xml-attr-single-space' => true, + // The `Content\Translation` class keeps a set of methods from the old + // `ContentTranslation` class for compatibility that should no longer be used. + // Some of them can be replaced by using `Version` class methods instead + // (see method comments). `Content\Translation::contentFile` should be avoided + // entirely and has no recommended replacement. + 'translation-methods' => true ]; /** @@ -116,6 +117,18 @@ class Helpers ) { $override = null; + // check if the LC_MESSAGES constant is defined + // some environments do not support LC_MESSAGES especially on Windows + // LC_MESSAGES constant is available if PHP was compiled with libintl + if (defined('LC_MESSAGES') === true) { + // backup current locale + $locale = setlocale(LC_MESSAGES, 0); + + // set locale to C so that errors and warning messages are + // printed in English for robust comparisons in the handler + setlocale(LC_MESSAGES, 'C'); + } + /** * @psalm-suppress UndefinedVariable */ @@ -151,6 +164,12 @@ class Helpers // action or the standard error handler threw an // exception; this avoids modifying global state restore_error_handler(); + + // check if the LC_MESSAGES constant is defined + if (defined('LC_MESSAGES') === true) { + // reset to original locale + setlocale(LC_MESSAGES, $locale); + } } return $override ?? $result; @@ -159,7 +178,6 @@ class Helpers /** * Checks if a helper was overridden by the user * by setting the `KIRBY_HELPER_*` constant - * @internal * * @param string $name Name of the helper */ @@ -189,6 +207,8 @@ class Helpers return count($value); } - throw new InvalidArgumentException('Could not determine the size of the given value'); + throw new InvalidArgumentException( + message: 'Could not determine the size of the given value' + ); } } diff --git a/public/kirby/src/Cms/Html.php b/public/kirby/src/Cms/Html.php index dfe8101..540fb25 100644 --- a/public/kirby/src/Cms/Html.php +++ b/public/kirby/src/Cms/Html.php @@ -3,6 +3,8 @@ namespace Kirby\Cms; use Kirby\Filesystem\F; +use Kirby\Plugin\Assets; +use Kirby\Plugin\Plugin; use Kirby\Toolkit\A; /** @@ -26,14 +28,14 @@ class Html extends \Kirby\Toolkit\Html * @param string|array|null $options Pass an array of attributes for the link tag or a media attribute string */ public static function css( - string|array|Plugin|PluginAssets $url, + string|array|Plugin|Assets $url, string|array|null $options = null ): string|null { if ($url instanceof Plugin) { $url = $url->assets(); } - if ($url instanceof PluginAssets) { + if ($url instanceof Assets) { $url = $url->css()->values(fn ($asset) => $asset->url()); } @@ -65,9 +67,7 @@ class Html extends \Kirby\Toolkit\Html $url = ($kirby->component('css'))($kirby, $url, $options); $url = Url::to($url); - $attr = array_merge((array)$options, [ - 'href' => $url - ]); + $attr = [...$options ?? [], 'href' => $url]; return ''; } @@ -92,14 +92,14 @@ class Html extends \Kirby\Toolkit\Html * @since 3.7.0 */ public static function js( - string|array|Plugin|PluginAssets $url, + string|array|Plugin|Assets $url, string|array|bool|null $options = null ): string|null { if ($url instanceof Plugin) { $url = $url->assets(); } - if ($url instanceof PluginAssets) { + if ($url instanceof Assets) { $url = $url->js()->values(fn ($asset) => $asset->url()); } @@ -122,7 +122,7 @@ class Html extends \Kirby\Toolkit\Html $url = ($kirby->component('js'))($kirby, $url, $options); $url = Url::to($url); - $attr = array_merge((array)$options, ['src' => $url]); + $attr = [...$options ?? [], 'src' => $url]; return ''; } diff --git a/public/kirby/src/Cms/Ingredients.php b/public/kirby/src/Cms/Ingredients.php index e8f4b33..87e346d 100644 --- a/public/kirby/src/Cms/Ingredients.php +++ b/public/kirby/src/Cms/Ingredients.php @@ -18,17 +18,12 @@ use Closure; */ class Ingredients { - /** - * @var array - */ - protected $ingredients = []; - /** * Creates a new ingredient collection */ - public function __construct(array $ingredients) - { - $this->ingredients = $ingredients; + public function __construct( + protected array $ingredients = [] + ) { } /** diff --git a/public/kirby/src/Cms/Item.php b/public/kirby/src/Cms/Item.php index 86dd85a..a606f16 100644 --- a/public/kirby/src/Cms/Item.php +++ b/public/kirby/src/Cms/Item.php @@ -20,6 +20,9 @@ use Kirby\Toolkit\Str; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @template TCollection of \Kirby\Cms\Items + * @use \Kirby\Cms\HasSiblings */ class Item { diff --git a/public/kirby/src/Cms/Items.php b/public/kirby/src/Cms/Items.php index 13cc966..7cc56ab 100644 --- a/public/kirby/src/Cms/Items.php +++ b/public/kirby/src/Cms/Items.php @@ -15,6 +15,9 @@ use Kirby\Exception\InvalidArgumentException; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @template TValue of \Kirby\Cms\Item + * @extends \Kirby\Cms\Collection */ class Items extends Collection { @@ -32,7 +35,7 @@ class Items extends Collection /** * @var \Kirby\Cms\ModelWithContent */ - protected $parent; + protected object|null $parent = null; public function __construct($objects = [], array $options = []) { @@ -56,7 +59,7 @@ class Items extends Collection } if (is_array($params) === false) { - throw new InvalidArgumentException('Invalid item options'); + throw new InvalidArgumentException(message: 'Invalid item options'); } // create a new collection of blocks @@ -64,7 +67,9 @@ class Items extends Collection foreach ($items as $item) { if (is_array($item) === false) { - throw new InvalidArgumentException('Invalid data for ' . static::ITEM_CLASS); + throw new InvalidArgumentException( + message: 'Invalid data for ' . static::ITEM_CLASS + ); } // inject properties from the parent diff --git a/public/kirby/src/Cms/Language.php b/public/kirby/src/Cms/Language.php index 4c6b491..fde1f5d 100644 --- a/public/kirby/src/Cms/Language.php +++ b/public/kirby/src/Cms/Language.php @@ -5,12 +5,11 @@ namespace Kirby\Cms; use Kirby\Data\Data; use Kirby\Exception\Exception; use Kirby\Exception\InvalidArgumentException; -use Kirby\Exception\LogicException; -use Kirby\Exception\PermissionException; +use Kirby\Exception\NotFoundException; use Kirby\Filesystem\F; use Kirby\Toolkit\Locale; use Kirby\Toolkit\Str; -use Throwable; +use Stringable; /** * The `$language` object represents @@ -27,11 +26,18 @@ use Throwable; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Languages> */ -class Language +class Language implements Stringable { use HasSiblings; + /** + * Short human-readable version used in template queries + */ + public const CLASS_ALIAS = 'language'; + /** * The parent Kirby instance */ @@ -42,6 +48,7 @@ class Language protected string $direction; protected array $locale; protected string $name; + protected bool $single; protected array $slugs; protected array $smartypants; protected array $translations; @@ -53,14 +60,17 @@ class Language public function __construct(array $props) { if (isset($props['code']) === false) { - throw new InvalidArgumentException('The property "code" is required'); + throw new InvalidArgumentException( + message: 'The property "code" is required' + ); } static::$kirby = $props['kirby'] ?? null; - $this->code = trim($props['code']); + $this->code = basename(trim($props['code'])); // prevent path traversal $this->default = ($props['default'] ?? false) === true; $this->direction = ($props['direction'] ?? null) === 'rtl' ? 'rtl' : 'ltr'; $this->name = trim($props['name'] ?? $this->code); + $this->single = $props['single'] ?? false; $this->slugs = $props['slugs'] ?? []; $this->smartypants = $props['smartypants'] ?? []; $this->translations = $props['translations'] ?? []; @@ -142,22 +152,12 @@ class Language /** * Creates a new language object - * @internal */ public static function create(array $props): static { - $kirby = App::instance(); - $user = $kirby->user(); - - if ( - $user === null || - $user->role()->permissions()->for('languages', 'create') === false - ) { - throw new PermissionException(['key' => 'language.create.permission']); - } - - $props['code'] = Str::slug($props['code'] ?? null); + $kirby = App::instance(); $languages = $kirby->languages(); + $props['code'] = Str::slug($props['code'] ?? null); // make the first language the default language if ($languages->count() === 0) { @@ -166,25 +166,30 @@ class Language $language = new static($props); - // trigger before hook - $kirby->trigger( + // validate the new language + LanguageRules::create($language); + + // apply before hook + $language = $kirby->apply( 'language.create:before', [ 'input' => $props, 'language' => $language - ] + ], + 'language' ); - // validate the new language + // re-validate the language after before hook was applied LanguageRules::create($language); $language->save(); + // convert content storage to multilang if ($languages->count() === 0) { foreach ($kirby->models() as $model) { - $model->storage()->convertLanguage( - 'default', - $language->code() + $model->storage()->moveLanguage( + Language::single(), + $language ); } } @@ -192,13 +197,14 @@ class Language // update the main languages collection in the app instance $kirby->languages(false)->append($language->code(), $language); - // trigger after hook - $kirby->trigger( + // apply after hook + $language = $kirby->apply( 'language.create:after', [ 'input' => $props, 'language' => $language - ] + ], + 'language' ); return $language; @@ -207,41 +213,36 @@ class Language /** * Delete the current language and * all its translation files - * @internal * * @throws \Kirby\Exception\Exception */ public function delete(): bool { $kirby = App::instance(); - $user = $kirby->user(); $code = $this->code(); - if ( - $user === null || - $user->role()->permissions()->for('languages', 'delete') === false - ) { - throw new PermissionException(['key' => 'language.delete.permission']); - } - - if ($this->isDeletable() === false) { - throw new Exception('The language cannot be deleted'); - } - - // trigger before hook - $kirby->trigger('language.delete:before', [ - 'language' => $this - ]); - - if (F::remove($this->root()) !== true) { - throw new Exception('The language could not be deleted'); + // validate the language rules + LanguageRules::delete($this); + + // apply before hook + $language = $kirby->apply( + 'language.delete:before', + ['language' => $this] + ); + + // re-validate the language rules after before hook was applied + LanguageRules::delete($language); + + if (F::remove($language->root()) !== true) { + throw new Exception(message: 'The language could not be deleted'); } + // if needed, convert content storage to single lang foreach ($kirby->models() as $model) { - if ($this->isLast() === true) { - $model->storage()->convertLanguage($code, 'default'); + if ($language->isLast() === true) { + $model->storage()->moveLanguage($this, Language::single()); } else { - $model->storage()->deleteLanguage($code); + $model->storage()->deleteLanguage($this); } } @@ -250,7 +251,7 @@ class Language // trigger after hook $kirby->trigger('language.delete:after', [ - 'language' => $this + 'language' => $language ]); return true; @@ -264,6 +265,34 @@ class Language return $this->direction; } + /** + * Converts a "user-facing" language code to a `Language` object + * + * @throws \Kirby\Exception\NotFoundException If the language does not exist + * @unstable + */ + public static function ensure(self|string|null $code = null): static + { + if ($code instanceof self) { + return $code; + } + + $kirby = App::instance(); + + // single language + if ($kirby->multilang() === false) { + return static::single(); + } + + // look up the actual language object if possible + if ($language = $kirby->language($code)) { + return $language; + } + + // validate the language code + throw new NotFoundException(message: 'Invalid language: ' . $code); + } + /** * Check if the language file exists */ @@ -272,6 +301,16 @@ class Language return file_exists($this->root()); } + /** + * Checks if the language is the same + * as the given language or language code + * @since 5.0.0 + */ + public function is(self|string $language): bool + { + return $this->code() === static::ensure($language)->code(); + } + /** * Checks if this is the default language * for the site. @@ -286,6 +325,11 @@ class Language */ public function isDeletable(): bool { + // a single-language object cannot be deleted + if ($this->isSingle() === true) { + return false; + } + // the default language can only be deleted if it's the last if ($this->isDefault() === true && $this->isLast() === false) { return false; @@ -302,6 +346,14 @@ class Language return App::instance()->languages()->count() === 1; } + /** + * Checks if this is the single language object + */ + public function isSingle(): bool + { + return $this->single; + } + /** * The id is required for collections * to work properly. The code is used as id @@ -325,6 +377,7 @@ class Language public static function loadRules(string $code): array { $kirby = App::instance(); + $code = basename($code); // prevent path traversal $code = Str::contains($code, '.') ? Str::before($code, '.') : $code; $file = $kirby->root('i18n:rules') . '/' . $code . '.json'; @@ -332,11 +385,7 @@ class Language $file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json'; } - try { - return Data::read($file); - } catch (\Exception) { - return []; - } + return Data::read($file, fail: false); } /** @@ -388,6 +437,14 @@ class Language return $path . '/(:all?)'; } + /** + * Returns the permissions object for this language + */ + public function permissions(): LanguagePermissions + { + return new LanguagePermissions($this); + } + /** * Returns the absolute path to the language file */ @@ -408,30 +465,28 @@ class Language /** * Get slug rules for language - * @internal */ public function rules(): array { $code = $this->locale(LC_CTYPE); - $data = static::loadRules($code); - return array_merge($data, $this->slugs()); + + return [ + ...static::loadRules($code), + ...$this->slugs() + ]; } /** * Saves the language settings in the languages folder - * @internal * * @return $this */ public function save(): static { - try { - $existingData = Data::read($this->root()); - } catch (Throwable) { - $existingData = []; - } + $existingData = Data::read($this->root(), fail: false); - $props = [ + $data = [ + ...$existingData, 'code' => $this->code(), 'default' => $this->isDefault(), 'direction' => $this->direction(), @@ -441,8 +496,6 @@ class Language 'url' => $this->url, ]; - $data = array_merge($existingData, $props); - ksort($data); Data::write($this->root(), $data); @@ -453,11 +506,25 @@ class Language /** * Private siblings collector */ - protected function siblingsCollection(): Collection + protected function siblingsCollection(): Languages { return App::instance()->languages(); } + /** + * Create a placeholder language object in a + * single-language installation + */ + public static function single(): static + { + return new static([ + 'code' => 'en', + 'default' => true, + 'locale' => App::instance()->option('locale', 'en_US.utf-8'), + 'single' => true + ]); + } + /** * Returns the custom slug rules for this language */ @@ -475,8 +542,7 @@ class Language } /** - * Returns the most important - * properties as array + * Returns the most important properties as array */ public function toArray(): array { @@ -511,19 +577,10 @@ class Language /** * Update language properties and save them - * @internal */ public function update(array|null $props = null): static { $kirby = App::instance(); - $user = $kirby->user(); - - if ( - $user === null || - $user->role()->permissions()->for('languages', 'update') === false - ) { - throw new PermissionException(['key' => 'language.update.permission']); - } // don't change the language code unset($props['code']); @@ -531,23 +588,27 @@ class Language // make sure the slug is nice and clean $props['slug'] = Str::slug($props['slug'] ?? null); - $updated = $this->clone($props); + // trigger before hook + $language = $kirby->apply( + 'language.update:before', + [ + 'language' => $this, + 'input' => $props + ] + ); + + // updated language object + $language = $language->clone($props); if (isset($props['translations']) === true) { - $updated->translations = $props['translations']; + $language->translations = $props['translations']; } - // validate the updated language - LanguageRules::update($updated); - - // trigger before hook - $kirby->trigger('language.update:before', [ - 'language' => $this, - 'input' => $props - ]); + // validate the language rules after before hook was applied + LanguageRules::update($language, $this); // if language just got promoted to be the new default language… - if ($this->isDefault() === false && $updated->isDefault() === true) { + if ($this->isDefault() === false && $language->isDefault() === true) { // convert the current default to a non-default language $previous = $kirby->defaultLanguage()?->clone(['default' => false])->save(); $kirby->languages(false)->set($previous->code(), $previous); @@ -557,27 +618,20 @@ class Language } } - // if language was the default language and got demoted… - if ( - $this->isDefault() === true && - $updated->isDefault() === false && - $kirby->defaultLanguage()->code() === $this->code() - ) { - // ensure another language has already been set as default - throw new LogicException('Please select another language to be the primary language'); - } - - $language = $updated->save(); + $language = $language->save(); // make sure the language is also updated in the languages collection $kirby->languages(false)->set($language->code(), $language); // trigger after hook - $kirby->trigger('language.update:after', [ - 'newLanguage' => $language, - 'oldLanguage' => $this, - 'input' => $props - ]); + $language = $kirby->apply( + 'language.update:after', + [ + 'newLanguage' => $language, + 'oldLanguage' => $this, + 'input' => $props + ] + ); return $language; } @@ -586,14 +640,19 @@ class Language * Returns a language variable object * for the key in the translations array */ - public function variable(string $key, bool $decode = false): LanguageVariable - { + public function variable( + string $key, + bool $decode = false + ): LanguageVariable { // allows decoding if base64-url encoded url is sent // for compatibility of different environments if ($decode === true) { $key = rawurldecode(base64_decode($key)); } - return new LanguageVariable($this, $key); + return new LanguageVariable( + language: $this, + key: $key + ); } } diff --git a/public/kirby/src/Cms/LanguagePermissions.php b/public/kirby/src/Cms/LanguagePermissions.php new file mode 100644 index 0000000..b73871e --- /dev/null +++ b/public/kirby/src/Cms/LanguagePermissions.php @@ -0,0 +1,22 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LanguagePermissions extends ModelPermissions +{ + protected const CATEGORY = 'languages'; + + protected function canDelete(): bool + { + return $this->model->isDeletable() === true; + } +} diff --git a/public/kirby/src/Cms/LanguageRouter.php b/public/kirby/src/Cms/LanguageRouter.php index 13ab2d1..550766d 100644 --- a/public/kirby/src/Cms/LanguageRouter.php +++ b/public/kirby/src/Cms/LanguageRouter.php @@ -60,7 +60,7 @@ class LanguageRouter $languages = Str::split(strtolower($route['language']), '|'); // validate the language - return in_array($language->code(), $languages) === true; + return in_array($language->code(), $languages, true) === true; })); // add the page-scope if necessary @@ -80,7 +80,9 @@ class LanguageRouter $routes[$index]['pattern'] = $patterns; $routes[$index]['page'] = $page; } else { - throw new NotFoundException('The page "' . $pageId . '" does not exist'); + throw new NotFoundException( + message: 'The page "' . $pageId . '" does not exist' + ); } } } diff --git a/public/kirby/src/Cms/LanguageRoutes.php b/public/kirby/src/Cms/LanguageRoutes.php index 68bdf42..2e74d3b 100644 --- a/public/kirby/src/Cms/LanguageRoutes.php +++ b/public/kirby/src/Cms/LanguageRoutes.php @@ -129,11 +129,10 @@ class LanguageRoutes // if there's just one language, // we take that to render the home page - if ($languages->count() === 1) { - $currentLanguage = $languages->first(); - } else { - $currentLanguage = $kirby->defaultLanguage(); - } + $currentLanguage = match ($languages->count()) { + 1 => $languages->first(), + default => $kirby->defaultLanguage() + }; // language detection on the home page with / as URL if ($kirby->url() !== $currentLanguage->url()) { diff --git a/public/kirby/src/Cms/LanguageRules.php b/public/kirby/src/Cms/LanguageRules.php index a59a678..5a1f45e 100644 --- a/public/kirby/src/Cms/LanguageRules.php +++ b/public/kirby/src/Cms/LanguageRules.php @@ -4,6 +4,8 @@ namespace Kirby\Cms; use Kirby\Exception\DuplicateException; use Kirby\Exception\InvalidArgumentException; +use Kirby\Exception\LogicException; +use Kirby\Exception\PermissionException; use Kirby\Toolkit\Str; /** @@ -21,31 +23,71 @@ class LanguageRules * Validates if the language can be created * * @throws \Kirby\Exception\DuplicateException If the language already exists + * @throws \Kirby\Exception\PermissionException If current user has not sufficient permissions */ - public static function create(Language $language): bool + public static function create(Language $language): void { static::validLanguageCode($language); static::validLanguageName($language); if ($language->exists() === true) { - throw new DuplicateException([ - 'key' => 'language.duplicate', - 'data' => [ - 'code' => $language->code() - ] - ]); + throw new DuplicateException( + key: 'language.duplicate', + data: ['code' => $language->code()] + ); } - return true; + if ($language->permissions()->can('create') !== true) { + throw new PermissionException( + key: 'language.create.permission' + ); + } + } + + /** + * Validates if the language can be deleted + * + * @throws \Kirby\Exception\LogicException If the language cannot be deleted + * @throws \Kirby\Exception\PermissionException If current user has not sufficient permissions + */ + public static function delete(Language $language): void + { + if ($language->permissions()->can('delete') !== true) { + throw new PermissionException( + key: 'language.delete.permission' + ); + } } /** * Validates if the language can be updated */ - public static function update(Language $language): void - { - static::validLanguageCode($language); - static::validLanguageName($language); + public static function update( + Language $newLanguage, + Language|null $oldLanguage = null + ): void { + static::validLanguageCode($newLanguage); + static::validLanguageName($newLanguage); + + $kirby = App::instance(); + + // if language was the default language and got demoted… + if ( + $oldLanguage?->isDefault() === true && + $newLanguage->isDefault() === false && + $kirby->defaultLanguage()->code() === $oldLanguage?->code() + ) { + // ensure another language has already been set as default + throw new LogicException( + message: 'Please select another language to be the primary language' + ); + } + + if ($newLanguage->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'language.update.permission' + ); + } } /** @@ -53,19 +95,17 @@ class LanguageRules * * @throws \Kirby\Exception\InvalidArgumentException If the language code is not valid */ - public static function validLanguageCode(Language $language): bool + public static function validLanguageCode(Language $language): void { if (Str::length($language->code()) < 2) { - throw new InvalidArgumentException([ - 'key' => 'language.code', - 'data' => [ + throw new InvalidArgumentException( + key: 'language.code', + data: [ 'code' => $language->code(), 'name' => $language->name() ] - ]); + ); } - - return true; } /** @@ -73,18 +113,16 @@ class LanguageRules * * @throws \Kirby\Exception\InvalidArgumentException If the language name is invalid */ - public static function validLanguageName(Language $language): bool + public static function validLanguageName(Language $language): void { if (Str::length($language->name()) < 1) { - throw new InvalidArgumentException([ - 'key' => 'language.name', - 'data' => [ + throw new InvalidArgumentException( + key: 'language.name', + data: [ 'code' => $language->code(), 'name' => $language->name() ] - ]); + ); } - - return true; } } diff --git a/public/kirby/src/Cms/LanguageVariable.php b/public/kirby/src/Cms/LanguageVariable.php index 7ca846b..669c07b 100644 --- a/public/kirby/src/Cms/LanguageVariable.php +++ b/public/kirby/src/Cms/LanguageVariable.php @@ -34,14 +34,18 @@ class LanguageVariable */ public static function create( string $key, - string|null $value = null + string|array|null $value = null ): static { if (is_numeric($key) === true) { - throw new InvalidArgumentException('The variable key must not be numeric'); + throw new InvalidArgumentException( + message: 'The variable key must not be numeric' + ); } if (empty($key) === true) { - throw new InvalidArgumentException('The variable needs a valid key'); + throw new InvalidArgumentException( + message: 'The variable needs a valid key' + ); } $kirby = App::instance(); @@ -50,15 +54,19 @@ class LanguageVariable if ($kirby->translation()->get($key) !== null) { if (isset($translations[$key]) === true) { - throw new DuplicateException('The variable already exists'); + throw new DuplicateException( + message: 'The variable already exists' + ); } - throw new DuplicateException('The variable is part of the core translation and cannot be overwritten'); + throw new DuplicateException( + message: 'The variable is part of the core translation and cannot be overwritten' + ); } $translations[$key] = $value ?? ''; - $language->update(['translations' => $translations]); + $language = $language->update(['translations' => $translations]); return $language->variable($key); } @@ -91,6 +99,15 @@ class LanguageVariable return isset($language->translations()[$this->key]) === true; } + /** + * Checks if the value is an array + * @since 5.0.0 + */ + public function hasMultipleValues(): bool + { + return is_array($this->value()) === true; + } + /** * Returns the unique key for the variable */ @@ -102,7 +119,7 @@ class LanguageVariable /** * Sets a new value for the language variable */ - public function update(string|null $value = null): static + public function update(string|array|null $value = null): static { $translations = $this->language->translations(); $translations[$this->key] = $value ?? ''; @@ -115,7 +132,7 @@ class LanguageVariable /** * Returns the value if the variable has been translated. */ - public function value(): string|null + public function value(): string|array|null { return $this->language->translations()[$this->key] ?? null; } diff --git a/public/kirby/src/Cms/Languages.php b/public/kirby/src/Cms/Languages.php index ba631b9..3c7d771 100644 --- a/public/kirby/src/Cms/Languages.php +++ b/public/kirby/src/Cms/Languages.php @@ -13,6 +13,8 @@ use Kirby\Filesystem\F; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Language> */ class Languages extends Collection { @@ -37,7 +39,9 @@ class Languages extends Collection ); if (count($defaults) > 1) { - throw new DuplicateException('You cannot have multiple default languages. Please check your language config files.'); + throw new DuplicateException( + message: 'You cannot have multiple default languages. Please check your language config files.' + ); } parent::__construct($objects, null); @@ -53,7 +57,6 @@ class Languages extends Collection /** * Creates a new language with the given props - * @internal */ public function create(array $props): Language { @@ -69,8 +72,26 @@ class Languages extends Collection } /** - * Convert all defined languages to a collection - * @internal + * Provides a collection of installed languages, even + * if in single-language mode. In single-language mode + * `Language::single()` is used to create the default language + * + * @unstable + */ + public static function ensure(): static + { + $kirby = App::instance(); + + if ($kirby->multilang() === true) { + return $kirby->languages(); + } + + return new static([Language::single()]); + } + + /** + * Load all languages from the languages directory + * and convert them to a collection */ public static function load(): static { diff --git a/public/kirby/src/Cms/Layout.php b/public/kirby/src/Cms/Layout.php index c6920fd..9feb6d8 100644 --- a/public/kirby/src/Cms/Layout.php +++ b/public/kirby/src/Cms/Layout.php @@ -14,6 +14,8 @@ use Kirby\Content\Content; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Layouts> */ class Layout extends Item { diff --git a/public/kirby/src/Cms/LayoutColumn.php b/public/kirby/src/Cms/LayoutColumn.php index 11d61d7..1865a91 100644 --- a/public/kirby/src/Cms/LayoutColumn.php +++ b/public/kirby/src/Cms/LayoutColumn.php @@ -14,6 +14,8 @@ use Kirby\Toolkit\Str; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\LayoutColumns> */ class LayoutColumn extends Item { diff --git a/public/kirby/src/Cms/LayoutColumns.php b/public/kirby/src/Cms/LayoutColumns.php index 195e39e..62890a1 100644 --- a/public/kirby/src/Cms/LayoutColumns.php +++ b/public/kirby/src/Cms/LayoutColumns.php @@ -11,6 +11,8 @@ namespace Kirby\Cms; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\LayoutColumn> */ class LayoutColumns extends Items { diff --git a/public/kirby/src/Cms/Layouts.php b/public/kirby/src/Cms/Layouts.php index fa6031c..ce499c7 100644 --- a/public/kirby/src/Cms/Layouts.php +++ b/public/kirby/src/Cms/Layouts.php @@ -15,6 +15,8 @@ use Throwable; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\Layout> */ class Layouts extends Items { diff --git a/public/kirby/src/Cms/License.php b/public/kirby/src/Cms/License.php index 6179cbb..7685ebb 100644 --- a/public/kirby/src/Cms/License.php +++ b/public/kirby/src/Cms/License.php @@ -24,7 +24,8 @@ class License { public const HISTORY = [ '3' => '2019-02-05', - '4' => '2023-11-28' + '4' => '2023-11-28', + '5' => '2025-06-24' ]; protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX'; @@ -42,9 +43,13 @@ class License protected string|null $date = null, protected string|null $signature = null, ) { - // normalize arguments - $this->code = $this->code !== null ? trim($this->code) : null; - $this->email = $this->email !== null ? $this->normalizeEmail($this->email) : null; + if ($code !== null) { + $this->code = trim($code); + } + + if ($email !== null) { + $this->email = $this->normalizeEmail($email); + } } /** @@ -181,7 +186,9 @@ class License // rather throw an exception to avoid further issues // @codeCoverageIgnoreStart if ($release === false) { - throw new InvalidArgumentException('The version for your license could not be found'); + throw new InvalidArgumentException( + message: 'The version for your license could not be found' + ); } // @codeCoverageIgnoreEnd @@ -335,15 +342,21 @@ class License public function register(): static { if ($this->type() === LicenseType::Invalid) { - throw new InvalidArgumentException(['key' => 'license.format']); + throw new InvalidArgumentException( + key: 'license.format' + ); } if ($this->hasValidEmailAddress() === false) { - throw new InvalidArgumentException(['key' => 'license.email']); + throw new InvalidArgumentException( + key: 'license.email' + ); } if ($this->domain === null) { - throw new InvalidArgumentException(['key' => 'license.domain']); + throw new InvalidArgumentException( + key: 'license.domain' + ); } // @codeCoverageIgnoreStart @@ -386,7 +399,10 @@ class License if ($response->code() !== 200) { $message = $response->json()['message'] ?? 'The request failed'; - throw new LogicException($message, $response->code()); + throw new LogicException( + key: $response->code(), + message: $message, + ); } return $response->json(); @@ -399,9 +415,9 @@ class License public function save(): bool { if ($this->status()->activatable() !== true) { - throw new InvalidArgumentException([ - 'key' => 'license.verification' - ]); + throw new InvalidArgumentException( + key: 'license.verification' + ); } // where to store the license file @@ -453,10 +469,10 @@ class License public function status(): LicenseStatus { return $this->status ??= match (true) { - $this->isMissing() === true => LicenseStatus::Missing, - $this->isLegacy() === true => LicenseStatus::Legacy, - $this->isInactive() === true => LicenseStatus::Inactive, - default => LicenseStatus::Active + $this->isMissing() => LicenseStatus::Missing, + $this->isLegacy() => LicenseStatus::Legacy, + $this->isInactive() => LicenseStatus::Inactive, + default => LicenseStatus::Active }; } @@ -510,7 +526,9 @@ class License if (empty($response['url']) === false) { // validate the redirect URL if (Str::startsWith($response['url'], static::hub()) === false) { - throw new Exception('We couldn’t redirect you to the Hub'); + throw new Exception( + message: 'We couldn’t redirect you to the Hub' + ); } return [ diff --git a/public/kirby/src/Cms/LicenseStatus.php b/public/kirby/src/Cms/LicenseStatus.php index 42cdc56..bfb67ec 100644 --- a/public/kirby/src/Cms/LicenseStatus.php +++ b/public/kirby/src/Cms/LicenseStatus.php @@ -43,6 +43,11 @@ enum LicenseStatus: string */ case Missing = 'missing'; + /** + * The license status is unknown + */ + case Unknown = 'unknown'; + /** * Checks if the license can be saved when it * was entered in the activation dialog; @@ -65,8 +70,8 @@ enum LicenseStatus: string public function dialog(): string|null { return match ($this) { - static::Missing => 'registration', static::Demo => null, + static::Missing => 'registration', default => 'license' }; } @@ -79,11 +84,12 @@ enum LicenseStatus: string public function icon(): string { return match ($this) { - static::Missing => 'key', - static::Legacy => 'alert', - static::Inactive => 'clock', static::Active => 'check', static::Demo => 'preview', + static::Inactive => 'clock', + static::Legacy => 'alert', + static::Missing => 'key', + static::Unknown => 'question', }; } @@ -112,9 +118,9 @@ enum LicenseStatus: string public function renewable(): bool { return match ($this) { - static::Demo, - static::Active => false, - default => true + static::Active, + static::Demo => false, + default => true }; } @@ -126,11 +132,12 @@ enum LicenseStatus: string public function theme(): string { return match ($this) { - static::Missing => 'love', - static::Legacy => 'negative', - static::Inactive => 'notice', static::Active => 'positive', static::Demo => 'notice', + static::Inactive => 'notice', + static::Legacy => 'negative', + static::Missing => 'love', + static::Unknown => 'passive', }; } diff --git a/public/kirby/src/Cms/Loader.php b/public/kirby/src/Cms/Loader.php index 6e71878..1fcc06b 100644 --- a/public/kirby/src/Cms/Loader.php +++ b/public/kirby/src/Cms/Loader.php @@ -28,24 +28,10 @@ use Kirby\Filesystem\F; */ class Loader { - /** - * @var \Kirby\Cms\App - */ - protected $kirby; - - /** - * @var bool - */ - protected $withPlugins; - - /** - * @param \Kirby\Cms\App $kirby - * @param bool $withPlugins - */ - public function __construct(App $kirby, bool $withPlugins = true) - { - $this->kirby = $kirby; - $this->withPlugins = $withPlugins; + public function __construct( + protected App $kirby, + protected bool $withPlugins = true + ) { } /** @@ -63,7 +49,10 @@ class Loader public function areas(): array { $areas = []; - $extensions = $this->withPlugins === true ? $this->kirby->extensions('areas') : []; + $extensions = match ($this->withPlugins) { + true => $this->kirby->extensions('areas'), + false => [] + }; // load core areas and extend them with elements // from plugins if they exist @@ -121,7 +110,10 @@ class Loader */ public function extensions(string $type): array { - return $this->withPlugins === false ? $this->kirby->core()->$type() : $this->kirby->extensions($type); + return match ($this->withPlugins) { + true => $this->kirby->extensions($type), + false => $this->kirby->core()->$type() + }; } /** @@ -174,12 +166,11 @@ class Loader */ public function resolveArea(string|array|Closure $area): array { - $area = $this->resolve($area); - $dropdowns = $area['dropdowns'] ?? []; + $area = $this->resolve($area); // convert closure dropdowns to an array definition // otherwise they cannot be merged properly later - foreach ($dropdowns as $key => $dropdown) { + foreach ($area['dropdowns'] ?? [] as $key => $dropdown) { if ($dropdown instanceof Closure) { $area['dropdowns'][$key] = [ 'options' => $dropdown diff --git a/public/kirby/src/Cms/Media.php b/public/kirby/src/Cms/Media.php index fe8c8e9..11a501e 100644 --- a/public/kirby/src/Cms/Media.php +++ b/public/kirby/src/Cms/Media.php @@ -95,31 +95,40 @@ class Media string $filename ): Response|false { $kirby = App::instance(); + $index = $kirby->root('index'); + $media = $kirby->root('media'); $root = match (true) { // assets is_string($model) - => $kirby->root('media') . '/assets/' . $model . '/' . $hash, + => $media . '/assets/' . $model . '/' . $hash, // parent files for file model that already included hash $model instanceof File - => dirname($model->mediaRoot()), + => $model->mediaDir(), // model files default => $model->mediaRoot() . '/' . $hash }; - $thumb = $root . '/' . $filename; - $job = $root . '/.jobs/' . $filename . '.json'; - try { + // prevent path traversal + $root = Dir::realpath($root, $media); + + $thumb = $root . '/' . $filename; + $job = $root . '/.jobs/' . $filename . '.json'; + $options = Data::read($job); } catch (Throwable) { // send a customized error message to make clearer what happened here - throw new NotFoundException('The thumbnail configuration could not be found'); + throw new NotFoundException( + message: 'The thumbnail configuration could not be found' + ); } if (empty($options['filename']) === true) { - throw new InvalidArgumentException('Incomplete thumbnail configuration'); + throw new InvalidArgumentException( + message: 'Incomplete thumbnail configuration' + ); } try { @@ -127,7 +136,12 @@ class Media // this adds support for custom assets $source = match (true) { is_string($model) === true - => $kirby->root('index') . '/' . $model . '/' . $options['filename'], + => F::realpath( + $index . '/' . $model . '/' . $options['filename'], + $index + ), + $model instanceof File + => $model->root(), default => $model->file($options['filename'])->root() }; @@ -161,10 +175,10 @@ class Media } // get both old and new versions (pre and post Kirby 3.4.0) - $versions = array_merge( - glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR), - glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR) - ); + $versions = [ + ...glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR), + ...glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR) + ]; // delete all versions of the file foreach ($versions as $version) { diff --git a/public/kirby/src/Cms/Model.php b/public/kirby/src/Cms/Model.php deleted file mode 100644 index a9c61e5..0000000 --- a/public/kirby/src/Cms/Model.php +++ /dev/null @@ -1,117 +0,0 @@ - - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -abstract class Model -{ - use Properties; - - /** - * Each model must define a CLASS_ALIAS - * which will be used in template queries. - * The CLASS_ALIAS is a short human-readable - * version of the class name. I.e. page. - */ - public const CLASS_ALIAS = null; - - /** - * The parent Kirby instance - * - * @var \Kirby\Cms\App - */ - public static $kirby; - - /** - * The parent site instance - * - * @var \Kirby\Cms\Site - */ - protected $site; - - /** - * Makes it possible to convert the entire model - * to a string. Mostly useful for debugging - * - * @return string - */ - public function __toString(): string - { - return $this->id(); - } - - /** - * Each model must return a unique id - * - * @return string|null - */ - public function id() - { - return null; - } - - /** - * Returns the parent Kirby instance - * - * @return \Kirby\Cms\App - */ - public function kirby() - { - return static::$kirby ??= App::instance(); - } - - /** - * Returns the parent Site instance - * - * @return \Kirby\Cms\Site - */ - public function site() - { - return $this->site ??= $this->kirby()->site(); - } - - /** - * Setter for the parent Kirby object - * - * @param \Kirby\Cms\App|null $kirby - * @return $this - */ - protected function setKirby(App|null $kirby = null) - { - static::$kirby = $kirby; - return $this; - } - - /** - * Setter for the parent site object - * - * @internal - * @param \Kirby\Cms\Site|null $site - * @return $this - */ - public function setSite(Site|null $site = null) - { - $this->site = $site; - return $this; - } - - /** - * Convert the model to a simple array - * - * @return array - */ - public function toArray(): array - { - return $this->propertiesToArray(); - } -} diff --git a/public/kirby/src/Cms/ModelCommit.php b/public/kirby/src/Cms/ModelCommit.php new file mode 100644 index 0000000..8f90cc6 --- /dev/null +++ b/public/kirby/src/Cms/ModelCommit.php @@ -0,0 +1,243 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class ModelCommit +{ + protected App $kirby; + protected string $prefix; + + public function __construct( + protected ModelWithContent $model, + protected string $action + ) { + $this->kirby = $this->model->kirby(); + $this->prefix = $this->model::CLASS_ALIAS; + } + + /** + * Runs the `after` hook and returns the result. + */ + public function after(mixed $state): mixed + { + // run the `after` hook + $arguments = $this->afterHookArguments($state); + $hook = $this->hook('after', $arguments); + + // flush the page cache after any model action + $this->kirby->cache('pages')->flush(); + + return $hook['result']; + } + + /** + * Returns the appropriate arguments for the `after` hook + * for the given model action. It's a wrapper around the + * more specific `afterHookArgumentsFor*Actions` methods. + */ + public function afterHookArguments(mixed $state): array + { + return match (true) { + $this->model instanceof File => + $this->afterHookArgumentsForFileActions($this->model, $this->action, $state), + $this->model instanceof Page => + $this->afterHookArgumentsForPageActions($this->model, $this->action, $state), + $this->model instanceof Site => + $this->afterHookArgumentsForSiteActions($this->model, $state), + $this->model instanceof User => + $this->afterHookArgumentsForUserActions($this->model, $this->action, $state), + default => + throw new Exception('Invalid model class') + }; + } + + /** + * Returns the appropriate arguments for the `after` hook + * for the given page action. + */ + protected function afterHookArgumentsForPageActions( + Page $model, + string $action, + mixed $state + ): array { + return match ($action) { + 'create' => [ + 'page' => $state + ], + 'duplicate' => [ + 'duplicatePage' => $state, + 'originalPage' => $model + ], + 'delete' => [ + 'status' => $state, + 'page' => $model + ], + default => [ + 'newPage' => $state, + 'oldPage' => $model + ] + }; + } + + /** + * Returns the appropriate arguments for the `after` hook + * for the given file action. + */ + protected function afterHookArgumentsForFileActions( + File $model, + string $action, + mixed $state + ): array { + return match ($action) { + 'create' => [ + 'file' => $state + ], + 'delete' => [ + 'status' => $state, + 'file' => $model + ], + default => [ + 'newFile' => $state, + 'oldFile' => $model + ] + }; + } + + /** + * Returns the appropriate arguments for the `after` hook + * for the given site action. + */ + protected function afterHookArgumentsForSiteActions( + Site $model, + mixed $state + ): array { + return [ + 'newSite' => $state, + 'oldSite' => $model + ]; + } + + /** + * Returns the appropriate arguments for the `after` hook + * for the given user action. + */ + protected function afterHookArgumentsForUserActions( + User $model, + string $action, + mixed $state + ): array { + return match ($action) { + 'create' => [ + 'user' => $state + ], + 'delete' => [ + 'status' => $state, + 'user' => $model + ], + default => [ + 'newUser' => $state, + 'oldUser' => $model + ] + }; + } + + /** + * Runs the `before` hook and modifies the arguments + */ + public function before(array $arguments): array + { + // check model rules + $this->validate($arguments); + + // run the `before` hook + $hook = $this->hook('before', $arguments); + + // check model rules again, after the hook got applied + $this->validate($hook['arguments']); + + return $hook['arguments']; + } + + /** + * Handles the full call of the given action, + * runs the `before` and `after` hooks and updates + * the state of the given model. + */ + public function call(array $arguments, Closure $callback): mixed + { + // run the before hook + $arguments = $this->before($arguments); + + // run the commit action + $state = $callback(...array_values($arguments)); + + // update the state for the after hook + ModelState::update( + method: $this->action, + current: $this->model, + next: $state + ); + + // run the after hook and return the result + return $this->after($state); + } + + /** + * Runs the given hook and modifies the first argument + * of the given arguments array. It returns an array with + * `arguments` and `result` keys. + */ + public function hook(string $hook, array $arguments): array + { + // the very first argument (which should be the model) + // is modified by the return value from the hook (if any returned) + $appliedTo = array_key_first($arguments); + + // run the hook and modify the first argument + $arguments[$appliedTo] = $this->kirby->apply( + // e.g. page.create:before + $this->prefix . '.' . $this->action . ':' . $hook, + $arguments, + $appliedTo + ); + + return [ + 'arguments' => $arguments, + 'result' => $arguments[$appliedTo], + ]; + } + + /** + * Checks the model rules for the given action + * if there's a matching rule method. + */ + public function validate(array $arguments): void + { + $rules = match (true) { + $this->model instanceof File => FileRules::class, + $this->model instanceof Page => PageRules::class, + $this->model instanceof Site => SiteRules::class, + $this->model instanceof User => UserRules::class, + default => throw new Exception('Invalid model class') // @codeCoverageIgnore + }; + + if (method_exists($rules, $this->action) === true) { + $rules::{$this->action}(...array_values($arguments)); + } + } +} diff --git a/public/kirby/src/Cms/ModelPermissions.php b/public/kirby/src/Cms/ModelPermissions.php index a700b26..798623b 100644 --- a/public/kirby/src/Cms/ModelPermissions.php +++ b/public/kirby/src/Cms/ModelPermissions.php @@ -2,6 +2,7 @@ namespace Kirby\Cms; +use Kirby\Exception\LogicException; use Kirby\Toolkit\A; /** @@ -15,18 +16,17 @@ use Kirby\Toolkit\A; */ abstract class ModelPermissions { - protected string $category; - protected ModelWithContent $model; + protected const CATEGORY = 'model'; protected array $options; - protected Permissions $permissions; - protected User $user; - public function __construct(ModelWithContent $model) + protected static array $cache = []; + + public function __construct(protected ModelWithContent|Language $model) { - $this->model = $model; - $this->options = $model->blueprint()->options(); - $this->user = $model->kirby()->user() ?? User::nobody(); - $this->permissions = $this->user->role()->permissions(); + $this->options = match (true) { + $model instanceof ModelWithContent => $model->blueprint()->options(), + default => [] + }; } public function __call(string $method, array $arguments = []): bool @@ -43,6 +43,17 @@ abstract class ModelPermissions return $this->toArray(); } + /** + * Can be overridden by specific child classes + * to return a model-specific value used to + * cache a once determined permission in memory + * @codeCoverageIgnore + */ + protected static function cacheKey(ModelWithContent|Language $model): string + { + return ''; + } + /** * Returns whether the current user is allowed to do * a certain action on the model @@ -53,8 +64,9 @@ abstract class ModelPermissions string $action, bool $default = false ): bool { - $user = $this->user->id(); - $role = $this->user->role()->id(); + $user = static::user(); + $userId = $user->id(); + $role = $user->role()->id(); // users with the `nobody` role can do nothing // that needs a permission check @@ -73,7 +85,7 @@ abstract class ModelPermissions } // the almighty `kirby` user can do anything - if ($user === 'kirby' && $role === 'admin') { + if ($userId === 'kirby' && $role === 'admin') { return true; } @@ -103,7 +115,32 @@ abstract class ModelPermissions } } - return $this->permissions->for($this->category, $action, $default); + $permissions = $user->role()->permissions(); + return $permissions->for(static::category($this->model), $action, $default); + } + + /** + * Quickly determines a permission for the current user role + * and model blueprint unless dynamic checking is required + */ + public static function canFromCache( + ModelWithContent|Language $model, + string $action, + bool $default = false + ): bool { + $role = $model->kirby()->role()?->id() ?? '__none__'; + $category = static::category($model); + $cacheKey = $category . '.' . $action . '/' . static::cacheKey($model) . '/' . $role; + + if (isset(static::$cache[$cacheKey]) === true) { + return static::$cache[$cacheKey]; + } + + if (method_exists(static::class, 'can' . $action) === true) { + throw new LogicException('Cannot use permission cache for dynamically-determined permission'); + } + + return static::$cache[$cacheKey] = $model->permissions()->can($action, $role, $default); } /** @@ -119,6 +156,15 @@ abstract class ModelPermissions return $this->can($action, !$default) === false; } + /** + * Can be overridden by specific child classes + * if the permission category needs to be dynamic + */ + protected static function category(ModelWithContent|Language $model): string + { + return static::CATEGORY; + } + public function toArray(): array { $array = []; @@ -129,4 +175,12 @@ abstract class ModelPermissions return $array; } + + /** + * Returns the currently logged in user + */ + protected static function user(): User + { + return App::instance()->user() ?? User::nobody(); + } } diff --git a/public/kirby/src/Cms/ModelState.php b/public/kirby/src/Cms/ModelState.php new file mode 100644 index 0000000..30d40de --- /dev/null +++ b/public/kirby/src/Cms/ModelState.php @@ -0,0 +1,107 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class ModelState +{ + /** + * Updates the state of the given model. + */ + public static function update( + string $method, + ModelWithContent $current, + ModelWithContent|bool|null $next = null, + ModelWithContent|Site|null $parent = null + ): void { + // normalize the method + $method = match ($method) { + 'append', 'create' => 'append', + 'remove', 'delete' => 'remove', + 'duplicate' => false, // The models need to take care of this + default => 'update' + }; + + if ($method === false) { + return; + } + + match (true) { + $current instanceof File => static::updateFile($method, $current, $next), + $current instanceof Page => static::updatePage($method, $current, $next, $parent), + $current instanceof Site => static::updateSite($current, $next), + $current instanceof User => static::updateUser($method, $current, $next), + }; + } + + /** + * Updates the state of the given file. + */ + protected static function updateFile( + string $method, + File $current, + File|bool|null $next = null + ): void { + $next = $next instanceof File ? $next : $current; + + // update the files collection + $next->parent()->files()->$method($next); + } + + /** + * Updates the state of the given page. + */ + protected static function updatePage( + string $method, + Page $current, + Page|bool|null $next = null, + Page|Site|null $parent = null + ): void { + $next = $next instanceof Page ? $next : $current; + $parent ??= $next->parentModel(); + + if ($next->isDraft() === true) { + $parent->drafts()->$method($next); + } else { + $parent->children()->$method($next); + } + + // update the childrenAndDrafts() cache + $parent->childrenAndDrafts()->$method($next); + } + + /** + * Updates the state of the given site. + */ + protected static function updateSite( + Site $current, + Site|null $next = null + ): void { + App::instance()->setSite($next ?? $current); + } + + /** + * Updates the state of the given user. + */ + protected static function updateUser( + string $method, + User $current, + User|bool|null $next = null + ): void { + $next = $next instanceof User ? $next : $current; + + // update the users collection + App::instance()->users()->$method($next); + } +} diff --git a/public/kirby/src/Cms/ModelWithContent.php b/public/kirby/src/Cms/ModelWithContent.php index d525307..7c87dc8 100644 --- a/public/kirby/src/Cms/ModelWithContent.php +++ b/public/kirby/src/Cms/ModelWithContent.php @@ -4,17 +4,23 @@ namespace Kirby\Cms; use Closure; use Kirby\Content\Content; -use Kirby\Content\ContentStorage; -use Kirby\Content\ContentTranslation; -use Kirby\Content\PlainTextContentStorageHandler; +use Kirby\Content\ImmutableMemoryStorage; +use Kirby\Content\Lock; +use Kirby\Content\MemoryStorage; +use Kirby\Content\Storage; +use Kirby\Content\Translation; +use Kirby\Content\Translations; +use Kirby\Content\Version; +use Kirby\Content\VersionId; +use Kirby\Content\Versions; use Kirby\Exception\InvalidArgumentException; -use Kirby\Exception\NotFoundException; +use Kirby\Form\Fields; use Kirby\Form\Form; use Kirby\Panel\Model; use Kirby\Toolkit\Str; use Kirby\Uuid\Identifiable; use Kirby\Uuid\Uuid; -use Kirby\Uuid\Uuids; +use Stringable; use Throwable; /** @@ -26,7 +32,7 @@ use Throwable; * @copyright Bastian Allgeier * @license https://getkirby.com/license */ -abstract class ModelWithContent implements Identifiable +abstract class ModelWithContent implements Identifiable, Stringable { /** * Each model must define a CLASS_ALIAS @@ -42,11 +48,9 @@ abstract class ModelWithContent implements Identifiable */ public array|null $blueprints = null; - public Content|null $content; public static App $kirby; protected Site|null $site; - protected ContentStorage $storage; - public Collection|null $translations = null; + protected Storage $storage; /** * Store values used to initilaize object @@ -74,7 +78,7 @@ abstract class ModelWithContent implements Identifiable public function blueprints(string|null $inSection = null): array { // helper function - $toBlueprints = function (array $sections): array { + $toBlueprints = static function (array $sections): array { $blueprints = []; foreach ($sections as $section) { @@ -100,6 +104,28 @@ abstract class ModelWithContent implements Identifiable return $this->blueprints ??= $toBlueprints($blueprint->sections()); } + /** + * Moves or copies the model to a new storage instance/type + * @since 5.0.0 + * @unstable + */ + public function changeStorage(Storage|string $toStorage, bool $copy = false): static + { + if (is_string($toStorage) === true) { + if (is_subclass_of($toStorage, Storage::class) === false) { + throw new InvalidArgumentException('Invalid storage class'); + } + + $toStorage = new $toStorage($this); + } + + $method = $copy ? 'copyAll' : 'moveAll'; + + $this->storage()->$method(to: $toStorage); + $this->storage = $toStorage; + return $this; + } + /** * Creates a new instance with the same * initial properties @@ -108,7 +134,22 @@ abstract class ModelWithContent implements Identifiable */ public function clone(array $props = []): static { - return new static(array_replace_recursive($this->propertyData, $props)); + $props = array_replace_recursive($this->propertyData, $props); + $clone = new static($props); + + // Move the clone to a new instance of the same storage class + // The storage classes might need to rely on the model instance + // and thus we need to make sure that the cloned object is + // passed on to the new storage instance + $storage = match (true) { + isset($props['content']), + isset($props['translations']) => $clone->storage()::class, + default => $this->storage()::class + }; + + $clone->changeStorage($storage); + + return $clone; } /** @@ -127,88 +168,22 @@ abstract class ModelWithContent implements Identifiable */ public function content(string|null $languageCode = null): Content { - // single language support - if ($this->kirby()->multilang() === false) { - if ($this->content instanceof Content) { - return $this->content; - } - - // don't normalize field keys (already handled by the `Data` class) - return $this->content = new Content($this->readContent(), $this, false); - } - // get the targeted language - $language = $this->kirby()->language($languageCode); + $language = Language::ensure($languageCode ?? 'current'); + $versionId = VersionId::$render ?? 'latest'; + $version = $this->version($versionId); - // stop if the language does not exist - if ($language === null) { - throw new InvalidArgumentException('Invalid language: ' . $languageCode); + if ($version->exists($language) === true) { + return $version->content($language); } - // only fetch from cache for the current language - if ($languageCode === null && $this->content instanceof Content) { - return $this->content; - } - - // get the translation by code - $translation = $this->translation($language->code()); - - // don't normalize field keys (already handled by the `ContentTranslation` class) - $content = new Content($translation->content(), $this, false); - - // only store the content for the current language - if ($languageCode === null) { - $this->content = $content; - } - - return $content; - } - - /** - * Returns the absolute path to the content file; - * NOTE: only supports the published content file - * (use `$model->storage()->contentFile()` for other versions) - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - * - * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist - */ - public function contentFile( - string|null $languageCode = null, - bool $force = false - ): string { - Helpers::deprecated('The internal $model->contentFile() method has been deprecated. You can use $model->storage()->contentFile() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file'); - - return $this->storage()->contentFile( - $this->storage()->defaultVersion(), - $languageCode, - $force - ); - } - - /** - * Returns an array with all content files; - * NOTE: only supports the published content file - * (use `$model->storage()->contentFiles()` for other versions) - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFiles(): array - { - Helpers::deprecated('The internal $model->contentFiles() method has been deprecated. You can use $model->storage()->contentFiles() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file'); - - return $this->storage()->contentFiles( - $this->storage()->defaultVersion() - ); + return $this->version()->content($language); } /** * Prepares the content that should be written * to the text file - * @internal + * @unstable */ public function contentFileData( array $data, @@ -217,96 +192,77 @@ abstract class ModelWithContent implements Identifiable return $data; } - /** - * Returns the absolute path to the - * folder in which the content file is - * located - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileDirectory(): string|null - { - Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return $this->root(); - } - - /** - * Returns the extension of the content file - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileExtension(): string - { - Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return $this->kirby()->contentExtension(); - } - - /** - * Needs to be declared by the final model - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - abstract public function contentFileName(): string; - /** * Converts model to new blueprint * incl. its content for all translations */ protected function convertTo(string $blueprint): static { - // first close object with new blueprint as template + // Keep a copy of the old model with the original storage handler. + // This will be used to delete the old versions. + $old = $this->clone(); + + // Clone the object with the new blueprint as template $new = $this->clone(['template' => $blueprint]); - // temporary compatibility change (TODO: also convert changes) - $identifier = $this->storage()->defaultVersion(); + // Make sure to use the same storage class as the original model. + // This is needed if the model has been constructed with `content` or + // `translations` props. In this case, the storage would be set to + // `MemoryStorage` in the clone method again, even if it might have been + // changed before. + $new->changeStorage($this->storage()::class); - // for multilang, we go through all translations and - // covnert the content for each of them, remove the content file - // to rewrite it with converted content afterwards - if ($this->kirby()->multilang() === true) { - $translations = []; + // Copy this instance into immutable storage. + // Moving the content would prematurely delete the old content storage entries. + // But we need to keep them until the new content is written. + $this->changeStorage( + toStorage: new ImmutableMemoryStorage( + model: $this, + nextModel: $new + ), + copy: true + ); - foreach ($this->kirby()->languages()->codes() as $code) { - if ($this->translation($code)?->exists() === true) { - $content = $this->content($code)->convertTo($blueprint); + // Get all languages to loop through + $languages = Languages::ensure(); - // delete the old text file - $this->storage()->delete( - $identifier, - $code - ); - - // save to re-create the translation content file - // with the converted/updated content - $new->save($content, $code); + // Loop through all versions + foreach ($old->versions() as $oldVersion) { + // Loop through all languages + foreach ($languages as $language) { + // Skip non-existing versions + if ($oldVersion->exists($language) === false) { + continue; } - $translations[] = [ - 'code' => $code, - 'content' => $content ?? null - ]; - } + // Convert the content to the new blueprint + $content = $oldVersion->content($language)->convertTo($blueprint); - // cloning the object with the new translations content ensures - // that `propertyData` prop does not hold any old translations - // content that could surface on subsequent cloning - return $new->clone(['translations' => $translations]); + // Delete the old versions. This will also remove the + // content files from the storage if this is a plain text + // storage instance. + $oldVersion->delete($language); + + // Save to re-create the new version + // with the converted/updated content + $new->version($oldVersion->id())->save($content, $language); + } } - // for single language setups, we do the same, - // just once for the main content - $content = $this->content()->convertTo($blueprint); + return $new; + } - // delete the old text file - $this->storage()->delete($identifier, 'default'); - - return $new->save($content); + /** + * Creates default content for the model, by using our + * Form class to generate the defaults, based on the + * model's blueprint setup. + * + * @since 5.0.0 + */ + public function createDefaultContent(): array + { + $fields = Fields::for($this, 'default'); + return $fields->fill($fields->defaults())->toStoredValues(); } /** @@ -334,7 +290,7 @@ abstract class ModelWithContent implements Identifiable $errors = []; foreach ($this->blueprint()->sections() as $section) { - $errors = array_merge($errors, $section->errors()); + $errors = [...$errors, ...$section->errors()]; } return $errors; @@ -384,11 +340,11 @@ abstract class ModelWithContent implements Identifiable /** * Checks if the model is locked for the current user + * @deprecated 5.0.0 Use `->lock()->isLocked()` instead */ public function isLocked(): bool { - $lock = $this->lock(); - return $lock && $lock->isLocked() === true; + return $this->lock()->isLocked() === true; } /** @@ -396,7 +352,7 @@ abstract class ModelWithContent implements Identifiable */ public function isValid(): bool { - return Form::for($this)->isValid() === true; + return $this->version('latest')->isValid('current'); } /** @@ -408,28 +364,11 @@ abstract class ModelWithContent implements Identifiable } /** - * Returns the lock object for this model - * - * Only if a content directory exists, - * virtual pages will need to overwrite this method + * Returns lock for the model */ - public function lock(): ContentLock|null + public function lock(): Lock { - $dir = $this->root(); - - if ($this::CLASS_ALIAS === 'file') { - $dir = dirname($dir); - } - - if ( - $this->kirby()->option('content.locking', true) && - is_string($dir) === true && - file_exists($dir) === true - ) { - return new ContentLock($this); - } - - return null; + return $this->version('changes')->lock('*'); } /** @@ -450,16 +389,12 @@ abstract class ModelWithContent implements Identifiable */ public function purge(): static { - $this->blueprints = null; - $this->content = null; - $this->translations = null; - + $this->blueprints = null; return $this; } /** * Creates a string query, starting from the model - * @internal */ public function query( string|null $query = null, @@ -490,20 +425,12 @@ abstract class ModelWithContent implements Identifiable /** * Read the content from the content file * @internal + * @deprecated 5.0.0 Use `->version()->read()` instead */ public function readContent(string|null $languageCode = null): array { - try { - return $this->storage()->read( - $this->storage()->defaultVersion(), - $languageCode - ); - } catch (NotFoundException) { - // only if the content file really does not exist, it's ok - // to return empty content. Otherwise this could lead to - // content loss in case of file reading issues - return []; - } + Helpers::deprecated('$model->readContent() is deprecated. Use $model->version()->read() instead.'); // @codeCoverageIgnore + return $this->version()->read($languageCode ?? 'default') ?? []; } /** @@ -512,90 +439,63 @@ abstract class ModelWithContent implements Identifiable abstract public function root(): string|null; /** - * Stores the content on disk - * @internal + * Low-level method to save the model with the given data. + * Consider using `::update()` instead. */ public function save( array|null $data = null, string|null $languageCode = null, bool $overwrite = false - ): static { - if ($this->kirby()->multilang() === true) { - return $this->saveTranslation($data, $languageCode, $overwrite); - } - - return $this->saveContent($data, $overwrite); - } - - /** - * Save the single language content - */ - protected function saveContent( - array|null $data = null, - bool $overwrite = false ): static { // create a clone to avoid modifying the original $clone = $this->clone(); - // merge the new data with the existing content - $clone->content()->update($data, $overwrite); + // move the old model into memory + $this->changeStorage( + toStorage: new ImmutableMemoryStorage( + model: $this, + nextModel: $clone + ), + copy: true + ); - // send the full content array to the writer - $clone->writeContent($clone->content()->toArray()); + // update the clone + $clone->version()->save( + $data ?? [], + $languageCode ?? 'current', + $overwrite + ); + + ModelState::update( + method: 'set', + current: $this, + next: $clone + ); return $clone; } /** - * Save a translation - * - * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist + * @deprecated 5.0.0 Use $model->save() instead + */ + protected function saveContent( + array|null $data = null, + bool $overwrite = false + ): static { + Helpers::deprecated('$model->saveContent() is deprecated. Use $model->save() instead.'); + return $this->save($data, 'default', $overwrite); + } + + /** + * @deprecated 5.0.0 Use $model->save() instead */ protected function saveTranslation( array|null $data = null, string|null $languageCode = null, bool $overwrite = false ): static { - // create a clone to not touch the original - $clone = $this->clone(); - - // fetch the matching translation and update all the strings - $translation = $clone->translation($languageCode); - - if ($translation === null) { - throw new InvalidArgumentException('Invalid language: ' . $languageCode); - } - - // get the content to store - $content = $translation->update($data, $overwrite)->content(); - $kirby = $this->kirby(); - $languageCode = $kirby->languageCode($languageCode); - - // remove all untranslatable fields - if ($languageCode !== $kirby->defaultLanguage()->code()) { - foreach ($this->blueprint()->fields() as $field) { - if (($field['translate'] ?? true) === false) { - $content[strtolower($field['name'])] = null; - } - } - - // remove UUID for non-default languages - if (Uuids::enabled() === true && isset($content['uuid']) === true) { - $content['uuid'] = null; - } - - // merge the translation with the new data - $translation->update($content, true); - } - - // send the full translation array to the writer - $clone->writeContent($translation->content(), $languageCode); - - // reset the content object - $clone->content = null; - - // return the updated model - return $clone; + Helpers::deprecated('$model->saveTranslation() is deprecated. Use $model->save() instead.'); + return $this->save($data, $languageCode ?? 'default', $overwrite); } /** @@ -605,11 +505,13 @@ abstract class ModelWithContent implements Identifiable */ protected function setContent(array|null $content = null): static { - if ($content !== null) { - $content = new Content($content, $this); + if ($content === null) { + return $this; } - $this->content = $content; + $this->changeStorage(MemoryStorage::class, copy: true); + $this->version()->save($content, 'default'); + return $this; } @@ -620,18 +522,18 @@ abstract class ModelWithContent implements Identifiable */ protected function setTranslations(array|null $translations = null): static { - if ($translations !== null) { - $this->translations = new Collection(); - - foreach ($translations as $props) { - $props['parent'] = $this; - $translation = new ContentTranslation($props); - $this->translations->data[$translation->code()] = $translation; - } - } else { - $this->translations = null; + if ($translations === null) { + return $this; } + $this->changeStorage(MemoryStorage::class, copy: true); + + Translations::create( + model: $this, + version: $this->version(), + translations: $translations + ); + return $this; } @@ -645,14 +547,10 @@ abstract class ModelWithContent implements Identifiable /** * Returns the content storage handler - * @internal */ - public function storage(): ContentStorage + public function storage(): Storage { - return $this->storage ??= new ContentStorage( - model: $this, - handler: PlainTextContentStorageHandler::class - ); + return $this->storage ??= $this->kirby()->storage($this); } /** @@ -701,7 +599,7 @@ abstract class ModelWithContent implements Identifiable } if ($handler !== 'template' && $handler !== 'safeTemplate') { - throw new InvalidArgumentException('Invalid toString handler'); // @codeCoverageIgnore + throw new InvalidArgumentException(message: 'Invalid toString handler'); // @codeCoverageIgnore } $result = Str::$handler($template, array_replace([ @@ -720,44 +618,36 @@ abstract class ModelWithContent implements Identifiable */ public function __toString(): string { - return $this->id(); + return (string)$this->id(); } /** * Returns a single translation by language code * If no code is specified the current translation is returned + * + * @throws \Kirby\Exception\NotFoundException If the language does not exist */ public function translation( string|null $languageCode = null - ): ContentTranslation|null { - if ($language = $this->kirby()->language($languageCode)) { - return $this->translations()->find($language->code()); - } + ): Translation { + $language = Language::ensure($languageCode ?? 'current'); - return null; + return new Translation( + model: $this, + version: $this->version(), + language: $language + ); } /** * Returns the translations collection */ - public function translations(): Collection + public function translations(): Translations { - if ($this->translations !== null) { - return $this->translations; - } - - $this->translations = new Collection(); - - foreach ($this->kirby()->languages() as $language) { - $translation = new ContentTranslation([ - 'parent' => $this, - 'code' => $language->code(), - ]); - - $this->translations->data[$translation->code()] = $translation; - } - - return $this->translations; + return Translations::load( + model: $this, + version: $this->version() + ); } /** @@ -770,26 +660,26 @@ abstract class ModelWithContent implements Identifiable string|null $languageCode = null, bool $validate = false ): static { - $form = Form::for($this, [ - 'ignoreDisabled' => $validate === false, - 'input' => $input, - 'language' => $languageCode, - ]); + $form = Form::for( + model: $this, + language: $languageCode, + ); - // validate the input - if ($validate === true && $form->isInvalid() === true) { - throw new InvalidArgumentException([ - 'fallback' => 'Invalid form with errors', - 'details' => $form->errors() - ]); + $form->submit( + input: $input ?? [], + force: $validate === false + ); + + if ($validate === true) { + $form->validate(); } return $this->commit( 'update', [ static::CLASS_ALIAS => $this, - 'values' => $form->data(), - 'strings' => $form->strings(), + 'values' => $form->toFormValues(), + 'strings' => $form->toStoredValues(), 'languageCode' => $languageCode ], fn ($model, $values, $strings, $languageCode) => @@ -806,24 +696,37 @@ abstract class ModelWithContent implements Identifiable return Uuid::for($this); } + /** + * Returns a content version instance + * @since 5.0.0 + */ + public function version(VersionId|string|null $versionId = null): Version + { + return new Version( + model: $this, + id: VersionId::from($versionId ?? 'latest') + ); + } + + /** + * Returns a versions collection + * @since 5.0.0 + */ + public function versions(): Versions + { + return Versions::load($this); + } + /** * Low level data writer method * to store the given data on disk or anywhere else * @internal + * @deprecated 5.0.0 Use `->version()->save()` instead */ public function writeContent(array $data, string|null $languageCode = null): bool { - $data = $this->contentFileData($data, $languageCode); - $id = $this->storage()->defaultVersion(); - - try { - // we can only update if the version already exists - $this->storage()->update($id, $languageCode, $data); - } catch (NotFoundException) { - // otherwise create a new version - $this->storage()->create($id, $languageCode, $data); - } - + Helpers::deprecated('$model->writeContent() is deprecated. Use $model->version()->save() instead.'); // @codeCoverageIgnore + $this->version()->save($data, $languageCode ?? 'default', true); return true; } } diff --git a/public/kirby/src/Cms/NestCollection.php b/public/kirby/src/Cms/NestCollection.php index 605b0cb..1ef051b 100644 --- a/public/kirby/src/Cms/NestCollection.php +++ b/public/kirby/src/Cms/NestCollection.php @@ -13,6 +13,8 @@ use Kirby\Toolkit\Collection as BaseCollection; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Toolkit\Collection<\Kirby\Cms\NestObject|\Kirby\Content\Field> */ class NestCollection extends BaseCollection { @@ -23,6 +25,8 @@ class NestCollection extends BaseCollection */ public function toArray(Closure|null $map = null): array { - return parent::toArray($map ?? fn ($object) => $object->toArray()); + return parent::toArray( + $map ?? fn ($object) => $object->toArray() + ); } } diff --git a/public/kirby/src/Cms/Page.php b/public/kirby/src/Cms/Page.php index a1e04df..4c667c7 100644 --- a/public/kirby/src/Cms/Page.php +++ b/public/kirby/src/Cms/Page.php @@ -4,12 +4,12 @@ namespace Kirby\Cms; use Closure; use Kirby\Content\Field; +use Kirby\Content\VersionId; use Kirby\Exception\Exception; use Kirby\Exception\InvalidArgumentException; use Kirby\Exception\NotFoundException; use Kirby\Filesystem\Dir; use Kirby\Http\Response; -use Kirby\Http\Uri; use Kirby\Panel\Page as Panel; use Kirby\Template\Template; use Kirby\Toolkit\A; @@ -28,12 +28,15 @@ use Throwable; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Pages> */ class Page extends ModelWithContent { use HasChildren; use HasFiles; use HasMethods; + use HasModels; use HasSiblings; use PageActions; use PageSiblings; @@ -46,11 +49,6 @@ class Page extends ModelWithContent */ public static array $methods = []; - /** - * Registry with all Page models - */ - public static array $models = []; - /** * The PageBlueprint object */ @@ -125,11 +123,11 @@ class Page extends ModelWithContent public function __construct(array $props) { if (isset($props['slug']) === false) { - throw new InvalidArgumentException('The page slug is required'); + throw new InvalidArgumentException( + message: 'The page slug is required' + ); } - parent::__construct($props); - $this->slug = $props['slug']; // Sets the dirname manually, which works // more reliable in connection with the inventory @@ -140,7 +138,15 @@ class Page extends ModelWithContent $this->parent = $props['parent'] ?? null; $this->root = $props['root'] ?? null; + // Set blueprint before setting content + // or translations in the parent constructor. + // Otherwise, the blueprint definition cannot be + // used when creating the right field values + // for the content. $this->setBlueprint($props['blueprint'] ?? null); + + parent::__construct($props); + $this->setChildren($props['children'] ?? null); $this->setDrafts($props['drafts'] ?? null); $this->setFiles($props['files'] ?? null); @@ -173,13 +179,14 @@ class Page extends ModelWithContent */ public function __debugInfo(): array { - return array_merge($this->toArray(), [ + return [ + ...$this->toArray(), 'content' => $this->content(), 'children' => $this->children(), 'siblings' => $this->siblings(), 'translations' => $this->translations(), 'files' => $this->files(), - ]); + ]; } /** @@ -220,8 +227,10 @@ class Page extends ModelWithContent return $this->blueprints; } - $blueprints = []; - $templates = $this->blueprint()->changeTemplate() ?? $this->blueprint()->options()['changeTemplate'] ?? []; + $blueprints = []; + $templates = $this->blueprint()->changeTemplate() ?? null; + $templates ??= $this->blueprint()->options()['changeTemplate'] ?? []; + $currentTemplate = $this->intendedTemplate()->name(); if (is_array($templates) === false) { @@ -229,7 +238,7 @@ class Page extends ModelWithContent } // add the current template to the array if it's not already there - if (in_array($currentTemplate, $templates) === false) { + if (in_array($currentTemplate, $templates, true) === false) { array_unshift($templates, $currentTemplate); } @@ -255,7 +264,7 @@ class Page extends ModelWithContent /** * Builds the cache id for the page */ - protected function cacheId(string $contentType): string + protected function cacheId(string $contentType, VersionId $versionId): string { $cacheId = [$this->id()]; @@ -263,6 +272,7 @@ class Page extends ModelWithContent $cacheId[] = $this->kirby()->language()->code(); } + $cacheId[] = $versionId->value(); $cacheId[] = $contentType; return implode('.', $cacheId); @@ -282,23 +292,8 @@ class Page extends ModelWithContent ]); } - /** - * Returns the content text file - * which is found by the inventory method - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileName(string|null $languageCode = null): string - { - Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return $this->intendedTemplate()->name(); - } - /** * Call the page controller - * @internal * * @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page` */ @@ -307,12 +302,13 @@ class Page extends ModelWithContent string $contentType = 'html' ): array { // create the template data - $data = array_merge($data, [ + $data = [ + ...$data, 'kirby' => $kirby = $this->kirby(), 'site' => $site = $this->site(), 'pages' => new LazyValue(fn () => $site->children()), 'page' => new LazyValue(fn () => $site->visit($this)) - ]); + ]; // call the template controller if there's one. $controllerData = $kirby->controller( @@ -324,7 +320,7 @@ class Page extends ModelWithContent // merge controller data with original data safely // to provide original data to template even if // it wasn't returned by the controller explicitly - if (empty($controllerData) === false) { + if ($controllerData !== []) { $classes = [ 'kirby' => App::class, 'site' => Site::class, @@ -339,7 +335,9 @@ class Page extends ModelWithContent // original data was overwritten, but matches expected type $value instanceof $classes[$key] => $value, // throw error if data was overwritten with wrong type - default => throw new InvalidArgumentException('The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"') + default => throw new InvalidArgumentException( + message: 'The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"' + ) }; } } @@ -361,7 +359,7 @@ class Page extends ModelWithContent } /** - * Sorting number + Slug + * Returns the directory name (UID with optional sorting number) */ public function dirname(): string { @@ -377,7 +375,8 @@ class Page extends ModelWithContent } /** - * Sorting number + Slug + * Returns the directory path relative to the `content` root + * (including optional sorting numbers and draft directories) */ public function diruri(): string { @@ -385,11 +384,10 @@ class Page extends ModelWithContent return $this->diruri; } - if ($this->isDraft() === true) { - $dirname = '_drafts/' . $this->dirname(); - } else { - $dirname = $this->dirname(); - } + $dirname = match ($this->isDraft()) { + true => '_drafts/' . $this->dirname(), + false => $this->dirname() + }; if ($parent = $this->parent()) { return $this->diruri = $parent->diruri() . '/' . $dirname; @@ -409,11 +407,10 @@ class Page extends ModelWithContent /** * Constructs a Page object and also * takes page models into account. - * @internal */ public static function factory($props): static { - return static::model($props['model'] ?? 'default', $props); + return static::model($props['model'] ?? $props['template'] ?? 'default', $props); } /** @@ -466,13 +463,13 @@ class Page extends ModelWithContent return $this->intendedTemplate; } - return $this->setTemplate($this->inventory()['template'])->intendedTemplate(); + return $this + ->setTemplate($this->inventory()['template']) + ->intendedTemplate(); } /** - * Returns the inventory of files - * children and content files - * @internal + * Returns the inventory of files children and content files */ public function inventory(): array { @@ -513,22 +510,17 @@ class Page extends ModelWithContent } /** - * Checks if the page is accessible that accessible and listable. - * This permission depends on the `read` option until v5 + * Checks if the page is accessible to the current user + * This permission depends on the `read` option until v6 */ public function isAccessible(): bool { - // TODO: remove this check when `read` option deprecated in v5 + // TODO: remove this check when `read` option deprecated in v6 if ($this->isReadable() === false) { return false; } - static $accessible = []; - $role = $this->kirby()->user()?->role()->id() ?? '__none__'; - $template = $this->intendedTemplate()->name(); - $accessible[$role] ??= []; - - return $accessible[$role][$template] ??= $this->permissions()->can('access'); + return PagePermissions::canFromCache($this, 'access'); } /** @@ -553,7 +545,7 @@ class Page extends ModelWithContent * pages cache. This will also check if one * of the ignore rules from the config kick in. */ - public function isCacheable(): bool + public function isCacheable(VersionId|null $versionId = null): bool { $kirby = $this->kirby(); $cache = $kirby->cache('pages'); @@ -565,11 +557,16 @@ class Page extends ModelWithContent return false; } + // updating the changes version does not flush the pages cache + if ($versionId?->is('changes') === true) { + return false; + } + // inspect the current request $request = $kirby->request(); // disable the pages cache for any request types but GET or HEAD - if (in_array($request->method(), ['GET', 'HEAD']) === false) { + if (in_array($request->method(), ['GET', 'HEAD'], true) === false) { return false; } @@ -592,7 +589,7 @@ class Page extends ModelWithContent // ignore pages by id if (is_array($ignore) === true) { - if (in_array($this->id(), $ignore) === true) { + if (in_array($this->id(), $ignore, true) === true) { return false; } } @@ -676,11 +673,11 @@ class Page extends ModelWithContent /** * Check if the page can be listable by the current user - * This permission depends on the `read` option until v5 + * This permission depends on the `read` option until v6 */ public function isListable(): bool { - // TODO: remove this check when `read` option deprecated in v5 + // TODO: remove this check when `read` option deprecated in v6 if ($this->isReadable() === false) { return false; } @@ -690,12 +687,7 @@ class Page extends ModelWithContent return false; } - static $listable = []; - $role = $this->kirby()->user()?->role()->id() ?? '__none__'; - $template = $this->intendedTemplate()->name(); - $listable[$role] ??= []; - - return $listable[$role][$template] ??= $this->permissions()->can('list'); + return PagePermissions::canFromCache($this, 'list'); } /** @@ -709,7 +701,8 @@ class Page extends ModelWithContent public function isMovableTo(Page|Site $parent): bool { try { - return PageRules::move($this, $parent); + PageRules::move($this, $parent); + return true; } catch (Throwable) { return false; } @@ -743,12 +736,12 @@ class Page extends ModelWithContent /** * Check if the page can be read by the current user - * @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options. + * @todo Deprecate `read` option in v6 and make the necessary changes for `access` and `list` options. */ public function isReadable(): bool { static $readable = []; - $role = $this->kirby()->user()?->role()->id() ?? '__none__'; + $role = $this->kirby()->role()?->id() ?? '__none__'; $template = $this->intendedTemplate()->name(); $readable[$role] ??= []; @@ -772,64 +765,29 @@ class Page extends ModelWithContent } /** - * Checks if the page access is verified. - * This is only used for drafts so far. - * @internal + * Returns the absolute path to the media folder for the page */ - public function isVerified(string|null $token = null): bool - { - if ( - $this->isPublished() === true && - $this->parents()->findBy('status', 'draft') === null - ) { - return true; - } - - if ($token === null) { - return false; - } - - return $this->token() === $token; - } - - /** - * Returns the root to the media folder for the page - * @internal - */ - public function mediaRoot(): string + public function mediaDir(): string { return $this->kirby()->root('media') . '/pages/' . $this->id(); } + /** + * @see `::mediaDir` + */ + public function mediaRoot(): string + { + return $this->mediaDir(); + } + /** * The page's base URL for any files - * @internal */ public function mediaUrl(): string { return $this->kirby()->url('media') . '/pages/' . $this->id(); } - /** - * Creates a page model if it has been registered - * @internal - */ - public static function model(string $name, array $props = []): static - { - $class = static::$models[$name] ?? null; - $class ??= static::$models['default'] ?? null; - - if ($class !== null) { - $object = new $class($props); - - if ($object instanceof self) { - return $object; - } - } - - return new static($props); - } - /** * Returns the last modification date of the page */ @@ -838,11 +796,8 @@ class Page extends ModelWithContent string|null $handler = null, string|null $languageCode = null ): int|string|false|null { - $identifier = $this->isDraft() === true ? 'changes' : 'published'; - - $modified = $this->storage()->modified( - $identifier, - $languageCode + $modified = $this->version()->modified( + $languageCode ?? 'current' ); if ($modified === null) { @@ -878,7 +833,6 @@ class Page extends ModelWithContent /** * Returns the parent id, if a parent exists - * @internal */ public function parentId(): string|null { @@ -889,7 +843,6 @@ class Page extends ModelWithContent * Returns the parent model, * which can either be another Page * or the Site - * @internal */ public function parentModel(): Page|Site { @@ -904,7 +857,7 @@ class Page extends ModelWithContent $parents = new Pages(); $page = $this->parent(); - while ($page !== null) { + while ($page instanceof Page) { $parents->append($page->id(), $page); $page = $page->parent(); } @@ -930,30 +883,16 @@ class Page extends ModelWithContent } /** - * Draft preview Url - * @internal + * Returns the preview URL with authentication for drafts and versions + * @unstable */ - public function previewUrl(): string|null + public function previewUrl(VersionId|string $versionId = 'latest'): string|null { - $preview = $this->blueprint()->preview(); - - if ($preview === false) { + if ($this->permissions()->can('preview') !== true) { return null; } - $url = match ($preview) { - true => $this->url(), - default => $preview - }; - - if ($this->isDraft() === true) { - $uri = new Uri($url); - $uri->query->token = $this->token(); - - $url = $uri->toString(); - } - - return $url; + return $this->version($versionId)->url(); } /** @@ -964,17 +903,31 @@ class Page extends ModelWithContent * the default template. * * @param string $contentType + * @param \Kirby\Content\VersionId|string|null $versionId Optional override for the auto-detected version to render * @throws \Kirby\Exception\NotFoundException If the default template cannot be found */ - public function render(array $data = [], $contentType = 'html'): string - { + public function render( + array $data = [], + $contentType = 'html', + VersionId|string|null $versionId = null + ): string { $kirby = $this->kirby(); $cache = $cacheId = $html = null; + // if not manually overridden, first use a globally set + // version ID (e.g. when rendering within another render), + // otherwise auto-detect from the request and fall back to + // the latest version if request is unauthenticated (no valid token); + // make sure to convert it to an object no matter what happened + $versionId ??= VersionId::$render; + $versionId ??= $this->renderVersionFromRequest(); + $versionId ??= 'latest'; + $versionId = VersionId::from($versionId); + // try to get the page from cache - if (empty($data) === true && $this->isCacheable() === true) { + if ($data === [] && $this->isCacheable($versionId) === true) { $cache = $kirby->cache('pages'); - $cacheId = $this->cacheId($contentType); + $cacheId = $this->cacheId($contentType, $versionId); $result = $cache->get($cacheId); $html = $result['html'] ?? null; $response = $result['response'] ?? []; @@ -995,37 +948,39 @@ class Page extends ModelWithContent // fetch the page regularly if ($html === null) { - if ($contentType === 'html') { - $template = $this->template(); - } else { - $template = $this->representation($contentType); - } + // set `VersionId::$render` to the intended version (only) while we render + $html = VersionId::render($versionId, function () use ($kirby, $data, $contentType) { + $template = match ($contentType) { + 'html' => $this->template(), + default => $this->representation($contentType) + }; - if ($template->exists() === false) { - throw new NotFoundException([ - 'key' => 'template.default.notFound' - ]); - } + if ($template->exists() === false) { + throw new NotFoundException( + key: 'template.default.notFound' + ); + } - $kirby->data = $this->controller($data, $contentType); + $kirby->data = $this->controller($data, $contentType); - // trigger before hook and apply for `data` - $kirby->data = $kirby->apply('page.render:before', [ - 'contentType' => $contentType, - 'data' => $kirby->data, - 'page' => $this - ], 'data'); + // trigger before hook and apply for `data` + $kirby->data = $kirby->apply('page.render:before', [ + 'contentType' => $contentType, + 'data' => $kirby->data, + 'page' => $this + ], 'data'); - // render the page - $html = $template->render($kirby->data); + // render the page + $html = $template->render($kirby->data); - // trigger after hook and apply for `html` - $html = $kirby->apply('page.render:after', [ - 'contentType' => $contentType, - 'data' => $kirby->data, - 'html' => $html, - 'page' => $this - ], 'html'); + // trigger after hook and apply for `html` + return $kirby->apply('page.render:after', [ + 'contentType' => $contentType, + 'data' => $kirby->data, + 'html' => $html, + 'page' => $this + ], 'html'); + }); // cache the result $response = $kirby->response(); @@ -1043,7 +998,42 @@ class Page extends ModelWithContent } /** - * @internal + * Determines which version (if any) can be rendered + * based on the token authentication in the current request + * @unstable + */ + public function renderVersionFromRequest(): VersionId|null + { + $request = $this->kirby()->request(); + $token = $request->get('_token', ''); + + try { + $versionId = VersionId::from($request->get('_version', '')); + } catch (InvalidArgumentException) { + // ignore invalid enum values in the request + $versionId = VersionId::latest(); + } + + // authenticated requests can always be trusted + $expectedToken = $this->version($versionId)->previewToken(); + if ($token !== '' && hash_equals($expectedToken, $token) === true) { + return $versionId; + } + + // published pages with published parents can render + // the latest version without (valid) token + if ( + $this->isPublished() === true && + $this->parents()->findBy('status', 'draft') === null + ) { + return VersionId::latest(); + } + + // drafts cannot be accessed without authentication + return null; + } + + /** * @throws \Kirby\Exception\NotFoundException If the content representation cannot be found */ public function representation(mixed $type): Template @@ -1056,7 +1046,9 @@ class Page extends ModelWithContent return $representation; } - throw new NotFoundException('The content representation cannot be found'); + throw new NotFoundException( + message: 'The content representation cannot be found' + ); } /** @@ -1109,7 +1101,7 @@ class Page extends ModelWithContent protected function setTemplate(string|null $template = null): static { if ($template !== null) { - $this->intendedTemplate = $this->kirby()->template($template); + $this->intendedTemplate = $this->kirby()->template(strtolower($template)); } return $this; @@ -1199,7 +1191,8 @@ class Page extends ModelWithContent */ public function toArray(): array { - return array_merge(parent::toArray(), [ + return [ + ...parent::toArray(), 'children' => $this->children()->keys(), 'files' => $this->files()->keys(), 'id' => $this->id(), @@ -1212,19 +1205,7 @@ class Page extends ModelWithContent 'uid' => $this->uid(), 'uri' => $this->uri(), 'url' => $this->url() - ]); - } - - /** - * Returns a verification token, which - * is used for the draft authentication - */ - protected function token(): string - { - return $this->kirby()->contentToken( - $this, - $this->id() . $this->template() - ); + ]; } /** diff --git a/public/kirby/src/Cms/PageActions.php b/public/kirby/src/Cms/PageActions.php index 4e27018..ba959d5 100644 --- a/public/kirby/src/Cms/PageActions.php +++ b/public/kirby/src/Cms/PageActions.php @@ -3,12 +3,14 @@ namespace Kirby\Cms; use Closure; +use Kirby\Content\ImmutableMemoryStorage; +use Kirby\Content\MemoryStorage; +use Kirby\Content\VersionCache; +use Kirby\Content\VersionId; use Kirby\Exception\DuplicateException; use Kirby\Exception\InvalidArgumentException; use Kirby\Exception\LogicException; -use Kirby\Exception\NotFoundException; use Kirby\Filesystem\Dir; -use Kirby\Form\Form; use Kirby\Toolkit\A; use Kirby\Toolkit\I18n; use Kirby\Toolkit\Str; @@ -26,76 +28,6 @@ use Kirby\Uuid\Uuids; */ trait PageActions { - /** - * Adapts necessary modifications which page uuid, page slug and files uuid - * of copy objects for single or multilang environments - * @internal - */ - protected function adaptCopy(Page $copy, bool $files = false, bool $children = false): Page - { - if ($this->kirby()->multilang() === true) { - foreach ($this->kirby()->languages() as $language) { - // overwrite with new UUID for the page and files - // for default language (remove old, add new) - if ( - Uuids::enabled() === true && - $language->isDefault() === true - ) { - $copy = $copy->save(['uuid' => Uuid::generate()], $language->code()); - - // regenerate UUIDs of page files - if ($files !== false) { - foreach ($copy->files() as $file) { - $file->save(['uuid' => Uuid::generate()], $language->code()); - } - } - - // regenerate UUIDs of all page children - if ($children !== false) { - foreach ($copy->index(true) as $child) { - // always adapt files of subpages as they are currently always copied; - // but don't adapt children because we already operate on the index - $this->adaptCopy($child, true); - } - } - } - - // remove all translated slugs - if ( - $language->isDefault() === false && - $copy->translation($language)->exists() === true - ) { - $copy = $copy->save(['slug' => null], $language->code()); - } - } - - return $copy; - } - - // overwrite with new UUID for the page and files (remove old, add new) - if (Uuids::enabled() === true) { - $copy = $copy->save(['uuid' => Uuid::generate()]); - - // regenerate UUIDs of page files - if ($files !== false) { - foreach ($copy->files() as $file) { - $file->save(['uuid' => Uuid::generate()]); - } - } - - // regenerate UUIDs of all page children - if ($children !== false) { - foreach ($copy->index(true) as $child) { - // always adapt files of subpages as they are currently always copied; - // but don't adapt children because we already operate on the index - $this->adaptCopy($child, true); - } - } - } - - return $copy; - } - /** * Changes the sorting number. * The sorting number must already be correct @@ -109,7 +41,9 @@ trait PageActions public function changeNum(int|null $num = null): static { if ($this->isDraft() === true) { - throw new LogicException('Drafts cannot change their sorting number'); + throw new LogicException( + message: 'Drafts cannot change their sorting number' + ); } // don't run the action if everything stayed the same @@ -119,9 +53,10 @@ trait PageActions return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) { $newPage = $oldPage->clone([ - 'num' => $num, - 'dirname' => null, - 'root' => null + 'num' => $num, + 'dirname' => null, + 'root' => null, + 'template' => $oldPage->intendedTemplate()->name(), ]); // actually move the page on disk @@ -131,13 +66,12 @@ trait PageActions // of the moved new page to use fly actions on old page in loop $oldPage->root = $newPage->root(); } else { - throw new LogicException('The page directory cannot be moved'); + throw new LogicException( + message: 'The page directory cannot be moved' + ); } } - // overwrite the child in the parent page - static::updateParentCollections($newPage, 'set'); - return $newPage; }); } @@ -153,13 +87,14 @@ trait PageActions string|null $languageCode = null ): static { // always sanitize the slug - $slug = Url::slug($slug); + $slug = Url::slug($slug); + $language = Language::ensure($languageCode ?? 'current'); // in multi-language installations the slug for the non-default // languages is stored in the text file. The changeSlugForLanguage // method takes care of that. - if ($this->kirby()->language($languageCode)?->isDefault() === false) { - return $this->changeSlugForLanguage($slug, $languageCode); + if ($language->isDefault() === false) { + return $this->changeSlugForLanguage($slug, $language->code()); } // if the slug stays exactly the same, @@ -168,35 +103,45 @@ trait PageActions return $this; } - $arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null]; - return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) { + $arguments = [ + 'page' => $this, + 'slug' => $slug, + 'languageCode' => null, + 'language' => $language + ]; + + return $this->commit('changeSlug', $arguments, function ($oldPage, $slug, $languageCode, $language) { $newPage = $oldPage->clone([ - 'slug' => $slug, - 'dirname' => null, - 'root' => null + 'slug' => $slug, + 'dirname' => null, + 'root' => null, + 'template' => $oldPage->intendedTemplate()->name(), ]); // clear UUID cache recursively (for children and files as well) $oldPage->uuid()?->clear(true); if ($oldPage->exists() === true) { - // remove the lock of the old page - $oldPage->lock()?->remove(); - // actually move stuff on disk if (Dir::move($oldPage->root(), $newPage->root()) !== true) { - throw new LogicException('The page directory cannot be moved'); + throw new LogicException( + message: 'The page directory cannot be moved' + ); } + // hard reset for the version cache + // to avoid broken/overlapping page references + VersionCache::reset(); + // remove from the siblings - static::updateParentCollections($oldPage, 'remove'); + ModelState::update( + method: 'remove', + current: $oldPage, + ); Dir::remove($oldPage->mediaRoot()); } - // overwrite the new page in the parent collection - static::updateParentCollections($newPage, 'set'); - return $newPage; }); } @@ -211,29 +156,34 @@ trait PageActions string $slug, string|null $languageCode = null ): static { - $language = $this->kirby()->language($languageCode); - - if (!$language) { - throw new NotFoundException('The language: "' . $languageCode . '" does not exist'); - } + $language = Language::ensure($languageCode ?? 'current'); if ($language->isDefault() === true) { - throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language'); + throw new InvalidArgumentException( + message: 'Use the changeSlug method to change the slug for the default language' + ); } - $arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $language->code()]; - return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) { + $arguments = [ + 'page' => $this, + 'slug' => $slug, + 'languageCode' => $language->code(), + 'language' => $language + ]; + + return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode, $language) { // remove the slug if it's the same as the folder name if ($slug === $page->uid()) { $slug = null; } - $newPage = $page->save(['slug' => $slug], $languageCode); + // make sure to update the slug in the changes version as well + // otherwise the new slug would be lost as soon as the changes are saved + if ($page->version('changes')->exists($language) === true) { + $page->version('changes')->update(['slug' => $slug], $language); + } - // overwrite the updated page in the parent collection - static::updateParentCollections($newPage, 'set'); - - return $newPage; + return $page->save(['slug' => $slug], $languageCode); }); } @@ -247,13 +197,17 @@ trait PageActions * @param int|null $position Optional sorting number * @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed */ - public function changeStatus(string $status, int|null $position = null): static - { + public function changeStatus( + string $status, + int|null $position = null + ): static { return match ($status) { 'draft' => $this->changeStatusToDraft(), 'listed' => $this->changeStatusToListed($position), 'unlisted' => $this->changeStatusToUnlisted(), - default => throw new InvalidArgumentException('Invalid status: ' . $status) + default => throw new InvalidArgumentException( + message: 'Invalid status: ' . $status + ) }; } @@ -350,12 +304,7 @@ trait PageActions return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) { // convert for new template/blueprint - $page = $oldPage->convertTo($template); - - // update the parent collection - static::updateParentCollections($page, 'set'); - - return $page; + return $oldPage->convertTo($template); }); } @@ -366,36 +315,34 @@ trait PageActions string $title, string|null $languageCode = null ): static { - // if the `$languageCode` argument is not set and is not the default language - // the `$languageCode` argument is sent as the current language - if ( - $languageCode === null && - $language = $this->kirby()->language() - ) { - if ($language->isDefault() === false) { - $languageCode = $language->code(); + $language = Language::ensure($languageCode ?? 'current'); + + $arguments = [ + 'page' => $this, + 'title' => $title, + 'languageCode' => $languageCode, + 'language' => $language + ]; + + return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode, $language) { + + // make sure to update the title in the changes version as well + // otherwise the new title would be lost as soon as the changes are saved + if ($page->version('changes')->exists($language) === true) { + $page->version('changes')->update(['title' => $title], $language); } - } - $arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode]; - - return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) { - $page = $page->save(['title' => $title], $languageCode); - - // flush the parent cache to get children and drafts right - static::updateParentCollections($page, 'set'); - - return $page; + return $page->save(['title' => $title], $language->code()); }); } /** * Commits a page action, by following these steps * - * 1. checks the action rules - * 2. sends the before hook + * 1. applies the `before` hook + * 2. checks the action rules * 3. commits the store action - * 4. sends the after hook + * 4. applies the `after` hook * 5. returns the result */ protected function commit( @@ -403,57 +350,37 @@ trait PageActions array $arguments, Closure $callback ): mixed { - $old = $this->hardcopy(); - $kirby = $this->kirby(); - $argumentValues = array_values($arguments); + $commit = new ModelCommit( + model: $this, + action: $action + ); - $this->rules()->$action(...$argumentValues); - $kirby->trigger('page.' . $action . ':before', $arguments); - - $result = $callback(...$argumentValues); - - if ($action === 'create') { - $argumentsAfter = ['page' => $result]; - } elseif ($action === 'duplicate') { - $argumentsAfter = ['duplicatePage' => $result, 'originalPage' => $old]; - } elseif ($action === 'delete') { - $argumentsAfter = ['status' => $result, 'page' => $old]; - } else { - $argumentsAfter = ['newPage' => $result, 'oldPage' => $old]; - } - $kirby->trigger('page.' . $action . ':after', $argumentsAfter); - - $kirby->cache('pages')->flush(); - return $result; + return $commit->call($arguments, $callback); } /** * Copies the page to a new parent * * @throws \Kirby\Exception\DuplicateException If the page already exists - * - * @internal */ public function copy(array $options = []): static { - $slug = $options['slug'] ?? $this->slug(); - $isDraft = $options['isDraft'] ?? $this->isDraft(); - $parent = $options['parent'] ?? null; - $parentModel = $options['parent'] ?? $this->site(); - $num = $options['num'] ?? null; - $children = $options['children'] ?? false; - $files = $options['files'] ?? false; + $slug = $options['slug'] ?? $this->slug(); + $isDraft = $options['isDraft'] ?? $this->isDraft(); + $parent = $options['parent'] ?? null; + $parentModel = $options['parent'] ?? $this->site(); + $num = $options['num'] ?? null; + $children = $options['children'] ?? false; + $files = $options['files'] ?? false; // clean up the slug $slug = Url::slug($slug); if ($parentModel->findPageOrDraft($slug)) { - throw new DuplicateException([ - 'key' => 'page.duplicate', - 'data' => [ - 'slug' => $slug - ] - ]); + throw new DuplicateException( + key: 'page.duplicate', + data: ['slug' => $slug] + ); } $tmp = new static([ @@ -463,9 +390,7 @@ trait PageActions 'slug' => $slug, ]); - $ignore = [ - $this->kirby()->locks()->file($this) - ]; + $ignore = []; // don't copy files if ($files === false) { @@ -473,8 +398,8 @@ trait PageActions $ignore[] = $file->root(); // append all content files - array_push($ignore, ...$file->storage()->contentFiles('published')); - array_push($ignore, ...$file->storage()->contentFiles('changes')); + array_push($ignore, ...$file->storage()->contentFiles(VersionId::latest())); + array_push($ignore, ...$file->storage()->contentFiles(VersionId::changes())); } } @@ -483,10 +408,19 @@ trait PageActions $copy = $parentModel->clone()->findPageOrDraft($slug); // normalize copy object - $copy = $this->adaptCopy($copy, $files, $children); + $copy = PageCopy::process( + copy: $copy, + original: $this, + withFiles: $files, + withChildren: $children + ); // add copy to siblings - static::updateParentCollections($copy, 'append', $parentModel); + ModelState::update( + method: 'append', + current: $copy, + parent: $parentModel + ); return $copy; } @@ -496,41 +430,39 @@ trait PageActions */ public static function create(array $props): Page { - // clean up the slug - $props['slug'] = Url::slug($props['slug'] ?? $props['content']['title'] ?? null); - $props['template'] = $props['model'] = strtolower($props['template'] ?? 'default'); - $props['isDraft'] ??= $props['draft'] ?? true; + $props = self::normalizeProps($props); - // make sure that a UUID gets generated and - // added to content right away - $props['content'] ??= []; + // create the instance without content or translations + // to avoid that the page is created in memory storage + $page = Page::factory([ + ...$props, + 'content' => null, + 'translations' => null + ]); + // merge the content with the defaults + $props['content'] = [ + ...$page->createDefaultContent(), + ...$props['content'], + ]; + + // make sure that a UUID gets generated + // and added to content right away if (Uuids::enabled() === true) { $props['content']['uuid'] ??= Uuid::generate(); } - // create a temporary page object - $page = Page::factory($props); + // keep the initial storage class + $storage = $page->storage()::class; - // always create pages in the default language - if ($page->kirby()->multilang() === true) { - $languageCode = $page->kirby()->defaultLanguage()->code(); - } else { - $languageCode = null; - } - - // create a form for the page - // use always default language to fill form with default values - $form = Form::for( - $page, - [ - 'language' => $languageCode, - 'values' => $props['content'] - ] - ); + // make sure that the temporary page is stored in memory + $page->changeStorage(MemoryStorage::class); // inject the content - $page = $page->clone(['content' => $form->strings(true)]); + $page->setContent($props['content']); + + // inject the translations + $page->setTranslations($props['translations'] ?? null); // run the hooks and creation action $page = $page->commit( @@ -539,14 +471,9 @@ trait PageActions 'page' => $page, 'input' => $props ], - function ($page, $props) use ($languageCode) { - // write the content file - $page = $page->save($page->content()->toArray(), $languageCode); - - // flush the parent cache to get children and drafts right - static::updateParentCollections($page, 'append'); - - return $page; + function ($page) use ($storage) { + // move to final storage + return $page->changeStorage($storage); } ); @@ -563,14 +490,15 @@ trait PageActions */ public function createChild(array $props): Page { - $props = array_merge($props, [ + $props = [ + ...$props, 'url' => null, 'num' => null, 'parent' => $this, 'site' => $this->site(), - ]); + ]; - $modelClass = Page::$models[$props['template'] ?? null] ?? Page::class; + $modelClass = static::$models[$props['template'] ?? null] ?? static::class; return $modelClass::create($props); } @@ -590,8 +518,7 @@ trait PageActions // the $format needs to produce only digits, // so it can be converted to integer below $format = $mode === 'date' ? 'Ymd' : 'YmdHi'; - $lang = $this->kirby()->defaultLanguage() ?? null; - $field = $this->content($lang)->get('date'); + $field = $this->content('default')->get('date'); $date = $field->isEmpty() ? 'now' : $field; return (int)date($format, strtotime($date)); case 'default': @@ -624,7 +551,7 @@ trait PageActions $template = Str::template($mode, [ 'kirby' => $app, - 'page' => $app->page($this->id()), + 'page' => $this, 'site' => $app->site(), ], ['fallback' => '']); @@ -638,42 +565,35 @@ trait PageActions public function delete(bool $force = false): bool { return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) { + $old = $page->clone(); + + // keep the content in iummtable memory storage + // to still have access to it in after hooks + $page->changeStorage(ImmutableMemoryStorage::class); + // clear UUID cache $page->uuid()?->clear(); // delete all files individually - foreach ($page->files() as $file) { + foreach ($old->files() as $file) { $file->delete(); } // delete all children individually - foreach ($page->children() as $child) { + foreach ($old->childrenAndDrafts() as $child) { $child->delete(true); } - // actually remove the page from disc - if ($page->exists() === true) { - // delete all public media files - Dir::remove($page->mediaRoot()); + // delete all versions, + // the plain text storage handler will then clean + // up the directory if it's empty + $old->versions()->delete(); - // delete the content folder for this page - Dir::remove($page->root()); - - // if the page is a draft and the _drafts folder - // is now empty. clean it up. - if ($page->isDraft() === true) { - $draftsDir = dirname($page->root()); - - if (Dir::isEmpty($draftsDir) === true) { - Dir::remove($draftsDir); - } - } - } - - static::updateParentCollections($page, 'remove'); - - if ($page->isDraft() === false) { - $page->resortSiblingsAfterUnlisting(); + if ( + $old->isListed() === true && + $old->blueprint()->num() === 'default' + ) { + $old->resortSiblingsAfterUnlisting(); } return true; @@ -733,17 +653,16 @@ trait PageActions $page->uuid()?->clear(true); // move drafts into the drafts folder of the parent - if ($page->isDraft() === true) { - $newRoot = $parent->root() . '/_drafts/' . $page->dirname(); - } else { - $newRoot = $parent->root() . '/' . $page->dirname(); - } + $newRoot = match ($page->isDraft()) { + true => $parent->root() . '/_drafts/' . $page->dirname(), + false => $parent->root() . '/' . $page->dirname() + }; // try to move the page directory on disk if (Dir::move($page->root(), $newRoot) !== true) { - throw new LogicException([ - 'key' => 'page.move.directory' - ]); + throw new LogicException( + key: 'page.move.directory' + ); } // flush all collection caches to be sure that @@ -752,19 +671,33 @@ trait PageActions // double-check if the new child can actually be found if (!$newPage = $parent->childrenAndDrafts()->find($page->slug())) { - throw new LogicException([ - 'key' => 'page.move.notFound' - ]); + throw new LogicException( + key: 'page.move.notFound' + ); } return $newPage; }); } + protected static function normalizeProps(array $props): array + { + $content = $props['content'] ?? []; + $template = $props['template'] ?? 'default'; + + return [ + ...$props, + 'content' => $content, + 'isDraft' => $props['isDraft'] ?? $props['draft'] ?? true, + 'model' => $props['model'] ?? $template, + 'slug' => Url::slug($props['slug'] ?? $content['title'] ?? null), + 'template' => $template, + ]; + } + /** * @return $this|static * @throws \Kirby\Exception\LogicException If the folder cannot be moved - * @internal */ public function publish(): static { @@ -773,14 +706,17 @@ trait PageActions } $page = $this->clone([ - 'isDraft' => false, - 'root' => null + 'isDraft' => false, + 'root' => null, + 'template' => $this->intendedTemplate()->name(), ]); // actually do it on disk if ($this->exists() === true) { if (Dir::move($this->root(), $page->root()) !== true) { - throw new LogicException('The draft folder cannot be moved'); + throw new LogicException( + message: 'The draft folder cannot be moved' + ); } // Get the draft folder and check if there are any other drafts @@ -829,29 +765,32 @@ trait PageActions */ protected function resortSiblingsAfterListing(int|null $position = null): bool { - // get all siblings including the current page - $siblings = $this - ->parentModel() - ->children() + $parent = $this->parentModel(); + $siblings = $parent->children(); + + // Get all listed siblings including the current page + $listed = $siblings ->listed() ->append($this) ->filter(fn ($page) => $page->blueprint()->num() === 'default'); - // get a non-associative array of ids - $keys = $siblings->keys(); + // Get a non-associative array of ids + $keys = $listed->keys(); $index = array_search($this->id(), $keys); - // if the page is not included in the siblings something went wrong + // If the page is not included in the siblings something went wrong if ($index === false) { - throw new LogicException('The page is not included in the sorting index'); + throw new LogicException( + message: 'The page is not included in the sorting index' + ); } if ($position > count($keys)) { $position = count($keys); } - // move the current page number in the array of keys - // subtract 1 from the num and the position, because of the + // Move the current page number in the array of keys. + // Subtract 1 from the num and the position, because of the // zero-based array keys $sorted = A::move($keys, $index, $position - 1); @@ -860,11 +799,14 @@ trait PageActions continue; } - $siblings->get($id)?->changeNum($key + 1); + // Apply the new sorting number + // and update the new object in the siblings collection + $newSibling = $listed->get($id)?->changeNum($key + 1); + $siblings->update($newSibling); } - $parent = $this->parentModel(); - $parent->children = $parent->children()->sort('num', 'asc'); + // Update the parent's children collection with the new sorting + $parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc'); $parent->childrenAndDrafts = null; return true; @@ -877,19 +819,26 @@ trait PageActions { $index = 0; $parent = $this->parentModel(); - $siblings = $parent - ->children() + $siblings = $parent->children(); + + // Get all listed siblings excluding the current page + $listed = $siblings ->listed() ->not($this) ->filter(fn ($page) => $page->blueprint()->num() === 'default'); - if ($siblings->count() > 0) { - foreach ($siblings as $sibling) { + if ($listed->count() > 0) { + foreach ($listed as $sibling) { $index++; - $sibling->changeNum($index); + + // Apply the new sorting number + // and update the new object in the siblings collection + $newSibling = $sibling->changeNum($index); + $siblings->update($newSibling); } - $parent->children = $siblings->sort('num', 'asc'); + // Update the parent's children collection with the new sorting + $parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc'); $parent->childrenAndDrafts = null; } @@ -897,26 +846,7 @@ trait PageActions } /** - * Stores the content on disk - * @internal - */ - public function save( - array|null $data = null, - string|null $languageCode = null, - bool $overwrite = false - ): static { - $page = parent::save($data, $languageCode, $overwrite); - - // overwrite the updated page in the parent collection - static::updateParentCollections($page, 'set'); - - return $page; - } - - /** - * Convert a page from listed or - * unlisted to draft. - * @internal + * Convert a page from listed or unlisted to draft * * @return $this|static * @throws \Kirby\Exception\LogicException If the folder cannot be moved @@ -928,16 +858,19 @@ trait PageActions } $page = $this->clone([ - 'isDraft' => true, - 'num' => null, - 'dirname' => null, - 'root' => null + 'isDraft' => true, + 'num' => null, + 'dirname' => null, + 'root' => null, + 'template' => $this->intendedTemplate()->name(), ]); // actually do it on disk if ($this->exists() === true) { if (Dir::move($this->root(), $page->root()) !== true) { - throw new LogicException('The page folder cannot be moved to drafts'); + throw new LogicException( + message: 'The page folder cannot be moved to drafts' + ); } } @@ -973,14 +906,11 @@ trait PageActions // if num is created from page content, update num on content update if ( $page->isListed() === true && - in_array($page->blueprint()->num(), ['zero', 'default']) === false + in_array($page->blueprint()->num(), ['zero', 'default'], true) === false ) { $page = $page->changeNum($page->createNum()); } - // overwrite the updated page in the parent collection - static::updateParentCollections($page, 'set'); - return $page; } @@ -988,29 +918,18 @@ trait PageActions * Updates parent collections with the new page object * after a page action * - * @param \Kirby\Cms\Page $page - * @param string $method Method to call on the parent collections - * @param \Kirby\Cms\Page|null $parentMdel + * @deprecated 5.0.0 Use ModelState::update instead */ protected static function updateParentCollections( - $page, - string $method, - $parentModel = null + Page $page, + string|false $method, + Page|Site|null $parentModel = null ): void { - $parentModel ??= $page->parentModel(); - - // method arguments depending on the called method - $args = $method === 'remove' ? [$page] : [$page->id(), $page]; - - if ($page->isDraft() === true) { - $parentModel->drafts()->$method(...$args); - } else { - $parentModel->children()->$method(...$args); - } - - // update the childrenAndDrafts() cache if it is initialized - if ($parentModel->childrenAndDrafts !== null) { - $parentModel->childrenAndDrafts()->$method(...$args); - } + ModelState::update( + method: $method, + current: $page, + next: $page, + parent: $parentModel + ); } } diff --git a/public/kirby/src/Cms/PageBlueprint.php b/public/kirby/src/Cms/PageBlueprint.php index 6015fbb..d65e7c8 100644 --- a/public/kirby/src/Cms/PageBlueprint.php +++ b/public/kirby/src/Cms/PageBlueprint.php @@ -67,8 +67,6 @@ class PageBlueprint extends Blueprint /** * Normalizes the ordering number - * - * @param mixed $num */ protected function normalizeNum($num): string { @@ -82,8 +80,6 @@ class PageBlueprint extends Blueprint /** * Normalizes the available status options for the page - * - * @param mixed $status */ protected function normalizeStatus($status): array { @@ -113,7 +109,7 @@ class PageBlueprint extends Blueprint // clean up and translate each status foreach ($status as $key => $options) { // skip invalid status definitions - if (in_array($key, ['draft', 'listed', 'unlisted']) === false || $options === false) { + if (in_array($key, ['draft', 'listed', 'unlisted'], true) === false || $options === false) { unset($status[$key]); continue; } diff --git a/public/kirby/src/Cms/PageCopy.php b/public/kirby/src/Cms/PageCopy.php new file mode 100644 index 0000000..c087d58 --- /dev/null +++ b/public/kirby/src/Cms/PageCopy.php @@ -0,0 +1,236 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class PageCopy +{ + public function __construct( + public Page $copy, + public Page|null $original = null, + public bool $withFiles = false, + public bool $withChildren = false, + public array $uuids = [] + ) { + } + + /** + * Converts UUIDs for copied pages, + * replacing the old UUID with a newly generated one + * for all newly generated pages and files + */ + public function convertUuids(Language|null $language): void + { + if (Uuids::enabled() === false) { + return; + } + + if ( + $language instanceof Language && + $language->isDefault() === false + ) { + return; + } + + // store old UUID + $old = $this->copy->uuid()->toString(); + + // re-generate UUID for the page + $this->copy = $this->copy->save( + ['uuid' => Uuid::generate()], + $language?->code() + ); + + // track UUID change + $this->uuids[$old] = $this->copy->uuid()->toString(); + + $this->convertFileUuids($language); + $this->convertChildrenUuids($language); + } + + /** + * Re-generate UUIDs for each child recursively + * and merge with the tracked changed UUIDs + */ + protected function convertChildrenUuids(Language|null $language): void + { + // re-generate UUIDs and track changes + if ($this->withChildren === true) { + foreach ($this->copy->childrenAndDrafts() as $child) { + // always adapt files of subpages as they are + // currently always copied; adapt children recursively + $child = new PageCopy( + $child, + withChildren: true, + withFiles: true, + uuids: $this->uuids + ); + $child->convertUuids($language); + $this->uuids = [...$this->uuids, ...$child->uuids]; + } + } + + // if children have not been copied over, + // track all children UUIDs from original page to + // remove/replace with empty string + if ($this->withChildren === false) { + foreach ($this->original?->index(drafts: true) ?? [] as $child) { + $this->uuids[$child->uuid()->toString()] = ''; + + foreach ($child->files() as $file) { + $this->uuids[$file->uuid()->toString()] = ''; + } + } + } + } + + /** + * Re-generate UUID for each file and track the change + */ + protected function convertFileUuids(Language|null $language): void + { + // re-generate UUIDs and track changes + if ($this->withFiles === true) { + foreach ($this->copy->files() as $file) { + // store old file UUID + $old = $file->uuid()->toString(); + + // re-generate UUID for the file + $file = $file->save( + ['uuid' => Uuid::generate()], + $language?->code() + ); + + // track UUID change + $this->uuids[$old] = $file->uuid()->toString(); + } + } + + // if files have not been copied over, + // track file UUIDs from original page to + // remove/replace with empty string + if ($this->withFiles === false) { + foreach ($this->original?->files() ?? [] as $file) { + $this->uuids[$file->uuid()->toString()] = ''; + } + } + } + + /** + * Returns all languages to adapt + * + * @todo Refactor once singe-lang mode also works with a language object + */ + public function languages(): Languages|iterable + { + $kirby = App::instance(); + + if ($kirby->multilang() === true) { + return $kirby->languages(); + } + + return [null]; + } + + /** + * Processes the copy with all necessary adaptations. + * Main method to use if not familiar with individual steps. + */ + public static function process( + Page $copy, + Page|null $original = null, + bool $withFiles = false, + bool $withChildren = false + ): Page { + $converter = new static($copy, $original, $withFiles, $withChildren); + + // loop through all languages to remove slug from non-default + // languages and re-generate UUIDs (and track changes) + foreach ($converter->languages() as $language) { + $converter->removeSlug($language); + $converter->convertUuids($language); + } + + // apply all tracked UUID changes at once + $converter->replaceUuids(); + + return $converter->copy; + } + + /** + * Removes translated slug for copied page. + * This is needed to avoid translated slug + * collisions with the original page. + */ + public function removeSlug(Language|null $language): void + { + // single lang setup + if ($language === null) { + return; + } + + // don't remove slug from default language + if ($language->isDefault() === true) { + return; + } + + if ($this->copy->translation($language)->exists() === true) { + $this->copy = $this->copy->save( + ['slug' => null], + $language->code() + ); + } + } + + /** + * Replace old UUIDs with new UUIDs in the content + */ + public function replaceUuids(): void + { + if (Uuids::enabled() === false) { + return; + } + + foreach ($this->copy->storage()->all() as $versionId => $language) { + $this->copy->storage()->replaceStrings( + $versionId, + $language, + $this->uuids + ); + } + + if ($this->withFiles === true) { + foreach ($this->copy->files() as $file) { + foreach ($file->storage()->all() as $versionId => $language) { + $file->storage()->replaceStrings( + $versionId, + $language, + $this->uuids + ); + } + } + } + + if ($this->withChildren === true) { + foreach ($this->copy->childrenAndDrafts() as $child) { + $child = new PageCopy($child, withFiles: true, withChildren: true, uuids: $this->uuids); + $child->replaceUuids(); + } + } + } +} diff --git a/public/kirby/src/Cms/PagePermissions.php b/public/kirby/src/Cms/PagePermissions.php index b4ca118..0161079 100644 --- a/public/kirby/src/Cms/PagePermissions.php +++ b/public/kirby/src/Cms/PagePermissions.php @@ -13,7 +13,15 @@ namespace Kirby\Cms; */ class PagePermissions extends ModelPermissions { - protected string $category = 'pages'; + protected const CATEGORY = 'pages'; + + /** + * Used to cache once determined permissions in memory + */ + protected static function cacheKey(ModelWithContent|Language $model): string + { + return $model->intendedTemplate()->name(); + } protected function canChangeSlug(): bool { diff --git a/public/kirby/src/Cms/PagePicker.php b/public/kirby/src/Cms/PagePicker.php index e51464a..7c4dda8 100644 --- a/public/kirby/src/Cms/PagePicker.php +++ b/public/kirby/src/Cms/PagePicker.php @@ -29,12 +29,13 @@ class PagePicker extends Picker */ public function defaults(): array { - return array_merge(parent::defaults(), [ + return [ + ...parent::defaults(), // Page ID of the selected parent. Used to navigate - 'parent' => null, + 'parent' => null, // enable/disable subpage navigation 'subpages' => true, - ]); + ]; } /** @@ -126,13 +127,13 @@ class PagePicker extends Picker if (empty($this->options['query']) === true) { $items = $this->itemsForParent(); - // when subpage navigation is enabled, a parent - // might be passed in addition to the query. - // The parent then takes priority. + // when subpage navigation is enabled, a parent + // might be passed in addition to the query. + // The parent then takes priority. } elseif ($this->options['subpages'] === true && empty($this->options['parent']) === false) { $items = $this->itemsForParent(); - // search by query + // search by query } else { $items = $this->itemsForQuery(); } @@ -178,7 +179,9 @@ class PagePicker extends Picker $items instanceof Page => $items->children(), $items instanceof Pages => $items, - default => throw new InvalidArgumentException('Your query must return a set of pages') + default => throw new InvalidArgumentException( + message: 'Your query must return a set of pages' + ) }; return $this->itemsForQuery = $items; diff --git a/public/kirby/src/Cms/PageRules.php b/public/kirby/src/Cms/PageRules.php index bef0993..b31fd75 100644 --- a/public/kirby/src/Cms/PageRules.php +++ b/public/kirby/src/Cms/PageRules.php @@ -25,13 +25,11 @@ class PageRules * * @throws \Kirby\Exception\InvalidArgumentException If the given number is invalid */ - public static function changeNum(Page $page, int|null $num = null): bool + public static function changeNum(Page $page, int|null $num = null): void { if ($num !== null && $num < 0) { - throw new InvalidArgumentException(['key' => 'page.num.invalid']); + throw new InvalidArgumentException(key: 'page.num.invalid'); } - - return true; } /** @@ -40,15 +38,13 @@ class PageRules * @throws \Kirby\Exception\DuplicateException If a page with this slug already exists * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the slug */ - public static function changeSlug(Page $page, string $slug): bool + public static function changeSlug(Page $page, string $slug): void { - if ($page->permissions()->changeSlug() !== true) { - throw new PermissionException([ - 'key' => 'page.changeSlug.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('changeSlug') !== true) { + throw new PermissionException( + key: 'page.changeSlug.permission', + data: ['slug' => $page->slug()] + ); } self::validateSlugLength($slug); @@ -58,24 +54,18 @@ class PageRules $drafts = $page->parentModel()->drafts(); if ($siblings->find($slug)?->is($page) === false) { - throw new DuplicateException([ - 'key' => 'page.duplicate', - 'data' => [ - 'slug' => $slug - ] - ]); + throw new DuplicateException( + key: 'page.duplicate', + data: ['slug' => $slug] + ); } if ($drafts->find($slug)?->is($page) === false) { - throw new DuplicateException([ - 'key' => 'page.draft.duplicate', - 'data' => [ - 'slug' => $slug - ] - ]); + throw new DuplicateException( + key: 'page.draft.duplicate', + data: ['slug' => $slug] + ); } - - return true; } /** @@ -87,16 +77,18 @@ class PageRules Page $page, string $status, int|null $position = null - ): bool { + ): void { if (isset($page->blueprint()->status()[$status]) === false) { - throw new InvalidArgumentException(['key' => 'page.status.invalid']); + throw new InvalidArgumentException(key: 'page.status.invalid'); } - return match ($status) { + match ($status) { 'draft' => static::changeStatusToDraft($page), 'listed' => static::changeStatusToListed($page, $position), 'unlisted' => static::changeStatusToUnlisted($page), - default => throw new InvalidArgumentException(['key' => 'page.status.invalid']) + default => throw new InvalidArgumentException( + key: 'page.status.invalid' + ) }; } @@ -105,27 +97,21 @@ class PageRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the page cannot be converted to a draft */ - public static function changeStatusToDraft(Page $page): bool + public static function changeStatusToDraft(Page $page): void { - if ($page->permissions()->changeStatus() !== true) { - throw new PermissionException([ - 'key' => 'page.changeStatus.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('changeStatus') !== true) { + throw new PermissionException( + key: 'page.changeStatus.permission', + data: ['slug' => $page->slug()] + ); } if ($page->isHomeOrErrorPage() === true) { - throw new PermissionException([ - 'key' => 'page.changeStatus.toDraft.invalid', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + throw new PermissionException( + key: 'page.changeStatus.toDraft.invalid', + data: ['slug' => $page->slug()] + ); } - - return true; } /** @@ -134,30 +120,26 @@ class PageRules * @throws \Kirby\Exception\InvalidArgumentException If the given position is invalid * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the status for the page cannot be changed by any user */ - public static function changeStatusToListed(Page $page, int $position): bool + public static function changeStatusToListed(Page $page, int $position): void { // no need to check for status changing permissions, // instead we need to check for sorting permissions if ($page->isListed() === true) { if ($page->isSortable() !== true) { - throw new PermissionException([ - 'key' => 'page.sort.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + throw new PermissionException( + key: 'page.sort.permission', + data: ['slug' => $page->slug()] + ); } - return true; + return; } static::publish($page); if ($position !== null && $position < 0) { - throw new InvalidArgumentException(['key' => 'page.num.invalid']); + throw new InvalidArgumentException(key: 'page.num.invalid'); } - - return true; } /** @@ -168,8 +150,6 @@ class PageRules public static function changeStatusToUnlisted(Page $page) { static::publish($page); - - return true; } /** @@ -178,30 +158,26 @@ class PageRules * @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template */ - public static function changeTemplate(Page $page, string $template): bool + public static function changeTemplate(Page $page, string $template): void { - if ($page->permissions()->changeTemplate() !== true) { - throw new PermissionException([ - 'key' => 'page.changeTemplate.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('changeTemplate') !== true) { + throw new PermissionException( + key: 'page.changeTemplate.permission', + data: ['slug' => $page->slug()] + ); } $blueprints = $page->blueprints(); if ( count($blueprints) <= 1 || - in_array($template, array_column($blueprints, 'name')) === false + in_array($template, array_column($blueprints, 'name'), true) === false ) { - throw new LogicException([ - 'key' => 'page.changeTemplate.invalid', - 'data' => ['slug' => $page->slug()] - ]); + throw new LogicException( + key: 'page.changeTemplate.invalid', + data: ['slug' => $page->slug()] + ); } - - return true; } /** @@ -210,20 +186,16 @@ class PageRules * @throws \Kirby\Exception\InvalidArgumentException If the new title is empty * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title */ - public static function changeTitle(Page $page, string $title): bool + public static function changeTitle(Page $page, string $title): void { - if ($page->permissions()->changeTitle() !== true) { - throw new PermissionException([ - 'key' => 'page.changeTitle.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('changeTitle') !== true) { + throw new PermissionException( + key: 'page.changeTitle.permission', + data: ['slug' => $page->slug()] + ); } static::validateTitleLength($title); - - return true; } /** @@ -233,27 +205,23 @@ class PageRules * @throws \Kirby\Exception\InvalidArgumentException If the slug is invalid * @throws \Kirby\Exception\PermissionException If the user is not allowed to create this page */ - public static function create(Page $page): bool + public static function create(Page $page): void { - if ($page->permissions()->create() !== true) { - throw new PermissionException([ - 'key' => 'page.create.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('create') !== true) { + throw new PermissionException( + key: 'page.create.permission', + data: ['slug' => $page->slug()] + ); } self::validateSlugLength($page->slug()); self::validateSlugProtectedPaths($page, $page->slug()); if ($page->exists() === true) { - throw new DuplicateException([ - 'key' => 'page.draft.duplicate', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + throw new DuplicateException( + key: 'page.draft.duplicate', + data: ['slug' => $page->slug()] + ); } $siblings = $page->parentModel()->children(); @@ -261,20 +229,18 @@ class PageRules $slug = $page->slug(); if ($siblings->find($slug)) { - throw new DuplicateException([ - 'key' => 'page.duplicate', - 'data' => ['slug' => $slug] - ]); + throw new DuplicateException( + key: 'page.duplicate', + data: ['slug' => $slug] + ); } if ($drafts->find($slug)) { - throw new DuplicateException([ - 'key' => 'page.draft.duplicate', - 'data' => ['slug' => $slug] - ]); + throw new DuplicateException( + key: 'page.draft.duplicate', + data: ['slug' => $slug] + ); } - - return true; } /** @@ -283,22 +249,18 @@ class PageRules * @throws \Kirby\Exception\LogicException If the page has children and should not be force-deleted * @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the page */ - public static function delete(Page $page, bool $force = false): bool + public static function delete(Page $page, bool $force = false): void { - if ($page->permissions()->delete() !== true) { - throw new PermissionException([ - 'key' => 'page.delete.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('delete') !== true) { + throw new PermissionException( + key: 'page.delete.permission', + data: ['slug' => $page->slug()] + ); } if (($page->hasChildren() === true || $page->hasDrafts() === true) && $force === false) { - throw new LogicException(['key' => 'page.delete.hasChildren']); + throw new LogicException(key: 'page.delete.hasChildren'); } - - return true; } /** @@ -310,56 +272,52 @@ class PageRules Page $page, string $slug, array $options = [] - ): bool { - if ($page->permissions()->duplicate() !== true) { - throw new PermissionException([ - 'key' => 'page.duplicate.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + ): void { + if ($page->permissions()->can('duplicate') !== true) { + throw new PermissionException( + key: 'page.duplicate.permission', + data: ['slug' => $page->slug()] + ); } self::validateSlugLength($slug); - - return true; } /** * Check if the page can be moved * to the given parent */ - public static function move(Page $page, Site|Page $parent): bool + public static function move(Page $page, Site|Page $parent): void { // if nothing changes, there's no need for checks if ($parent->is($page->parent()) === true) { - return true; + return; } - if ($page->permissions()->move() !== true) { - throw new PermissionException([ - 'key' => 'page.move.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('move') !== true) { + throw new PermissionException( + key: 'page.move.permission', + data: ['slug' => $page->slug()] + ); } // the page cannot be moved into itself - if ($parent instanceof Page && ($page->is($parent) === true || $page->isAncestorOf($parent) === true)) { - throw new LogicException([ - 'key' => 'page.move.ancestor', - ]); + if ( + $parent instanceof Page && + ( + $page->is($parent) === true || + $page->isAncestorOf($parent) === true + ) + ) { + throw new LogicException(key: 'page.move.ancestor'); } // check for duplicates if ($parent->childrenAndDrafts()->find($page->slug())) { - throw new DuplicateException([ - 'key' => 'page.move.duplicate', - 'data' => [ - 'slug' => $page->slug(), - ] - ]); + throw new DuplicateException( + key: 'page.move.duplicate', + data: ['slug' => $page->slug()] + ); } $allowed = []; @@ -399,41 +357,37 @@ class PageRules $allowed !== [] && in_array($page->intendedTemplate()->name(), $allowed) === false ) { - throw new PermissionException([ - 'key' => 'page.move.template', - 'data' => [ + throw new PermissionException( + key: 'page.move.template', + data: [ 'template' => $page->intendedTemplate()->name(), 'parent' => $parent->id() ?? '/', ] - ]); + ); } - - return true; } /** * Check if the page can be published * (status change from draft to listed or unlisted) */ - public static function publish(Page $page): bool + public static function publish(Page $page): void { - if ($page->permissions()->changeStatus() !== true) { - throw new PermissionException([ - 'key' => 'page.changeStatus.permission', - 'data' => [ + if ($page->permissions()->can('changeStatus') !== true) { + throw new PermissionException( + key: 'page.changeStatus.permission', + data: [ 'slug' => $page->slug() ] - ]); + ); } if ($page->isDraft() === true && empty($page->errors()) === false) { - throw new PermissionException([ - 'key' => 'page.changeStatus.incomplete', - 'details' => $page->errors() - ]); + throw new PermissionException( + key: 'page.changeStatus.incomplete', + details: $page->errors() + ); } - - return true; } /** @@ -441,18 +395,14 @@ class PageRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the page */ - public static function update(Page $page, array $content = []): bool + public static function update(Page $page, array $content = []): void { - if ($page->permissions()->update() !== true) { - throw new PermissionException([ - 'key' => 'page.update.permission', - 'data' => [ - 'slug' => $page->slug() - ] - ]); + if ($page->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'page.update.permission', + data: ['slug' => $page->slug()] + ); } - - return true; } /** @@ -466,21 +416,17 @@ class PageRules $slugLength = Str::length($slug); if ($slugLength === 0) { - throw new InvalidArgumentException([ - 'key' => 'page.slug.invalid', - ]); + throw new InvalidArgumentException(key: 'page.slug.invalid'); } if ($slugsMaxlength = App::instance()->option('slugs.maxlength', 255)) { $maxlength = (int)$slugsMaxlength; if ($slugLength > $maxlength) { - throw new InvalidArgumentException([ - 'key' => 'page.slug.maxlength', - 'data' => [ - 'length' => $maxlength - ] - ]); + throw new InvalidArgumentException( + key: 'page.slug.maxlength', + data: ['length' => $maxlength] + ); } } } @@ -505,12 +451,10 @@ class PageRules $index = array_search($slug, $paths); if ($index !== false) { - throw new InvalidArgumentException([ - 'key' => 'page.changeSlug.reserved', - 'data' => [ - 'path' => $paths[$index] - ] - ]); + throw new InvalidArgumentException( + key: 'page.changeSlug.reserved', + data: ['path' => $paths[$index]] + ); } } } @@ -523,9 +467,7 @@ class PageRules public static function validateTitleLength(string $title): void { if (Str::length($title) === 0) { - throw new InvalidArgumentException([ - 'key' => 'page.changeTitle.empty', - ]); + throw new InvalidArgumentException(key: 'page.changeTitle.empty'); } } } diff --git a/public/kirby/src/Cms/PageSiblings.php b/public/kirby/src/Cms/PageSiblings.php index 04f9f63..9215f05 100644 --- a/public/kirby/src/Cms/PageSiblings.php +++ b/public/kirby/src/Cms/PageSiblings.php @@ -16,10 +16,8 @@ trait PageSiblings /** * Checks if there's a next listed * page in the siblings collection - * - * @param \Kirby\Cms\Collection|null $collection */ - public function hasNextListed($collection = null): bool + public function hasNextListed(Pages|null $collection = null): bool { return $this->nextListed($collection) !== null; } @@ -27,10 +25,8 @@ trait PageSiblings /** * Checks if there's a next unlisted * page in the siblings collection - * - * @param \Kirby\Cms\Collection|null $collection */ - public function hasNextUnlisted($collection = null): bool + public function hasNextUnlisted(Pages|null $collection = null): bool { return $this->nextUnlisted($collection) !== null; } @@ -38,10 +34,8 @@ trait PageSiblings /** * Checks if there's a previous listed * page in the siblings collection - * - * @param \Kirby\Cms\Collection|null $collection */ - public function hasPrevListed($collection = null): bool + public function hasPrevListed(Pages|null $collection = null): bool { return $this->prevListed($collection) !== null; } @@ -49,68 +43,48 @@ trait PageSiblings /** * Checks if there's a previous unlisted * page in the siblings collection - * - * @param \Kirby\Cms\Collection|null $collection */ - public function hasPrevUnlisted($collection = null): bool + public function hasPrevUnlisted(Pages|null $collection = null): bool { return $this->prevUnlisted($collection) !== null; } /** * Returns the next listed page if it exists - * - * @param \Kirby\Cms\Collection|null $collection - * - * @return \Kirby\Cms\Page|null */ - public function nextListed($collection = null) + public function nextListed(Pages|null $collection = null): Page|null { return $this->nextAll($collection)->listed()->first(); } /** * Returns the next unlisted page if it exists - * - * @param \Kirby\Cms\Collection|null $collection - * - * @return \Kirby\Cms\Page|null */ - public function nextUnlisted($collection = null) + public function nextUnlisted(Pages|null $collection = null): Page|null { return $this->nextAll($collection)->unlisted()->first(); } /** * Returns the previous listed page - * - * @param \Kirby\Cms\Collection|null $collection - * - * @return \Kirby\Cms\Page|null */ - public function prevListed($collection = null) + public function prevListed(Pages|null $collection = null): Page|null { return $this->prevAll($collection)->listed()->last(); } /** * Returns the previous unlisted page - * - * @param \Kirby\Cms\Collection|null $collection - * - * @return \Kirby\Cms\Page|null */ - public function prevUnlisted($collection = null) + public function prevUnlisted(Pages|null $collection = null): Page|null { return $this->prevAll($collection)->unlisted()->last(); } /** * Private siblings collector - * - * @return \Kirby\Cms\Collection */ - protected function siblingsCollection() + protected function siblingsCollection(): Pages { if ($this->isDraft() === true) { return $this->parentModel()->drafts(); @@ -121,11 +95,12 @@ trait PageSiblings /** * Returns siblings with the same template - * - * @return \Kirby\Cms\Pages */ - public function templateSiblings(bool $self = true) + public function templateSiblings(bool $self = true): Pages { - return $this->siblings($self)->filter('intendedTemplate', $this->intendedTemplate()->name()); + return $this->siblings($self)->filter( + 'intendedTemplate', + $this->intendedTemplate()->name() + ); } } diff --git a/public/kirby/src/Cms/Pages.php b/public/kirby/src/Cms/Pages.php index 8c96708..bbb1bbc 100644 --- a/public/kirby/src/Cms/Pages.php +++ b/public/kirby/src/Cms/Pages.php @@ -2,8 +2,11 @@ namespace Kirby\Cms; +use Kirby\Exception\Exception; use Kirby\Exception\InvalidArgumentException; +use Kirby\Exception\NotFoundException; use Kirby\Uuid\HasUuids; +use Throwable; /** * The `$pages` object refers to a @@ -20,6 +23,8 @@ use Kirby\Uuid\HasUuids; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Page> */ class Pages extends Collection { @@ -27,23 +32,24 @@ class Pages extends Collection /** * Cache for the index only listed and unlisted pages - * - * @var \Kirby\Cms\Pages|null */ - protected $index = null; + protected Pages|null $index = null; /** * Cache for the index all statuses also including drafts - * - * @var \Kirby\Cms\Pages|null */ - protected $indexWithDrafts = null; + protected Pages|null $indexWithDrafts = null; /** * All registered pages methods */ public static array $methods = []; + /** + * @var \Kirby\Cms\Page|\Kirby\Cms\Site|null + */ + protected object|null $parent = null; + /** * Adds a single page or * an entire second collection to the @@ -59,23 +65,25 @@ class Pages extends Collection // add a pages collection if ($object instanceof self) { - $this->data = array_merge($this->data, $object->data); + $this->data = [...$this->data, ...$object->data]; - // add a page by id + // add a page by id } elseif ( is_string($object) === true && $page = $site->find($object) ) { $this->__set($page->id(), $page); - // add a page object + // add a page object } elseif ($object instanceof Page) { $this->__set($object->id(), $object); - // give a useful error message on invalid input; - // silently ignore "empty" values for compatibility with existing setups + // give a useful error message on invalid input; + // silently ignore "empty" values for compatibility with existing setups } elseif (in_array($object, [null, false, true], true) !== true) { - throw new InvalidArgumentException('You must pass a Pages or Page object or an ID of an existing page to the Pages collection'); + throw new InvalidArgumentException( + message: 'You must pass a Pages or Page object or an ID of an existing page to the Pages collection' + ); } return $this; @@ -92,9 +100,9 @@ class Pages extends Collection /** * Returns all children for each page in the array */ - public function children(): Pages + public function children(): static { - $children = new Pages([]); + $children = new static([]); foreach ($this->data as $page) { foreach ($page->children() as $childKey => $child) { @@ -113,6 +121,41 @@ class Pages extends Collection return $this->files()->filter('type', 'code'); } + /** + * Deletes the pages with the given IDs + * if they exist in the collection + * + * @throws \Kirby\Exception\Exception If not all pages could be deleted + */ + public function delete(array $ids): void + { + $exceptions = []; + + // delete all pages and collect errors + foreach ($ids as $id) { + try { + $model = $this->get($id); + + if ($model instanceof Page === false) { + throw new NotFoundException( + key: 'page.undefined', + ); + } + + $model->delete(); + } catch (Throwable $e) { + $exceptions[$id] = $e; + } + } + + if ($exceptions !== []) { + throw new Exception( + key: 'page.delete.multiple', + details: $exceptions + ); + } + } + /** * Returns all documents of all children */ @@ -124,9 +167,9 @@ class Pages extends Collection /** * Fetch all drafts for all pages in the collection */ - public function drafts(): Pages + public function drafts(): static { - $drafts = new Pages([]); + $drafts = new static([]); foreach ($this->data as $page) { foreach ($page->drafts() as $draftKey => $draft) { @@ -203,7 +246,7 @@ class Pages extends Collection $key = trim($key, '/'); // strip extensions from the id - if (strpos($key, '.') !== false) { + if (str_contains($key, '.') === true) { $info = pathinfo($key); if ($info['dirname'] !== '.') { @@ -243,14 +286,12 @@ class Pages extends Collection /** * Finds a child or child of a child recursively - * - * @return mixed */ protected function findByKeyRecursive( string $id, string|null $startAt = null, bool $multiLang = false - ) { + ): Page|null { $path = explode('/', $id); $item = null; $query = $startAt; @@ -294,12 +335,8 @@ class Pages extends Collection /** * Custom getter that is able to find * extension pages - * - * @param string $key - * @param mixed $default - * @return \Kirby\Cms\Page|null */ - public function get($key, $default = null) + public function get(string $key, mixed $default = null): Page|null { if ($key === null) { return null; @@ -323,19 +360,17 @@ class Pages extends Collection /** * Create a recursive flat index of all * pages and subpages, etc. - * - * @return \Kirby\Cms\Pages */ - public function index(bool $drafts = false) + public function index(bool $drafts = false): static { // get object property by cache mode $index = $drafts === true ? $this->indexWithDrafts : $this->index; - if ($index instanceof self) { + if ($index instanceof Pages) { return $index; } - $index = new Pages([]); + $index = new static([]); foreach ($this->data as $pageKey => $page) { $index->data[$pageKey] = $page; @@ -374,10 +409,9 @@ class Pages extends Collection /** * Include all given items in the collection * - * @param mixed ...$args * @return $this|static */ - public function merge(...$args) + public function merge(string|Pages|Page|array ...$args): static { // merge multiple arguments at once if (count($args) > 1) { @@ -398,9 +432,9 @@ class Pages extends Collection } // merge an entire collection - if ($args[0] instanceof self) { - $collection = clone $this; - $collection->data = array_merge($collection->data, $args[0]->data); + if ($args[0] instanceof Pages) { + $collection = clone $this; + $collection->data = [...$collection->data, ...$args[0]->data]; return $collection; } @@ -430,10 +464,9 @@ class Pages extends Collection * Filter all pages by excluding the given template * @since 3.3.0 * - * @param string|array $templates - * @return \Kirby\Cms\Pages + * @return $this|static */ - public function notTemplate($templates) + public function notTemplate(string|array|null $templates): static { if (empty($templates) === true) { return $this; @@ -444,8 +477,7 @@ class Pages extends Collection } return $this->filter( - fn ($page) => - !in_array($page->intendedTemplate()->name(), $templates) + fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true) === false ); } @@ -466,10 +498,9 @@ class Pages extends Collection /** * Filter all pages by the given template * - * @param string|array $templates - * @return \Kirby\Cms\Pages + * @return $this|static */ - public function template($templates) + public function template(string|array|null $templates): static { if (empty($templates) === true) { return $this; @@ -480,8 +511,7 @@ class Pages extends Collection } return $this->filter( - fn ($page) => - in_array($page->intendedTemplate()->name(), $templates) + fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true) ); } diff --git a/public/kirby/src/Cms/Pagination.php b/public/kirby/src/Cms/Pagination.php index 223d80a..c776ac7 100644 --- a/public/kirby/src/Cms/Pagination.php +++ b/public/kirby/src/Cms/Pagination.php @@ -24,24 +24,18 @@ class Pagination extends BasePagination { /** * Pagination method (param, query, none) - * - * @var string */ - protected $method; + protected string $method; /** * The base URL - * - * @var string */ - protected $url; + protected Uri $url; /** * Variable name for query strings - * - * @var string */ - protected $variable; + protected string $variable; /** * Creates the pagination object. As a new @@ -78,11 +72,11 @@ class Pagination extends BasePagination ]); } - if ($params['method'] === 'query') { - $params['page'] ??= $params['url']->query()->get($params['variable']); - } elseif ($params['method'] === 'param') { - $params['page'] ??= $params['url']->params()->get($params['variable']); - } + $params['page'] ??= match ($params['method']) { + 'query' => $params['url']->query()->get($params['variable']), + 'param' => $params['url']->params()->get($params['variable']), + default => null + }; parent::__construct($params); @@ -134,20 +128,22 @@ class Pagination extends BasePagination $url = clone $this->url; $variable = $this->variable; - if ($this->hasPage($page) === false) { + if ( + $this->hasPage($page) === false || + in_array($this->method, ['query', 'param'], true) === false + ) { return null; } - $pageValue = $page === 1 ? null : $page; - - if ($this->method === 'query') { - $url->query->$variable = $pageValue; - } elseif ($this->method === 'param') { - $url->params->$variable = $pageValue; - } else { - return null; + if ($page === 1) { + $page = null; } + match ($this->method) { + 'query' => $url->query->$variable = $page, + 'param' => $url->params->$variable = $page + }; + return $url->toString(); } diff --git a/public/kirby/src/Cms/Permissions.php b/public/kirby/src/Cms/Permissions.php index 731eebc..a89ff22 100644 --- a/public/kirby/src/Cms/Permissions.php +++ b/public/kirby/src/Cms/Permissions.php @@ -37,6 +37,7 @@ class Permissions 'list' => true, 'read' => true, 'replace' => true, + 'sort' => true, 'update' => true ], 'languages' => [ @@ -95,7 +96,9 @@ class Permissions // dynamically register the extended actions foreach (static::$extendedActions as $key => $actions) { if (isset($this->actions[$key]) === true) { - throw new InvalidArgumentException('The action ' . $key . ' is already a core action'); + throw new InvalidArgumentException( + message: 'The action ' . $key . ' is already a core action' + ); } $this->actions[$key] = $actions; @@ -177,14 +180,14 @@ class Permissions */ protected function setCategories(array $settings): static { - foreach ($settings as $categoryName => $categoryActions) { - if (is_bool($categoryActions) === true) { - $this->setCategory($categoryName, $categoryActions); + foreach ($settings as $name => $actions) { + if (is_bool($actions) === true) { + $this->setCategory($name, $actions); } - if (is_array($categoryActions) === true) { - foreach ($categoryActions as $actionName => $actionSetting) { - $this->setAction($categoryName, $actionName, $actionSetting); + if (is_array($actions) === true) { + foreach ($actions as $action => $setting) { + $this->setAction($name, $action, $setting); } } } @@ -199,11 +202,13 @@ class Permissions protected function setCategory(string $category, bool $setting): static { if ($this->hasCategory($category) === false) { - throw new InvalidArgumentException('Invalid permissions category'); + throw new InvalidArgumentException( + message: 'Invalid permissions category' + ); } - foreach ($this->actions[$category] as $actionName => $actionSetting) { - $this->actions[$category][$actionName] = $setting; + foreach ($this->actions[$category] as $action => $actionSetting) { + $this->actions[$category][$action] = $setting; } return $this; diff --git a/public/kirby/src/Cms/Picker.php b/public/kirby/src/Cms/Picker.php index f3bcce0..35e0ba9 100644 --- a/public/kirby/src/Cms/Picker.php +++ b/public/kirby/src/Cms/Picker.php @@ -23,7 +23,7 @@ abstract class Picker */ public function __construct(array $params = []) { - $this->options = array_merge($this->defaults(), $params); + $this->options = [...$this->defaults(), ...$params]; $this->kirby = $this->options['model']->kirby(); $this->site = $this->kirby->site(); } diff --git a/public/kirby/src/Cms/Responder.php b/public/kirby/src/Cms/Responder.php index 640381e..5bd9802 100644 --- a/public/kirby/src/Cms/Responder.php +++ b/public/kirby/src/Cms/Responder.php @@ -5,6 +5,7 @@ namespace Kirby\Cms; use Kirby\Exception\InvalidArgumentException; use Kirby\Filesystem\Mime; use Kirby\Toolkit\Str; +use Stringable; /** * Global response configuration @@ -15,7 +16,7 @@ use Kirby\Toolkit\Str; * @copyright Bastian Allgeier * @license https://getkirby.com/license */ -class Responder +class Responder implements Stringable { /** * Timestamp when the response expires @@ -134,7 +135,7 @@ class Responder public function usesCookie(string $name): void { // only add unique names - if (in_array($name, $this->usesCookies) === false) { + if (in_array($name, $this->usesCookies, true) === false) { $this->usesCookies[] = $name; } } @@ -187,7 +188,9 @@ class Responder $parsedExpires = strtotime($expires); if (is_int($parsedExpires) !== true) { - throw new InvalidArgumentException('Invalid time string "' . $expires . '"'); + throw new InvalidArgumentException( + message: 'Invalid time string "' . $expires . '"' + ); } $expires = $parsedExpires; @@ -293,7 +296,7 @@ class Responder } // lazily inject (never override custom headers) - return array_merge($injectedHeaders, $this->headers); + return [...$injectedHeaders, ...$this->headers]; } $this->headers = $headers; @@ -384,8 +387,9 @@ class Responder * all caches due to using dynamic data based on auth * and/or cookies; the request data only matters if it * is actually used/relied on by the response + * * @since 3.7.0 - * @internal + * @unstable */ public static function isPrivate(bool $usesAuth, array $usesCookies): bool { diff --git a/public/kirby/src/Cms/Role.php b/public/kirby/src/Cms/Role.php index ecdf2a6..9b7d8d5 100644 --- a/public/kirby/src/Cms/Role.php +++ b/public/kirby/src/Cms/Role.php @@ -2,10 +2,10 @@ namespace Kirby\Cms; -use Exception; use Kirby\Data\Data; use Kirby\Filesystem\F; use Kirby\Toolkit\I18n; +use Stringable; /** * Represents a User role with attached @@ -17,7 +17,7 @@ use Kirby\Toolkit\I18n; * @copyright Bastian Allgeier * @license https://getkirby.com/license */ -class Role +class Role implements Stringable { protected string|null $description; protected string $name; @@ -48,13 +48,14 @@ class Role return $this->name(); } - public static function admin(array $inject = []): static + public static function defaultAdmin(array $inject = []): static { - try { - return static::load('admin'); - } catch (Exception) { - return static::factory(static::defaults()['admin'], $inject); - } + return static::factory(static::defaults()['admin'], $inject); + } + + public static function defaultNobody(array $inject = []): static + { + return static::factory(static::defaults()['nobody'], $inject); } protected static function defaults(): array @@ -106,8 +107,10 @@ class Role public static function load(string $file, array $inject = []): static { - $data = Data::read($file); - $data['name'] = F::name($file); + $data = [ + ...Data::read($file), + 'name' => F::name($file) + ]; return static::factory($data, $inject); } @@ -117,15 +120,6 @@ class Role return $this->name; } - public static function nobody(array $inject = []): static - { - try { - return static::load('nobody'); - } catch (Exception) { - return static::factory(static::defaults()['nobody'], $inject); - } - } - public function permissions(): Permissions { return $this->permissions; diff --git a/public/kirby/src/Cms/Roles.php b/public/kirby/src/Cms/Roles.php index 4b33db6..230a9fe 100644 --- a/public/kirby/src/Cms/Roles.php +++ b/public/kirby/src/Cms/Roles.php @@ -15,6 +15,8 @@ namespace Kirby\Cms; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Role> */ class Roles extends Collection { @@ -89,7 +91,7 @@ class Roles extends Collection // always include the admin role if ($collection->find('admin') === null) { - $collection->set('admin', Role::admin()); + $collection->set('admin', Role::defaultAdmin()); } // return the collection sorted by name @@ -102,8 +104,8 @@ class Roles extends Collection $roles = new static(); // load roles from plugins - foreach ($kirby->extensions('blueprints') as $blueprintName => $blueprint) { - if (substr($blueprintName, 0, 6) !== 'users/') { + foreach ($kirby->extensions('blueprints') as $name => $blueprint) { + if (str_starts_with($name, 'users/') === false) { continue; } @@ -112,11 +114,10 @@ class Roles extends Collection $blueprint = $blueprint($kirby); } - if (is_array($blueprint) === true) { - $role = Role::factory($blueprint, $inject); - } else { - $role = Role::load($blueprint, $inject); - } + $role = match (is_array($blueprint)) { + true => Role::factory($blueprint, $inject), + false => Role::load($blueprint, $inject) + }; $roles->set($role->id(), $role); } @@ -137,7 +138,7 @@ class Roles extends Collection // always include the admin role if ($roles->find('admin') === null) { - $roles->set('admin', Role::admin($inject)); + $roles->set('admin', Role::defaultAdmin($inject)); } // return the collection sorted by name diff --git a/public/kirby/src/Cms/Section.php b/public/kirby/src/Cms/Section.php index 42ee388..a1785d3 100644 --- a/public/kirby/src/Cms/Section.php +++ b/public/kirby/src/Cms/Section.php @@ -33,11 +33,15 @@ class Section extends Component public function __construct(string $type, array $attrs = []) { if (isset($attrs['model']) === false) { - throw new InvalidArgumentException('Undefined section model'); + throw new InvalidArgumentException( + message: 'Undefined section model' + ); } if ($attrs['model'] instanceof ModelWithContent === false) { - throw new InvalidArgumentException('Invalid section model'); + throw new InvalidArgumentException( + message: 'Invalid section model' + ); } // use the type as fallback for the name @@ -92,11 +96,12 @@ class Section extends Component public function toResponse(): array { - return array_merge([ + return [ 'status' => 'ok', 'code' => 200, 'name' => $this->name, - 'type' => $this->type - ], $this->toArray()); + 'type' => $this->type, + ...$this->toArray() + ]; } } diff --git a/public/kirby/src/Cms/Site.php b/public/kirby/src/Cms/Site.php index cab166d..6333401 100644 --- a/public/kirby/src/Cms/Site.php +++ b/public/kirby/src/Cms/Site.php @@ -2,6 +2,7 @@ namespace Kirby\Cms; +use Kirby\Content\VersionId; use Kirby\Exception\InvalidArgumentException; use Kirby\Exception\LogicException; use Kirby\Filesystem\Dir; @@ -81,14 +82,20 @@ class Site extends ModelWithContent */ public function __construct(array $props = []) { - parent::__construct($props); - $this->errorPageId = $props['errorPageId'] ?? 'error'; $this->homePageId = $props['homePageId'] ?? 'home'; $this->page = $props['page'] ?? null; $this->url = $props['url'] ?? null; + // Set blueprint before setting content + // or translations in the parent constructor. + // Otherwise, the blueprint definition cannot be + // used when creating the right field values + // for the content. $this->setBlueprint($props['blueprint'] ?? null); + + parent::__construct($props); + $this->setChildren($props['children'] ?? null); $this->setDrafts($props['drafts'] ?? null); $this->setFiles($props['files'] ?? null); @@ -120,11 +127,12 @@ class Site extends ModelWithContent */ public function __debugInfo(): array { - return array_merge($this->toArray(), [ + return [ + ...$this->toArray(), 'content' => $this->content(), 'children' => $this->children(), 'files' => $this->files(), - ]); + ]; } /** @@ -186,20 +194,9 @@ class Site extends ModelWithContent array $data, string|null $languageCode = null ): array { - return A::prepend($data, ['title' => $data['title'] ?? null]); - } - - /** - * Filename for the content file - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileName(): string - { - Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return 'site'; + return A::prepend($data, [ + 'title' => $data['title'] ?? null + ]); } /** @@ -212,7 +209,6 @@ class Site extends ModelWithContent /** * Returns the global error page id - * @internal */ public function errorPageId(): string { @@ -237,7 +233,6 @@ class Site extends ModelWithContent /** * Returns the global home page id - * @internal */ public function homePageId(): string { @@ -247,7 +242,6 @@ class Site extends ModelWithContent /** * Creates an inventory of all files * and children in the site directory - * @internal */ public function inventory(): array { @@ -267,8 +261,6 @@ class Site extends ModelWithContent /** * Compares the current object with the given site object - * - * @param mixed $site */ public function is($site): bool { @@ -280,17 +272,23 @@ class Site extends ModelWithContent } /** - * Returns the root to the media folder for the site - * @internal + * Returns the absolute path to the media folder for the page */ - public function mediaRoot(): string + public function mediaDir(): string { return $this->kirby()->root('media') . '/site'; } + /** + * @see `::mediaDir` + */ + public function mediaRoot(): string + { + return $this->mediaDir(); + } + /** * The site's base url for any files - * @internal */ public function mediaUrl(): string { @@ -362,24 +360,17 @@ class Site extends ModelWithContent } /** - * Preview Url - * @internal + * Returns the preview URL with authentication for drafts and versions + * @unstable */ - public function previewUrl(): string|null + public function previewUrl(VersionId|string $versionId = 'latest'): string|null { - $preview = $this->blueprint()->preview(); - - if ($preview === false) { + // the site previews the home page and thus needs to check permissions for it + if ($this->homePage()?->permissions()->can('preview') !== true) { return null; } - if ($preview === true) { - $url = $this->url(); - } else { - $url = $preview; - } - - return $url; + return $this->version($versionId)->url(); } /** @@ -403,8 +394,10 @@ class Site extends ModelWithContent /** * Search all pages in the site */ - public function search(string|null $query = null, string|array $params = []): Pages - { + public function search( + string|null $query = null, + string|array $params = [] + ): Pages { return $this->index()->search($query, $params); } @@ -416,8 +409,10 @@ class Site extends ModelWithContent protected function setBlueprint(array|null $blueprint = null): static { if ($blueprint !== null) { - $blueprint['model'] = $this; - $this->blueprint = new SiteBlueprint($blueprint); + $this->blueprint = new SiteBlueprint([ + 'model' => $this, + ...$blueprint + ]); } return $this; @@ -429,7 +424,8 @@ class Site extends ModelWithContent */ public function toArray(): array { - return array_merge(parent::toArray(), [ + return [ + ...parent::toArray(), 'children' => $this->children()->keys(), 'errorPage' => $this->errorPage()?->id() ?? false, 'files' => $this->files()->keys(), @@ -437,7 +433,7 @@ class Site extends ModelWithContent 'page' => $this->page()?->id() ?? false, 'title' => $this->title()->value(), 'url' => $this->url(), - ]); + ]; } /** @@ -460,18 +456,14 @@ class Site extends ModelWithContent string|null $languageCode = null, array|null $options = null ): string { - if ($language = $this->kirby()->language($languageCode)) { - return $language->url(); - } - - return $this->kirby()->url(); + return + $this->kirby()->language($languageCode)?->url() ?? + $this->kirby()->url(); } /** - * Sets the current page by - * id or page object and + * Sets the current page by id or page object and * returns the current page - * @internal */ public function visit( string|Page $page, @@ -489,7 +481,7 @@ class Site extends ModelWithContent // handle invalid pages if ($page instanceof Page === false) { - throw new InvalidArgumentException('Invalid page object'); + throw new InvalidArgumentException(message: 'Invalid page object'); } // set and return the current active page diff --git a/public/kirby/src/Cms/SiteActions.php b/public/kirby/src/Cms/SiteActions.php index 9d67d5d..398f32d 100644 --- a/public/kirby/src/Cms/SiteActions.php +++ b/public/kirby/src/Cms/SiteActions.php @@ -18,10 +18,10 @@ trait SiteActions /** * Commits a site action, by following these steps * - * 1. checks the action rules - * 2. sends the before hook + * 1. applies the `before` hook + * 2. checks the action rules * 3. commits the store action - * 4. sends the after hook + * 4. applies the `after` hook * 5. returns the result */ protected function commit( @@ -29,19 +29,12 @@ trait SiteActions array $arguments, Closure $callback ): mixed { - $old = $this->hardcopy(); - $kirby = $this->kirby(); - $argumentValues = array_values($arguments); + $commit = new ModelCommit( + model: $this, + action: $action + ); - $this->rules()->$action(...$argumentValues); - $kirby->trigger('site.' . $action . ':before', $arguments); - - $result = $callback(...$argumentValues); - - $kirby->trigger('site.' . $action . ':after', ['newSite' => $result, 'oldSite' => $old]); - - $kirby->cache('pages')->flush(); - return $result; + return $commit->call($arguments, $callback); } /** @@ -51,24 +44,25 @@ trait SiteActions string $title, string|null $languageCode = null ): static { - // if the `$languageCode` argument is not set and is not the default language - // the `$languageCode` argument is sent as the current language - if ( - $languageCode === null && - $language = $this->kirby()->language() - ) { - if ($language->isDefault() === false) { - $languageCode = $language->code(); + $language = Language::ensure($languageCode ?? 'current'); + + $arguments = [ + 'site' => $this, + 'title' => trim($title), + 'languageCode' => $languageCode, + 'language' => $language + ]; + + return $this->commit('changeTitle', $arguments, function ($site, $title, $languageCode, $language) { + + // make sure to update the title in the changes version as well + // otherwise the new title would be lost as soon as the changes are saved + if ($site->version('changes')->exists($language) === true) { + $site->version('changes')->update(['title' => $title], $language); } - } - $arguments = ['site' => $this, 'title' => trim($title), 'languageCode' => $languageCode]; - - return $this->commit( - 'changeTitle', - $arguments, - fn ($site, $title, $languageCode) => $site->save(['title' => $title], $languageCode) - ); + return $site->save(['title' => $title], $language->code()); + }); } /** @@ -76,14 +70,13 @@ trait SiteActions */ public function createChild(array $props): Page { - $props = array_merge($props, [ + return Page::create([ + ...$props, 'url' => null, 'num' => null, 'parent' => null, 'site' => $this, ]); - - return Page::create($props); } /** diff --git a/public/kirby/src/Cms/SitePermissions.php b/public/kirby/src/Cms/SitePermissions.php index 8e58415..fe64134 100644 --- a/public/kirby/src/Cms/SitePermissions.php +++ b/public/kirby/src/Cms/SitePermissions.php @@ -13,5 +13,5 @@ namespace Kirby\Cms; */ class SitePermissions extends ModelPermissions { - protected string $category = 'site'; + protected const CATEGORY = 'site'; } diff --git a/public/kirby/src/Cms/SiteRules.php b/public/kirby/src/Cms/SiteRules.php index 08da997..609c880 100644 --- a/public/kirby/src/Cms/SiteRules.php +++ b/public/kirby/src/Cms/SiteRules.php @@ -23,17 +23,19 @@ class SiteRules * @throws \Kirby\Exception\InvalidArgumentException If the title is empty * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title */ - public static function changeTitle(Site $site, string $title): bool + public static function changeTitle(Site $site, string $title): void { - if ($site->permissions()->changeTitle() !== true) { - throw new PermissionException(['key' => 'site.changeTitle.permission']); + if ($site->permissions()->can('changeTitle') !== true) { + throw new PermissionException( + key: 'site.changeTitle.permission' + ); } if (Str::length($title) === 0) { - throw new InvalidArgumentException(['key' => 'site.changeTitle.empty']); + throw new InvalidArgumentException( + key: 'site.changeTitle.empty' + ); } - - return true; } /** @@ -41,12 +43,12 @@ class SiteRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the site */ - public static function update(Site $site, array $content = []): bool + public static function update(Site $site, array $content = []): void { - if ($site->permissions()->update() !== true) { - throw new PermissionException(['key' => 'site.update.permission']); + if ($site->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'site.update.permission' + ); } - - return true; } } diff --git a/public/kirby/src/Cms/Structure.php b/public/kirby/src/Cms/Structure.php index efdfad1..9ecd747 100644 --- a/public/kirby/src/Cms/Structure.php +++ b/public/kirby/src/Cms/Structure.php @@ -15,6 +15,8 @@ namespace Kirby\Cms; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\StructureObject> */ class Structure extends Items { diff --git a/public/kirby/src/Cms/StructureObject.php b/public/kirby/src/Cms/StructureObject.php index 9806b7d..57b509c 100644 --- a/public/kirby/src/Cms/StructureObject.php +++ b/public/kirby/src/Cms/StructureObject.php @@ -19,6 +19,8 @@ use Kirby\Content\Content; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Structure> */ class StructureObject extends Item { @@ -77,9 +79,9 @@ class StructureObject extends Item */ public function toArray(): array { - return array_merge( - $this->content()->toArray(), - parent::toArray() - ); + return [ + ...$this->content()->toArray(), + ...parent::toArray() + ]; } } diff --git a/public/kirby/src/Cms/System.php b/public/kirby/src/Cms/System.php index aa6ff4e..95b97d6 100644 --- a/public/kirby/src/Cms/System.php +++ b/public/kirby/src/Cms/System.php @@ -75,10 +75,7 @@ class System switch ($folder) { case 'content': - return $url . '/' . basename($this->app->site()->storage()->contentFile( - 'published', - 'default' - )); + return $url . '/' . basename($this->app->site()->version('latest')->contentFile()); case 'git': return $url . '/config'; case 'kirby': @@ -192,28 +189,36 @@ class System try { Dir::make($this->app->root('accounts')); } catch (Throwable) { - throw new PermissionException('The accounts directory could not be created'); + throw new PermissionException( + message: 'The accounts directory could not be created' + ); } // init /site/sessions try { Dir::make($this->app->root('sessions')); } catch (Throwable) { - throw new PermissionException('The sessions directory could not be created'); + throw new PermissionException( + message: 'The sessions directory could not be created' + ); } // init /content try { Dir::make($this->app->root('content')); } catch (Throwable) { - throw new PermissionException('The content directory could not be created'); + throw new PermissionException( + message: 'The content directory could not be created' + ); } // init /media try { Dir::make($this->app->root('media')); } catch (Throwable) { - throw new PermissionException('The media directory could not be created'); + throw new PermissionException( + message: 'The media directory could not be created' + ); } } @@ -232,7 +237,7 @@ class System { return $this->is2FA() === true && - in_array('totp', $this->app->auth()->enabledChallenges()) === true; + in_array('totp', $this->app->auth()->enabledChallenges(), true) === true; } /** @@ -363,7 +368,7 @@ class System public function php(): bool { return - version_compare(PHP_VERSION, '8.1.0', '>=') === true && + version_compare(PHP_VERSION, '8.2.0', '>=') === true && version_compare(PHP_VERSION, '8.5.0', '<') === true; } diff --git a/public/kirby/src/Cms/System/UpdateStatus.php b/public/kirby/src/Cms/System/UpdateStatus.php index 0579f68..51e70e0 100644 --- a/public/kirby/src/Cms/System/UpdateStatus.php +++ b/public/kirby/src/Cms/System/UpdateStatus.php @@ -5,9 +5,9 @@ namespace Kirby\Cms\System; use Composer\Semver\Semver; use Exception; use Kirby\Cms\App; -use Kirby\Cms\Plugin; use Kirby\Exception\Exception as KirbyException; use Kirby\Http\Remote; +use Kirby\Plugin\Plugin; use Kirby\Toolkit\I18n; use Kirby\Toolkit\Str; @@ -102,15 +102,15 @@ class UpdateStatus /** * Returns the Panel icon for the status value * - * @return string 'check'|'alert'|'info' + * @return string 'check'|'alert'|'info'|'question' */ public function icon(): string { return match ($this->status()) { - 'up-to-date', 'not-vulnerable' => 'check', + 'up-to-date', 'not-vulnerable' => 'check', 'security-update', 'security-upgrade' => 'alert', - 'update', 'upgrade' => 'info', - default => 'question' + 'update', 'upgrade' => 'info', + default => 'question' }; } @@ -252,10 +252,10 @@ class UpdateStatus public function theme(): string { return match ($this->status()) { - 'up-to-date', 'not-vulnerable' => 'positive', + 'up-to-date', 'not-vulnerable' => 'positive', 'security-update', 'security-upgrade' => 'negative', - 'update', 'upgrade' => 'info', - default => 'notice' + 'update', 'upgrade' => 'info', + default => 'passive' }; } @@ -350,14 +350,10 @@ class UpdateStatus try { return Semver::satisfies($version, $constraint); } catch (Exception $e) { - $package = $this->packageName(); - $message = 'Error comparing version constraint for ' . $package . ' ' . $reason . ': ' . $e->getMessage(); - - $exception = new KirbyException([ - 'fallback' => $message, - 'previous' => $e - ]); - $this->exceptions[] = $exception; + $this->exceptions[] = new KirbyException( + previous: $e, + fallback: 'Error comparing version constraint for ' . $this->packageName() . ' ' . $reason . ': ' . $e->getMessage(), + ); return false; } @@ -378,7 +374,9 @@ class UpdateStatus foreach ($filters as $key => $version) { if (isset($item[$key]) !== true) { $package = $this->packageName(); - $this->exceptions[] = new KirbyException('Missing constraint ' . $key . ' for ' . $package . ' ' . $reason); + $this->exceptions[] = new KirbyException( + 'Missing constraint ' . $key . ' for ' . $package . ' ' . $reason + ); return false; } @@ -547,7 +545,9 @@ class UpdateStatus // before we request the data, ensure we have a writable cache; // this reduces strain on the CDN from repeated requests if ($cache->enabled() === false) { - $this->exceptions[] = new KirbyException('Cannot check for updates without a working "updates" cache'); + $this->exceptions[] = new KirbyException( + message: 'Cannot check for updates without a working "updates" cache' + ); return null; } @@ -556,7 +556,7 @@ class UpdateStatus // we collect it below for debugging try { if (static::$timedOut === true) { - throw new Exception('Previous remote request timed out'); // @codeCoverageIgnore + throw new Exception(message: 'Previous remote request timed out'); // @codeCoverageIgnore } $response = Remote::get( @@ -566,22 +566,22 @@ class UpdateStatus // allow status code HTTP 200 or 0 (e.g. for the file:// protocol) if (in_array($response->code(), [0, 200], true) !== true) { - throw new Exception('HTTP error ' . $response->code()); // @codeCoverageIgnore + throw new Exception(message: 'HTTP error ' . $response->code()); // @codeCoverageIgnore } $data = $response->json(); if (is_array($data) !== true) { - throw new Exception('Invalid JSON data'); + throw new Exception(message: 'Invalid JSON data'); } } catch (Exception $e) { $package = $this->packageName(); $message = 'Could not load update data for ' . $package . ': ' . $e->getMessage(); - $exception = new KirbyException([ - 'fallback' => $message, - 'previous' => $e - ]); + $exception = new KirbyException( + fallback: $message, + previous: $e + ); $this->exceptions[] = $exception; // if the request timed out, prevent additional diff --git a/public/kirby/src/Cms/Translation.php b/public/kirby/src/Cms/Translation.php index d71d336..13d6df2 100644 --- a/public/kirby/src/Cms/Translation.php +++ b/public/kirby/src/Cms/Translation.php @@ -2,7 +2,6 @@ namespace Kirby\Cms; -use Exception; use Kirby\Data\Data; use Kirby\Toolkit\Str; @@ -68,10 +67,11 @@ class Translation return $this->data; } - // get the fallback array - $fallback = App::instance()->translation('en')->data(); - - return array_merge($fallback, $this->data); + return [ + // add the fallback array + ...App::instance()->translation('en')->data(), + ...$this->data + ]; } /** @@ -110,11 +110,10 @@ class Translation string $root, array $inject = [] ): static { - try { - $data = array_merge(Data::read($root), $inject); - } catch (Exception) { - $data = []; - } + $data = [ + ...Data::read($root, fail: false), + ...$inject + ]; return new static($code, $data); } diff --git a/public/kirby/src/Cms/Translations.php b/public/kirby/src/Cms/Translations.php index 40c0b55..916018f 100644 --- a/public/kirby/src/Cms/Translations.php +++ b/public/kirby/src/Cms/Translations.php @@ -16,6 +16,8 @@ use Kirby\Filesystem\F; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Translation> */ class Translations extends Collection { @@ -46,7 +48,11 @@ class Translations extends Collection } $locale = F::name($filename); - $translation = Translation::load($locale, $root . '/' . $filename, $inject[$locale] ?? []); + $translation = Translation::load( + $locale, + $root . '/' . $filename, + $inject[$locale] ?? [] + ); $collection->data[$locale] = $translation; } diff --git a/public/kirby/src/Cms/Url.php b/public/kirby/src/Cms/Url.php index eb5036f..c8dee99 100644 --- a/public/kirby/src/Cms/Url.php +++ b/public/kirby/src/Cms/Url.php @@ -2,6 +2,7 @@ namespace Kirby\Cms; +use Kirby\Filesystem\F; use Kirby\Http\Url as BaseUrl; use Kirby\Toolkit\Str; @@ -63,10 +64,11 @@ class Url extends BaseUrl $kirby = App::instance(); $page = $kirby->site()->page(); $path = $assetPath . '/' . $page->template() . '.' . $extension; - $file = $kirby->root('assets') . '/' . $path; + $root = $kirby->root('assets'); + $file = $root . '/' . $path; $url = $kirby->url('assets') . '/' . $path; - return file_exists($file) === true ? $url : null; + return F::exists($file, $root) === true ? $url : null; } /** diff --git a/public/kirby/src/Cms/User.php b/public/kirby/src/Cms/User.php index 6b09900..9cd8161 100644 --- a/public/kirby/src/Cms/User.php +++ b/public/kirby/src/Cms/User.php @@ -24,11 +24,14 @@ use SensitiveParameter; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Users> */ class User extends ModelWithContent { use HasFiles; use HasMethods; + use HasModels; use HasSiblings; use UserActions; @@ -40,11 +43,6 @@ class User extends ModelWithContent */ public static array $methods = []; - /** - * Registry with all User models - */ - public static array $models = []; - protected UserBlueprint|null $blueprint = null; protected array $credentials; protected string|null $email; @@ -63,7 +61,7 @@ class User extends ModelWithContent { // helper function to easily edit values (if not null) // before assigning them to their properties - $set = function (string $key, Closure $callback) use ($props) { + $set = static function (string $key, Closure $callback) use ($props) { if ($value = $props[$key] ?? null) { $value = $callback($value); } @@ -76,8 +74,6 @@ class User extends ModelWithContent // so it also gets stored in propertyData prop $props['id'] ??= $this->createId(); - parent::__construct($props); - $this->id = $props['id']; $this->email = $set('email', fn ($email) => Str::lower(trim($email))); $this->language = $set('language', fn ($language) => trim($language)); @@ -85,7 +81,15 @@ class User extends ModelWithContent $this->password = $props['password'] ?? null; $this->role = $set('role', fn ($role) => Str::lower(trim($role))); + // Set blueprint before setting content + // or translations in the parent constructor. + // Otherwise, the blueprint definition cannot be + // used when creating the right field values + // for the content. $this->setBlueprint($props['blueprint'] ?? null); + + parent::__construct($props); + $this->setFiles($props['files'] ?? null); } @@ -115,11 +119,12 @@ class User extends ModelWithContent */ public function __debugInfo(): array { - return array_merge($this->toArray(), [ + return [ + ...$this->toArray(), 'avatar' => $this->avatar(), 'content' => $this->content(), 'role' => $this->role() - ]); + ]; } /** @@ -149,7 +154,11 @@ class User extends ModelWithContent public function blueprint(): UserBlueprint { try { - return $this->blueprint ??= UserBlueprint::factory('users/' . $this->role(), 'users/default', $this); + return $this->blueprint ??= UserBlueprint::factory( + 'users/' . $this->role(), + 'users/default', + $this + ); } catch (Exception) { return $this->blueprint ??= new UserBlueprint([ 'model' => $this, @@ -162,7 +171,8 @@ class User extends ModelWithContent /** * Prepares the content for the write method * @internal - * @param string $languageCode|null Not used so far + * + * @param string|null $languageCode Not used so far */ public function contentFileData( array $data, @@ -180,20 +190,6 @@ class User extends ModelWithContent return $data; } - /** - * Filename for the content file - * - * @internal - * @deprecated 4.0.0 - * @todo Remove in v5 - * @codeCoverageIgnore - */ - public function contentFileName(): string - { - Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); - return 'user'; - } - protected function credentials(): array { return $this->credentials ??= $this->readCredentials(); @@ -212,30 +208,21 @@ class User extends ModelWithContent */ public function exists(): bool { - return $this->storage()->exists( - 'published', - 'default' - ); + return $this->version('latest')->exists('default'); } /** * Constructs a User object and also - * takes User models into account. - * @internal + * takes User models into account */ public static function factory(mixed $props): static { - if (empty($props['model']) === false) { - return static::model($props['model'], $props); - } - - return new static($props); + return static::model($props['model'] ?? $props['role'] ?? 'default', $props); } /** - * Hashes the user's password unless it is `null`, + * Hashes the provided password unless it is `null`, * which will leave it as `null` - * @internal */ public static function hashPassword( #[SensitiveParameter] @@ -376,7 +363,9 @@ class User extends ModelWithContent Session|array|null $session = null ): void { if ($this->id() === 'kirby') { - throw new PermissionException('The almighty user "kirby" cannot be used for login, only for raising permissions in code via `$kirby->impersonate()`'); + throw new PermissionException( + message: 'The almighty user "kirby" cannot be used for login, only for raising permissions in code via `$kirby->impersonate()`' + ); } $kirby = $this->kirby(); @@ -435,40 +424,29 @@ class User extends ModelWithContent } /** - * Returns the root to the media folder for the user - * @internal + * Returns the absolute path to the media folder for the user */ - public function mediaRoot(): string + public function mediaDir(): string { return $this->kirby()->root('media') . '/users/' . $this->id(); } + /** + * @see `::mediaDir` + */ + public function mediaRoot(): string + { + return $this->mediaDir(); + } + /** * Returns the media url for the user object - * @internal */ public function mediaUrl(): string { return $this->kirby()->url('media') . '/users/' . $this->id(); } - /** - * Creates a user model if it has been registered - * @internal - */ - public static function model(string $name, array $props = []): static - { - if ($class = (static::$models[$name] ?? null)) { - $object = new $class($props); - - if ($object instanceof self) { - return $object; - } - } - - return new static($props); - } - /** * Returns the last modification date of the user */ @@ -477,7 +455,7 @@ class User extends ModelWithContent string|null $handler = null, string|null $languageCode = null ): int|string|false { - $modifiedContent = $this->storage()->modified('published', $languageCode); + $modifiedContent = $this->version('latest')->modified($languageCode ?? 'current'); $modifiedIndex = F::modified($this->root() . '/index.php'); $modifiedTotal = max([$modifiedContent, $modifiedIndex]); @@ -502,13 +480,11 @@ class User extends ModelWithContent */ public function nameOrEmail(): Field { - $name = $this->name(); - return $name->isNotEmpty() ? $name : new Field($this, 'email', $this->email()); + return $this->name()->or(new Field($this, 'email', $this->email())); } /** * Create a dummy nobody - * @internal */ public static function nobody(): static { @@ -568,9 +544,11 @@ class User extends ModelWithContent return $this->role; } - $name = $this->role ?? $this->credentials()['role'] ?? 'visitor'; + $name = $this->role ?? $this->credentials()['role'] ?? 'default'; - return $this->role = $this->kirby()->roles()->find($name) ?? Role::nobody(); + return $this->role = + $this->kirby()->roles()->find($name) ?? + Role::defaultNobody(); } /** @@ -628,8 +606,10 @@ class User extends ModelWithContent protected function setBlueprint(array|null $blueprint = null): static { if ($blueprint !== null) { - $blueprint['model'] = $this; - $this->blueprint = new UserBlueprint($blueprint); + $this->blueprint = new UserBlueprint([ + ...$blueprint, + 'model' => $this + ]); } return $this; @@ -643,10 +623,10 @@ class User extends ModelWithContent protected function sessionFromOptions(Session|array|null $session): Session { // use passed session options or session object if set - if (is_array($session) === true) { + $session ??= ['detect' => true]; + + if ($session instanceof Session === false) { $session = $this->kirby()->session($session); - } elseif ($session instanceof Session === false) { - $session = $this->kirby()->session(['detect' => true]); } return $session; @@ -666,14 +646,15 @@ class User extends ModelWithContent */ public function toArray(): array { - return array_merge(parent::toArray(), [ + return [ + ...parent::toArray(), 'avatar' => $this->avatar()?->toArray(), 'email' => $this->email(), 'id' => $this->id(), 'language' => $this->language(), 'role' => $this->role()->name(), 'username' => $this->username() - ]); + ]; } /** @@ -688,8 +669,12 @@ class User extends ModelWithContent string|null $fallback = '', string $handler = 'template' ): string { - $template ??= $this->email(); - return parent::toString($template, $data, $fallback, $handler); + return parent::toString( + $template ?? $this->email(), + $data, + $fallback, + $handler + ); } /** @@ -699,7 +684,7 @@ class User extends ModelWithContent */ public function username(): string|null { - return $this->name()->or($this->email())->value(); + return $this->nameOrEmail()->value(); } /** @@ -714,36 +699,36 @@ class User extends ModelWithContent string|null $password = null ): bool { if (empty($this->password()) === true) { - throw new NotFoundException(['key' => 'user.password.undefined']); + throw new NotFoundException( + key: 'user.password.undefined' + ); } // `UserRules` enforces a minimum length of 8 characters, // so everything below that is a typo if (Str::length($password) < 8) { - throw new InvalidArgumentException(['key' => 'user.password.invalid']); + throw new InvalidArgumentException( + key: 'user.password.invalid' + ); } // too long passwords can cause DoS attacks if (Str::length($password) > 1000) { - throw new InvalidArgumentException(['key' => 'user.password.excessive']); + throw new InvalidArgumentException( + key: 'user.password.excessive' + ); } if (password_verify($password, $this->password()) !== true) { - throw new InvalidArgumentException(['key' => 'user.password.wrong', 'httpCode' => 401]); + throw new InvalidArgumentException( + key: 'user.password.wrong', + httpCode: 401 + ); } return true; } - /** - * @deprecated 4.0.0 Use `->secretsFile()` instead - * @codeCoverageIgnore - */ - protected function passwordFile(): string - { - return $this->secretsFile(); - } - /** * Returns the path to the file containing * all user secrets, including the password diff --git a/public/kirby/src/Cms/UserActions.php b/public/kirby/src/Cms/UserActions.php index 57bf09a..296f6ff 100644 --- a/public/kirby/src/Cms/UserActions.php +++ b/public/kirby/src/Cms/UserActions.php @@ -3,13 +3,13 @@ namespace Kirby\Cms; use Closure; +use Kirby\Content\ImmutableMemoryStorage; +use Kirby\Content\MemoryStorage; use Kirby\Data\Data; use Kirby\Data\Json; -use Kirby\Exception\LogicException; use Kirby\Exception\PermissionException; use Kirby\Filesystem\Dir; use Kirby\Filesystem\F; -use Kirby\Form\Form; use Kirby\Http\Idn; use Kirby\Toolkit\A; use Kirby\Toolkit\Str; @@ -35,16 +35,8 @@ trait UserActions $email = trim($email); return $this->commit('changeEmail', ['user' => $this, 'email' => Idn::decodeEmail($email)], function ($user, $email) { - $user = $user->clone([ - 'email' => $email - ]); - - $user->updateCredentials([ - 'email' => $email - ]); - - // update the users collection - $user->kirby()->users()->set($user->id(), $user); + $user = $user->clone(['email' => $email]); + $user->updateCredentials(['email' => $email]); return $user; }); @@ -56,16 +48,8 @@ trait UserActions public function changeLanguage(string $language): static { return $this->commit('changeLanguage', ['user' => $this, 'language' => $language], function ($user, $language) { - $user = $user->clone([ - 'language' => $language, - ]); - - $user->updateCredentials([ - 'language' => $language - ]); - - // update the users collection - $user->kirby()->users()->set($user->id(), $user); + $user = $user->clone(['language' => $language]); + $user->updateCredentials(['language' => $language]); return $user; }); @@ -79,16 +63,8 @@ trait UserActions $name = trim($name); return $this->commit('changeName', ['user' => $this, 'name' => $name], function ($user, $name) { - $user = $user->clone([ - 'name' => $name - ]); - - $user->updateCredentials([ - 'name' => $name - ]); - - // update the users collection - $user->kirby()->users()->set($user->id(), $user); + $user = $user->clone(['name' => $name]); + $user->updateCredentials(['name' => $name]); return $user; }); @@ -106,14 +82,11 @@ trait UserActions ): static { return $this->commit('changePassword', ['user' => $this, 'password' => $password], function ($user, $password) { $user = $user->clone([ - 'password' => $password = User::hashPassword($password) + 'password' => $password = static::hashPassword($password) ]); $user->writePassword($password); - // update the users collection - $user->kirby()->users()->set($user->id(), $user); - // keep the user logged in to the current browser // if they changed their own password // (regenerate the session token, update the login timestamp) @@ -131,16 +104,8 @@ trait UserActions public function changeRole(string $role): static { return $this->commit('changeRole', ['user' => $this, 'role' => $role], function ($user, $role) { - $user = $user->clone([ - 'role' => $role, - ]); - - $user->updateCredentials([ - 'role' => $role - ]); - - // update the users collection - $user->kirby()->users()->set($user->id(), $user); + $user = $user->clone(['role' => $role]); + $user->updateCredentials(['role' => $role]); return $user; }); @@ -171,10 +136,10 @@ trait UserActions /** * Commits a user action, by following these steps * - * 1. checks the action rules - * 2. sends the before hook + * 1. applies the `before` hook + * 2. checks the action rules * 3. commits the action - * 4. sends the after hook + * 4. applies the `after` hook * 5. returns the result * * @throws \Kirby\Exception\PermissionException @@ -185,59 +150,55 @@ trait UserActions Closure $callback ): mixed { if ($this->isKirby() === true) { - throw new PermissionException('The Kirby user cannot be changed'); + throw new PermissionException( + message: 'The Kirby user cannot be changed' + ); } - $old = $this->hardcopy(); - $kirby = $this->kirby(); - $argumentValues = array_values($arguments); + $commit = new ModelCommit( + model: $this, + action: $action + ); - $this->rules()->$action(...$argumentValues); - $kirby->trigger('user.' . $action . ':before', $arguments); - - $result = $callback(...$argumentValues); - - $argumentsAfter = match ($action) { - 'create' => ['user' => $result], - 'delete' => ['status' => $result, 'user' => $old], - default => ['newUser' => $result, 'oldUser' => $old] - }; - - $kirby->trigger('user.' . $action . ':after', $argumentsAfter); - - $kirby->cache('pages')->flush(); - return $result; + return $commit->call($arguments, $callback); } /** * Creates a new User from the given props and returns a new User object */ - public static function create(array|null $props = null): User + public static function create(array $props): User { - $data = $props; + $input = $props; + $props = self::normalizeProps($props); - if (isset($props['email']) === true) { - $data['email'] = Idn::decodeEmail($props['email']); - } - - if (isset($props['password']) === true) { - $data['password'] = User::hashPassword($props['password']); - } - - $props['role'] = $props['model'] = strtolower($props['role'] ?? 'default'); - - $user = User::factory($data); - - // create a form for the user - $form = Form::for($user, [ - 'values' => $props['content'] ?? [] + // create the instance without content or translations + // to avoid that the user is created in memory storage + $user = User::factory([ + ...$props, + 'content' => null, + 'translations' => null ]); + // merge the content with the defaults + $props['content'] = [ + ...$user->createDefaultContent(), + ...$props['content'], + ]; + + // keep the initial storage class + $storage = $user->storage()::class; + + // make sure that the temporary user is stored in memory + $user->changeStorage(MemoryStorage::class); + // inject the content - $user = $user->clone(['content' => $form->strings(true)]); + $user->setContent($props['content']); + + // inject the translations + $user->setTranslations($props['translations'] ?? null); // run the hook - return $user->commit('create', ['user' => $user, 'input' => $props], function ($user, $props) { + return $user->commit('create', ['user' => $user, 'input' => $input], function ($user) use ($storage) { $user->writeCredentials([ 'email' => $user->email(), 'language' => $user->language(), @@ -246,19 +207,10 @@ trait UserActions ]); $user->writePassword($user->password()); - - // always create users in the default language - if ($user->kirby()->multilang() === true) { - $languageCode = $user->kirby()->defaultLanguage()->code(); - } else { - $languageCode = null; - } - - // add the user to users collection - $user->kirby()->users()->add($user); + $user->changeStorage($storage); // write the user data - return $user->save($user->content()->toArray(), $languageCode); + return $user; }); } @@ -272,9 +224,8 @@ trait UserActions do { try { $id = Str::random($length); - if (UserRules::validId($this, $id) === true) { - return $id; - } + UserRules::validId($this, $id); + return $id; // we can't really test for a random match // @codeCoverageIgnoreStart @@ -293,25 +244,53 @@ trait UserActions public function delete(): bool { return $this->commit('delete', ['user' => $this], function ($user) { - if ($user->exists() === false) { - return true; + $old = $user->clone(); + + // keep the content in iummtable memory storage + // to still have access to it in after hooks + $user->changeStorage(ImmutableMemoryStorage::class); + + // delete all files individually + foreach ($old->files() as $file) { + $file->delete(); } - // delete all public assets for this user - Dir::remove($user->mediaRoot()); + // delete all versions, + // the plain text storage handler will then clean + // up the directory if it's empty + $old->versions()->delete(); - // delete the user directory - if (Dir::remove($user->root()) !== true) { - throw new LogicException('The user directory for "' . $user->email() . '" could not be deleted'); - } - - // remove the user from users collection - $user->kirby()->users()->remove($user); + // delete the user directory to get rid + // of the .htpasswd and index.php files. + // we need to solve this at a later point with + // something like a credential storage + Dir::remove($old->root()); return true; }); } + protected static function normalizeProps(array $props): array + { + $content = $props['content'] ?? []; + $role = $props['role'] ?? 'default'; + + if (isset($props['email']) === true) { + $props['email'] = Idn::decodeEmail($props['email']); + } + + if (isset($props['password']) === true) { + $props['password'] = static::hashPassword($props['password']); + } + + return [ + ...$props, + 'content' => $content, + 'model' => $props['model'] ?? $role, + 'role' => $role + ]; + } + /** * Read the account information from disk */ @@ -376,10 +355,12 @@ trait UserActions // set auth user data only if the current user is this user if ($user->isLoggedIn() === true) { $this->kirby()->auth()->setUser($user); - } - // update the users collection - $user->kirby()->users()->set($user->id(), $user); + ModelState::update( + method: 'set', + current: $user, + ); + } return $user; } @@ -395,7 +376,10 @@ trait UserActions $credentials['email'] = Str::lower(trim($credentials['email'])); } - return $this->writeCredentials(array_merge($this->credentials(), $credentials)); + return $this->writeCredentials([ + ...$this->credentials(), + ...$credentials + ]); } /** diff --git a/public/kirby/src/Cms/UserPermissions.php b/public/kirby/src/Cms/UserPermissions.php index cab3776..4103fca 100644 --- a/public/kirby/src/Cms/UserPermissions.php +++ b/public/kirby/src/Cms/UserPermissions.php @@ -13,15 +13,12 @@ namespace Kirby\Cms; */ class UserPermissions extends ModelPermissions { - protected string $category = 'users'; - - public function __construct(User $model) + /** + * Used to cache once determined permissions in memory + */ + protected static function cacheKey(ModelWithContent|Language $model): string { - parent::__construct($model); - - // change the scope of the permissions, - // when the current user is this user - $this->category = $this->user?->is($model) ? 'user' : 'users'; + return $model->role()->id(); } protected function canChangeRole(): bool @@ -29,7 +26,7 @@ class UserPermissions extends ModelPermissions // protect admin from role changes by non-admin if ( $this->model->isAdmin() === true && - $this->user->isAdmin() !== true + static::user()->isAdmin() !== true ) { return false; } @@ -45,7 +42,7 @@ class UserPermissions extends ModelPermissions protected function canCreate(): bool { // the admin can always create new users - if ($this->user->isAdmin() === true) { + if (static::user()->isAdmin() === true) { return true; } @@ -61,4 +58,11 @@ class UserPermissions extends ModelPermissions { return $this->model->isLastAdmin() !== true; } + + protected static function category(ModelWithContent|Language $model): string + { + // change the scope of the permissions, + // when the current user is this user + return static::user()->is($model) ? 'user' : 'users'; + } } diff --git a/public/kirby/src/Cms/UserPicker.php b/public/kirby/src/Cms/UserPicker.php index 0e9ab3e..c79b775 100644 --- a/public/kirby/src/Cms/UserPicker.php +++ b/public/kirby/src/Cms/UserPicker.php @@ -22,10 +22,10 @@ class UserPicker extends Picker */ public function defaults(): array { - $defaults = parent::defaults(); - $defaults['text'] = '{{ user.username }}'; - - return $defaults; + return [ + ...parent::defaults(), + 'text' => '{{ user.username }}' + ]; } /** @@ -52,14 +52,13 @@ class UserPicker extends Picker // catch invalid data if ($users instanceof Users === false) { - throw new InvalidArgumentException('Your query must return a set of users'); + throw new InvalidArgumentException( + message: 'Your query must return a set of users' + ); } - // search - $users = $this->search($users); - - // sort - $users = $users->sort('username', 'asc'); + // search & sort + $users = $this->search($users)->sort('username', 'asc'); // paginate return $this->paginate($users); diff --git a/public/kirby/src/Cms/UserRules.php b/public/kirby/src/Cms/UserRules.php index df5918e..67cb4f1 100644 --- a/public/kirby/src/Cms/UserRules.php +++ b/public/kirby/src/Cms/UserRules.php @@ -27,16 +27,16 @@ class UserRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the address */ - public static function changeEmail(User $user, string $email): bool + public static function changeEmail(User $user, string $email): void { - if ($user->permissions()->changeEmail() !== true) { - throw new PermissionException([ - 'key' => 'user.changeEmail.permission', - 'data' => ['name' => $user->username()] - ]); + if ($user->permissions()->can('changeEmail') !== true) { + throw new PermissionException( + key: 'user.changeEmail.permission', + data: ['name' => $user->username()] + ); } - return static::validEmail($user, $email); + static::validEmail($user, $email); } /** @@ -44,16 +44,16 @@ class UserRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the language */ - public static function changeLanguage(User $user, string $language): bool + public static function changeLanguage(User $user, string $language): void { - if ($user->permissions()->changeLanguage() !== true) { - throw new PermissionException([ - 'key' => 'user.changeLanguage.permission', - 'data' => ['name' => $user->username()] - ]); + if ($user->permissions()->can('changeLanguage') !== true) { + throw new PermissionException( + key: 'user.changeLanguage.permission', + data: ['name' => $user->username()] + ); } - return static::validLanguage($user, $language); + static::validLanguage($user, $language); } /** @@ -61,16 +61,14 @@ class UserRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the name */ - public static function changeName(User $user, string $name): bool + public static function changeName(User $user, string $name): void { - if ($user->permissions()->changeName() !== true) { - throw new PermissionException([ - 'key' => 'user.changeName.permission', - 'data' => ['name' => $user->username()] - ]); + if ($user->permissions()->can('changeName') !== true) { + throw new PermissionException( + key: 'user.changeName.permission', + data: ['name' => $user->username()] + ); } - - return true; } /** @@ -82,15 +80,15 @@ class UserRules User $user, #[SensitiveParameter] string $password - ): bool { - if ($user->permissions()->changePassword() !== true) { - throw new PermissionException([ - 'key' => 'user.changePassword.permission', - 'data' => ['name' => $user->username()] - ]); + ): void { + if ($user->permissions()->can('changePassword') !== true) { + throw new PermissionException( + key: 'user.changePassword.permission', + data: ['name' => $user->username()] + ); } - return static::validPassword($user, $password); + static::validPassword($user, $password); } /** @@ -99,42 +97,40 @@ class UserRules * @throws \Kirby\Exception\LogicException If the user is the last admin * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the role */ - public static function changeRole(User $user, string $role): bool + public static function changeRole(User $user, string $role): void { // prevent non-admins making a user to admin if ( $user->kirby()->user()->isAdmin() === false && $role === 'admin' ) { - throw new PermissionException([ - 'key' => 'user.changeRole.toAdmin' - ]); + throw new PermissionException( + key: 'user.changeRole.toAdmin' + ); } // prevent demoting the last admin if ($role !== 'admin' && $user->isLastAdmin() === true) { - throw new LogicException([ - 'key' => 'user.changeRole.lastAdmin', - 'data' => ['name' => $user->username()] - ]); + throw new LogicException( + key: 'user.changeRole.lastAdmin', + data: ['name' => $user->username()] + ); } // check permissions - if ($user->permissions()->changeRole() !== true) { - throw new PermissionException([ - 'key' => 'user.changeRole.permission', - 'data' => ['name' => $user->username()] - ]); + if ($user->permissions()->can('changeRole') !== true) { + throw new PermissionException( + key: 'user.changeRole.permission', + data: ['name' => $user->username()] + ); } // prevent changing to role that is not available for user if ($user->roles()->find($role) instanceof Role === false) { - throw new InvalidArgumentException([ - 'key' => 'user.role.invalid', - ]); + throw new InvalidArgumentException( + key: 'user.role.invalid', + ); } - - return true; } /** @@ -147,14 +143,16 @@ class UserRules User $user, #[SensitiveParameter] string|null $secret - ): bool { + ): void { $currentUser = $user->kirby()->user(); if ( $currentUser->is($user) === false && $currentUser->isAdmin() === false ) { - throw new PermissionException('You cannot change the time-based code for ' . $user->email()); + throw new PermissionException( + message: 'You cannot change the time-based code for ' . $user->email() + ); } // safety check to avoid accidental insecure secrets; @@ -162,8 +160,6 @@ class UserRules if ($secret !== null) { new Totp($secret); } - - return true; } /** @@ -171,7 +167,7 @@ class UserRules * * @throws \Kirby\Exception\PermissionException If the user is not allowed to create a new user */ - public static function create(User $user, array $props = []): bool + public static function create(User $user, array $props = []): void { static::validId($user, $user->id()); static::validEmail($user, $user->email(), true); @@ -192,16 +188,16 @@ class UserRules // admins are allowed everything if ($currentUser?->isAdmin() === true) { - return true; + return; } // allow to create the first user if ($user->kirby()->users()->count() === 0) { - return true; + return; } // check user permissions - if ($user->permissions()->create() !== true) { + if ($user->permissions()->can('create') !== true) { throw new PermissionException([ 'key' => 'user.create.permission' ]); @@ -218,8 +214,6 @@ class UserRules 'key' => 'user.role.invalid', ]); } - - return true; } /** @@ -228,26 +222,26 @@ class UserRules * @throws \Kirby\Exception\LogicException If this is the last user or last admin, which cannot be deleted * @throws \Kirby\Exception\PermissionException If the user is not allowed to delete this user */ - public static function delete(User $user): bool + public static function delete(User $user): void { if ($user->isLastAdmin() === true) { - throw new LogicException(['key' => 'user.delete.lastAdmin']); + throw new LogicException( + key: 'user.delete.lastAdmin' + ); } if ($user->isLastUser() === true) { - throw new LogicException([ - 'key' => 'user.delete.lastUser' - ]); + throw new LogicException( + key: 'user.delete.lastUser' + ); } - if ($user->permissions()->delete() !== true) { - throw new PermissionException([ - 'key' => 'user.delete.permission', - 'data' => ['name' => $user->username()] - ]); + if ($user->permissions()->can('delete') !== true) { + throw new PermissionException( + key: 'user.delete.permission', + data: ['name' => $user->username()] + ); } - - return true; } /** @@ -259,15 +253,13 @@ class UserRules User $user, array $values = [], array $strings = [] - ): bool { - if ($user->permissions()->update() !== true) { - throw new PermissionException([ - 'key' => 'user.update.permission', - 'data' => ['name' => $user->username()] - ]); + ): void { + if ($user->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'user.update.permission', + data: ['name' => $user->username()] + ); } - - return true; } /** @@ -280,27 +272,24 @@ class UserRules User $user, string $email, bool $strict = false - ): bool { + ): void { if (V::email($email ?? null) === false) { - throw new InvalidArgumentException([ - 'key' => 'user.email.invalid', - ]); + throw new InvalidArgumentException( + key: 'user.email.invalid' + ); } - if ($strict === true) { - $duplicate = $user->kirby()->users()->find($email); - } else { - $duplicate = $user->kirby()->users()->not($user)->find($email); - } + $duplicate = match ($strict) { + true => $user->kirby()->users()->find($email), + false => $user->kirby()->users()->not($user)->find($email) + }; if ($duplicate) { - throw new DuplicateException([ - 'key' => 'user.duplicate', - 'data' => ['email' => $email] - ]); + throw new DuplicateException( + key: 'user.duplicate', + data: ['email' => $email] + ); } - - return true; } /** @@ -308,17 +297,19 @@ class UserRules * * @throws \Kirby\Exception\DuplicateException If the user already exists */ - public static function validId(User $user, string $id): bool + public static function validId(User $user, string $id): void { - if (in_array($id, ['account', 'kirby', 'nobody']) === true) { - throw new InvalidArgumentException('"' . $id . '" is a reserved word and cannot be used as user id'); + if (in_array($id, ['account', 'kirby', 'nobody'], true) === true) { + throw new InvalidArgumentException( + message: '"' . $id . '" is a reserved word and cannot be used as user id' + ); } if ($user->kirby()->users()->find($id)) { - throw new DuplicateException('A user with this id exists'); + throw new DuplicateException( + message: 'A user with this id exists' + ); } - - return true; } /** @@ -326,15 +317,11 @@ class UserRules * * @throws \Kirby\Exception\InvalidArgumentException If the language does not exist */ - public static function validLanguage(User $user, string $language): bool + public static function validLanguage(User $user, string $language): void { if (in_array($language, $user->kirby()->translations()->keys(), true) === false) { - throw new InvalidArgumentException([ - 'key' => 'user.language.invalid', - ]); + throw new InvalidArgumentException(key: 'user.language.invalid'); } - - return true; } /** @@ -346,12 +333,10 @@ class UserRules User $user, #[SensitiveParameter] string $password - ): bool { + ): void { // too short passwords are ineffective if (Str::length($password ?? null) < 8) { - throw new InvalidArgumentException([ - 'key' => 'user.password.invalid', - ]); + throw new InvalidArgumentException(key: 'user.password.invalid'); } // too long passwords can cause DoS attacks @@ -359,12 +344,8 @@ class UserRules // (blocked here as well to avoid passwords // that cannot be used to log in) if (Str::length($password ?? null) > 1000) { - throw new InvalidArgumentException([ - 'key' => 'user.password.excessive', - ]); + throw new InvalidArgumentException(key: 'user.password.excessive'); } - - return true; } /** @@ -373,14 +354,12 @@ class UserRules * @throws \Kirby\Exception\InvalidArgumentException If the user role does not exist * @deprecated 4.5.0 */ - public static function validRole(User $user, string $role): bool + public static function validRole(User $user, string $role): void { - if ($user->kirby()->roles()->find($role) instanceof Role) { - return true; + if ($user->kirby()->roles()->find($role) instanceof Role === false) { + throw new InvalidArgumentException( + key: 'user.role.invalid', + ); } - - throw new InvalidArgumentException([ - 'key' => 'user.role.invalid', - ]); } } diff --git a/public/kirby/src/Cms/Users.php b/public/kirby/src/Cms/Users.php index 4014402..c67d1fe 100644 --- a/public/kirby/src/Cms/Users.php +++ b/public/kirby/src/Cms/Users.php @@ -19,6 +19,8 @@ use Kirby\Uuid\HasUuids; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\User> */ class Users extends Collection { @@ -47,23 +49,25 @@ class Users extends Collection { // add a users collection if ($object instanceof self) { - $this->data = array_merge($this->data, $object->data); + $this->data = [...$this->data, ...$object->data]; - // add a user by id + // add a user by id } elseif ( is_string($object) === true && $user = App::instance()->user($object) ) { $this->__set($user->id(), $user); - // add a user object + // add a user object } elseif ($object instanceof User) { $this->__set($object->id(), $object); - // give a useful error message on invalid input; - // silently ignore "empty" values for compatibility with existing setups + // give a useful error message on invalid input; + // silently ignore "empty" values for compatibility with existing setups } elseif (in_array($object, [null, false, true], true) !== true) { - throw new InvalidArgumentException('You must pass a Users or User object or an ID of an existing user to the Users collection'); + throw new InvalidArgumentException( + message: 'You must pass a Users or User object or an ID of an existing user to the Users collection' + ); } return $this; diff --git a/public/kirby/src/Content/Changes.php b/public/kirby/src/Content/Changes.php new file mode 100644 index 0000000..a741004 --- /dev/null +++ b/public/kirby/src/Content/Changes.php @@ -0,0 +1,197 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Changes +{ + protected App $kirby; + + public function __construct() + { + $this->kirby = App::instance(); + } + + /** + * Access helper for the cache, in which changes are stored + */ + public function cache(): Cache + { + return $this->kirby->cache('changes'); + } + + /** + * Returns whether the cache has been populated + */ + public function cacheExists(): bool + { + return $this->cache()->get('__updated__') !== null; + } + + /** + * Returns the cache key for a given model + */ + public function cacheKey(ModelWithContent $model): string + { + return $model::CLASS_ALIAS . 's'; + } + + /** + * Verify that the tracked model still really has changes. + * If not, untrack and remove from collection. + * + * @template T of \Kirby\Cms\Files|\Kirby\Cms\Pages|\Kirby\Cms\Users + * @param T $tracked + * @return T + */ + public function ensure(Files|Pages|Users $tracked): Files|Pages|Users + { + foreach ($tracked as $model) { + if ($model->version('changes')->exists('*') === false) { + $this->untrack($model); + $tracked->remove($model); + } + } + + return $tracked; + } + + /** + * Return all files with unsaved changes + */ + public function files(): Files + { + $files = new Files([]); + + foreach ($this->read('files') as $id) { + if ($file = $this->kirby->file($id)) { + $files->add($file); + } + } + + return $this->ensure($files); + } + + /** + * Rebuilds the cache by finding all models with changes version + */ + public function generateCache(): void + { + $models = [ + 'files' => [], + 'pages' => [], + 'users' => [] + ]; + + foreach ($this->kirby->models() as $model) { + if ($model->version('changes')->exists('*') === true) { + $models[$this->cacheKey($model)][] = (string)($model->uuid() ?? $model->id()); + } + } + + foreach ($models as $key => $changes) { + $this->update($key, $changes); + } + } + + /** + * Return all pages with unsaved changes + */ + public function pages(): Pages + { + /** + * @var \Kirby\Cms\Pages $pages + */ + $pages = $this->kirby->site()->find( + false, + false, + ...$this->read('pages') + ); + + return $this->ensure($pages); + } + + /** + * Read the changes for a given model type + */ + public function read(string $key): array + { + return $this->cache()->get($key) ?? []; + } + + /** + * Add a new model to the list of unsaved changes + */ + public function track(ModelWithContent $model): void + { + $key = $this->cacheKey($model); + + $changes = $this->read($key); + $changes[] = (string)($model->uuid() ?? $model->id()); + + $this->update($key, $changes); + } + + /** + * Remove a model from the list of unsaved changes + */ + public function untrack(ModelWithContent $model): void + { + // get the cache key for the model type + $key = $this->cacheKey($model); + + // remove the model from the list of changes + $changes = A::filter( + $this->read($key), + fn ($id) => $id !== (string)($model->uuid() ?? $model->id()) + ); + + $this->update($key, $changes); + } + + /** + * Update the changes field + */ + public function update(string $key, array $changes): void + { + $changes = array_unique($changes); + $changes = array_values($changes); + + $this->cache()->set($key, $changes); + $this->cache()->set('__updated__', time()); + } + + /** + * Return all users with unsaved changes + */ + public function users(): Users + { + /** + * @var \Kirby\Cms\Users $users + */ + $users = $this->kirby->users()->find( + false, + false, + ...$this->read('users') + ); + + return $this->ensure($users); + } +} diff --git a/public/kirby/src/Content/Content.php b/public/kirby/src/Content/Content.php index 0169287..0f58525 100644 --- a/public/kirby/src/Content/Content.php +++ b/public/kirby/src/Content/Content.php @@ -3,6 +3,7 @@ namespace Kirby\Content; use Kirby\Cms\Blueprint; +use Kirby\Cms\File; use Kirby\Cms\ModelWithContent; use Kirby\Form\Form; @@ -96,14 +97,15 @@ class Content ); // forms - $oldForm = new Form([ - 'fields' => $old->fields(), - 'model' => $this->parent - ]); - $newForm = new Form([ - 'fields' => $new->fields(), - 'model' => $this->parent - ]); + $oldForm = new Form( + fields: $old->fields(), + model: $this->parent + ); + + $newForm = new Form( + fields: $new->fields(), + model: $this->parent + ); // fields $oldFields = $oldForm->fields(); @@ -122,8 +124,14 @@ class Content } } + // if the parent is a file, overwrite the template + // with the new template name + if ($this->parent instanceof File) { + $data['template'] = $to; + } + // preserve existing fields - return array_merge($this->data, $data); + return [...$this->data, ...$data]; } /** @@ -228,10 +236,7 @@ class Content } /** - * Updates the content and returns - * a cloned object - * - * @return $this + * Updates the content in memory. */ public function update( array|null $content = null, diff --git a/public/kirby/src/Content/ContentStorage.php b/public/kirby/src/Content/ContentStorage.php deleted file mode 100644 index 23eab40..0000000 --- a/public/kirby/src/Content/ContentStorage.php +++ /dev/null @@ -1,314 +0,0 @@ - - * @author Nico Hoffmann - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -class ContentStorage -{ - protected ContentStorageHandler $handler; - - public function __construct( - protected ModelWithContent $model, - string $handler = PlainTextContentStorageHandler::class - ) { - $this->handler = new $handler($model); - } - - /** - * Magic caller for handler methods - */ - public function __call(string $name, array $args): mixed - { - return $this->handler->$name(...$args); - } - - /** - * Returns generator for all existing versions-languages combinations - * - * @return Generator - * @todo 4.0.0 consider more descpritive name - */ - public function all(): Generator - { - foreach ($this->model->kirby()->languages()->codes() as $lang) { - foreach ($this->dynamicVersions() as $version) { - if ($this->exists($version, $lang) === true) { - yield $version => $lang; - } - } - } - } - - /** - * Returns the absolute path to the content file - * @internal eventually should only exists in PlainTextContentStorage, - * when not relying anymore on language helper - * - * @param string $lang Code `'default'` in a single-lang installation - * - * @throws \Kirby\Exception\LogicException If the model type doesn't have a known content filename - */ - public function contentFile( - string $version, - string $lang, - bool $force = false - ): string { - $lang = $this->language($lang, $force); - return $this->handler->contentFile($version, $lang); - } - - /** - * Adapts all versions when converting languages - * @internal - */ - public function convertLanguage(string $from, string $to): void - { - $from = $this->language($from, true); - $to = $this->language($to, true); - - foreach ($this->dynamicVersions() as $version) { - $this->handler->move($version, $from, $version, $to); - } - } - - /** - * Creates a new version - * - * @param string|null $lang Code `'default'` in a single-lang installation - * @param array $fields Content fields - */ - public function create( - string $versionType, - string|null $lang, - array $fields - ): void { - $lang = $this->language($lang); - $this->handler->create($versionType, $lang, $fields); - } - - /** - * Returns the default version identifier for the model - * @internal - */ - public function defaultVersion(): string - { - if ( - $this->model instanceof Page === true && - $this->model->isDraft() === true - ) { - return 'changes'; - } - - return 'published'; - } - - /** - * Deletes an existing version in an idempotent way if it was already deleted - * - * @param string $lang Code `'default'` in a single-lang installation - */ - public function delete( - string $version, - string|null $lang = null, - bool $force = false - ): void { - $lang = $this->language($lang, $force); - $this->handler->delete($version, $lang); - } - - /** - * Deletes all versions when deleting a language - * @internal - */ - public function deleteLanguage(string|null $lang): void - { - $lang = $this->language($lang, true); - - foreach ($this->dynamicVersions() as $version) { - $this->handler->delete($version, $lang); - } - } - - /** - * Returns all versions availalbe for the model that can be updated - * @internal - */ - public function dynamicVersions(): array - { - $versions = ['changes']; - - if ( - $this->model instanceof Page === false || - $this->model->isDraft() === false - ) { - $versions[] = 'published'; - } - - return $versions; - } - - /** - * Checks if a version exists - * - * @param string|null $lang Code `'default'` in a single-lang installation; - * checks for "any language" if not provided - */ - public function exists( - string $version, - string|null $lang - ): bool { - if ($lang !== null) { - $lang = $this->language($lang); - } - - return $this->handler->exists($version, $lang); - } - - /** - * Returns the modification timestamp of a version - * if it exists - * - * @param string $lang Code `'default'` in a single-lang installation - */ - public function modified( - string $version, - string|null $lang = null - ): int|null { - $lang = $this->language($lang); - return $this->handler->modified($version, $lang); - } - - /** - * Returns the stored content fields - * - * @param string $lang Code `'default'` in a single-lang installation - * @return array - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function read( - string $version, - string|null $lang = null - ): array { - $lang = $this->language($lang); - $this->ensureExistingVersion($version, $lang); - return $this->handler->read($version, $lang); - } - - /** - * Updates the modification timestamp of an existing version - * - * @param string $lang Code `'default'` in a single-lang installation - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function touch( - string $version, - string|null $lang = null - ): void { - $lang = $this->language($lang); - $this->ensureExistingVersion($version, $lang); - $this->handler->touch($version, $lang); - } - - /** - * Touches all versions of a language - * @internal - */ - public function touchLanguage(string|null $lang): void - { - $lang = $this->language($lang, true); - - foreach ($this->dynamicVersions() as $version) { - if ($this->exists($version, $lang) === true) { - $this->handler->touch($version, $lang); - } - } - } - - /** - * Updates the content fields of an existing version - * - * @param string $lang Code `'default'` in a single-lang installation - * @param array $fields Content fields - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function update( - string $version, - string|null $lang = null, - array $fields = [] - ): void { - $lang = $this->language($lang); - $this->ensureExistingVersion($version, $lang); - $this->handler->update($version, $lang, $fields); - } - - /** - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - protected function ensureExistingVersion( - string $version, - string $lang - ): void { - if ($this->exists($version, $lang) !== true) { - throw new NotFoundException('Version "' . $version . ' (' . $lang . ')" does not already exist'); - } - } - - /** - * Converts a "user-facing" language code to a "raw" language code to be - * used for storage - * - * @param bool $force If set to `true`, the language code is not validated - * @return string Language code - */ - protected function language( - string|null $languageCode = null, - bool $force = false - ): string { - // in force mode, use the provided language code even in single-lang for - // compatibility with the previous behavior in `$model->contentFile()` - if ($force === true) { - return $languageCode ?? 'default'; - } - - // in multi-lang, … - if ($this->model->kirby()->multilang() === true) { - // look up the actual language object if possible - $language = $this->model->kirby()->language($languageCode); - - // validate the language code - if ($language === null) { - throw new InvalidArgumentException('Invalid language: ' . $languageCode); - } - - return $language->code(); - } - - // otherwise use hardcoded "default" code for single lang - return 'default'; - } -} diff --git a/public/kirby/src/Content/ContentStorageHandler.php b/public/kirby/src/Content/ContentStorageHandler.php deleted file mode 100644 index 6f39d11..0000000 --- a/public/kirby/src/Content/ContentStorageHandler.php +++ /dev/null @@ -1,96 +0,0 @@ - - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -interface ContentStorageHandler -{ - public function __construct(ModelWithContent $model); - - /** - * Creates a new version - * - * @param string $lang Code `'default'` in a single-lang installation - * @param array $fields Content fields - */ - public function create(string $versionType, string $lang, array $fields): void; - - /** - * Deletes an existing version in an idempotent way if it was already deleted - * - * @param string $lang Code `'default'` in a single-lang installation - */ - public function delete(string $version, string $lang): void; - - /** - * Checks if a version exists - * - * @param string|null $lang Code `'default'` in a single-lang installation; - * checks for "any language" if not provided - */ - public function exists(string $version, string|null $lang): bool; - - /** - * Returns the modification timestamp of a version if it exists - * - * @param string $lang Code `'default'` in a single-lang installation - */ - public function modified(string $version, string $lang): int|null; - - /** - * Moves content from one version-language combination to another - * - * @param string $fromLang Code `'default'` in a single-lang installation - * @param string $toLang Code `'default'` in a single-lang installation - */ - public function move( - string $fromVersion, - string $fromLang, - string $toVersion, - string $toLang - ): void; - - /** - * Returns the stored content fields - * - * @param string $lang Code `'default'` in a single-lang installation - * @return array - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function read(string $version, string $lang): array; - - /** - * Updates the modification timestamp of an existing version - * - * @param string $lang Code `'default'` in a single-lang installation - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function touch(string $version, string $lang): void; - - /** - * Updates the content fields of an existing version - * - * @param string $lang Code `'default'` in a single-lang installation - * @param array $fields Content fields - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function update(string $version, string $lang, array $fields): void; -} diff --git a/public/kirby/src/Content/ContentTranslation.php b/public/kirby/src/Content/ContentTranslation.php deleted file mode 100644 index e341d15..0000000 --- a/public/kirby/src/Content/ContentTranslation.php +++ /dev/null @@ -1,173 +0,0 @@ - - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -class ContentTranslation -{ - protected string $code; - protected array|null $content; - protected string $contentFile; - protected ModelWithContent $parent; - protected string|null $slug; - - /** - * Creates a new translation object - */ - public function __construct(array $props) - { - $this->code = $props['code']; - $this->parent = $props['parent']; - $this->slug = $props['slug'] ?? null; - - if ($content = $props['content'] ?? null) { - $this->content = array_change_key_case($content); - } else { - $this->content = null; - } - } - - /** - * Improve `var_dump` output - * @codeCoverageIgnore - */ - public function __debugInfo(): array - { - return $this->toArray(); - } - - /** - * Returns the language code of the - * translation - */ - public function code(): string - { - return $this->code; - } - - /** - * Returns the translation content - * as plain array - */ - public function content(): array - { - $parent = $this->parent(); - $content = $this->content ??= $parent->readContent($this->code()); - - // merge with the default content - if ( - $this->isDefault() === false && - $defaultLanguage = $parent->kirby()->defaultLanguage() - ) { - $content = array_merge( - $parent->translation($defaultLanguage->code())?->content() ?? [], - $content - ); - } - - return $content; - } - - /** - * Absolute path to the translation content file - */ - public function contentFile(): string - { - // temporary compatibility change (TODO: take this from the parent `ModelVersion` object) - $identifier = $this->parent::CLASS_ALIAS === 'page' && $this->parent->isDraft() === true ? - 'changes' : - 'published'; - - return $this->contentFile = $this->parent->storage()->contentFile( - $identifier, - $this->code, - true - ); - } - - /** - * Checks if the translation file exists - */ - public function exists(): bool - { - return - empty($this->content) === false || - file_exists($this->contentFile()) === true; - } - - /** - * Returns the translation code as id - */ - public function id(): string - { - return $this->code(); - } - - /** - * Checks if the this is the default translation - * of the model - */ - public function isDefault(): bool - { - return $this->code() === $this->parent->kirby()->defaultLanguage()?->code(); - } - - /** - * Returns the parent page, file or site object - */ - public function parent(): ModelWithContent - { - return $this->parent; - } - - /** - * Returns the custom translation slug - */ - public function slug(): string|null - { - return $this->slug ??= ($this->content()['slug'] ?? null); - } - - /** - * Merge the old and new data - * - * @return $this - */ - public function update(array|null $data = null, bool $overwrite = false): static - { - $data = array_change_key_case((array)$data); - - $this->content = match ($overwrite) { - true => $data, - default => array_merge($this->content(), $data) - }; - - return $this; - } - - /** - * Converts the most important translation - * props to an array - */ - public function toArray(): array - { - return [ - 'code' => $this->code(), - 'content' => $this->content(), - 'exists' => $this->exists(), - 'slug' => $this->slug(), - ]; - } -} diff --git a/public/kirby/src/Content/Field.php b/public/kirby/src/Content/Field.php index 201ccc2..06f0f5d 100644 --- a/public/kirby/src/Content/Field.php +++ b/public/kirby/src/Content/Field.php @@ -4,6 +4,7 @@ namespace Kirby\Content; use Closure; use Kirby\Cms\ModelWithContent; +use Stringable; /** * Every field in a Kirby content text file @@ -25,46 +26,29 @@ use Kirby\Cms\ModelWithContent; * @copyright Bastian Allgeier * @license https://getkirby.com/license */ -class Field +class Field implements Stringable { /** * Field method aliases */ public static array $aliases = []; - /** - * The field name - */ - protected string $key; - /** * Registered field methods */ public static array $methods = []; - /** - * The parent object if available. - * This will be the page, site, user or file - * to which the content belongs - */ - protected ModelWithContent|null $parent; - - /** - * The value of the field - */ - public mixed $value; - /** * Creates a new field object + * + * @param \Kirby\Cms\ModelWithContent|null $parent Parent object if available. This will be the page, site, user or file to which the content belongs + * @param string $key The field name */ public function __construct( - ModelWithContent|null $parent, - string $key, - mixed $value + protected ModelWithContent|null $parent, + protected string $key, + public mixed $value ) { - $this->key = $key; - $this->value = $value; - $this->parent = $parent; } /** @@ -93,7 +77,7 @@ class Field * Simplifies the var_dump result * @codeCoverageIgnore * - * @see Field::toArray + * @see self::toArray() */ public function __debugInfo(): array { @@ -104,7 +88,7 @@ class Field * Makes it possible to simply echo * or stringify the entire object * - * @see Field::toString + * @see self::toString() */ public function __toString(): string { @@ -154,7 +138,7 @@ class Field } /** - * @see Field::parent() + * @see self::parent() */ public function model(): ModelWithContent|null { @@ -168,7 +152,7 @@ class Field */ public function or(mixed $fallback = null): static { - if ($this->isNotEmpty()) { + if ($this->isNotEmpty() === true) { return $this; } diff --git a/public/kirby/src/Content/ImmutableMemoryStorage.php b/public/kirby/src/Content/ImmutableMemoryStorage.php new file mode 100644 index 0000000..3eac5f4 --- /dev/null +++ b/public/kirby/src/Content/ImmutableMemoryStorage.php @@ -0,0 +1,90 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class ImmutableMemoryStorage extends MemoryStorage +{ + public function __construct( + protected ModelWithContent $model, + protected ModelWithContent|null $nextModel = null + ) { + parent::__construct($model); + } + + /** + * Immutable storage entries cannot be deleted + * + * @throws \Kirby\Exception\LogicException + */ + public function delete(VersionId $versionId, Language $language): void + { + $this->preventMutation('deleted'); + } + + /** + * Immutable storage entries cannot be moved + * + * @throws \Kirby\Exception\LogicException + */ + public function move( + VersionId $fromVersionId, + Language $fromLanguage, + VersionId|null $toVersionId = null, + Language|null $toLanguage = null, + Storage|null $toStorage = null + ): void { + $this->preventMutation('moved'); + } + + /** + * Returns the next state of the model if the + * reference is given + */ + public function nextModel(): ModelWithContent|null + { + return $this->nextModel; + } + + /** + * Throws an exception to avoid the mutation of storage data + * + * @throws \Kirby\Exception\LogicException + */ + protected function preventMutation(string $mutation): void + { + throw new LogicException( + message: 'Storage for the ' . $this->model::CLASS_ALIAS . ' is immutable and cannot be ' . $mutation . '. Make sure to use the last alteration of the object.' + ); + } + + /** + * Immutable storage entries cannot be touched + * + * @throws \Kirby\Exception\LogicException + */ + public function touch(VersionId $versionId, Language $language): void + { + $this->preventMutation('touched'); + } + + /** + * Immutable storage entries cannot be updated + * + * @throws \Kirby\Exception\LogicException + */ + public function update(VersionId $versionId, Language $language, array $fields): void + { + $this->preventMutation('updated'); + } +} diff --git a/public/kirby/src/Content/Lock.php b/public/kirby/src/Content/Lock.php new file mode 100644 index 0000000..6ac15fa --- /dev/null +++ b/public/kirby/src/Content/Lock.php @@ -0,0 +1,229 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Lock +{ + public function __construct( + protected User|null $user = null, + protected int|null $modified = null, + protected bool $legacy = false + ) { + } + + /** + * Creates a lock for the given version by + * reading the modification timestamp and + * lock user id from the version. + */ + public static function for( + Version $version, + Language|string $language = 'default' + ): static { + + if ($legacy = static::legacy($version->model())) { + return $legacy; + } + + // wildcard to search for a lock in any language + // the first locked one will be preferred + if ($language === '*') { + foreach (Languages::ensure() as $language) { + $lock = static::for($version, $language); + + // return the first locked lock if any exists + if ($lock->isLocked() === true) { + return $lock; + } + } + + // return the last lock if no lock was found + return $lock; + } + + $language = Language::ensure($language); + + // if the version does not exist, it cannot be locked + if ($version->exists($language) === false) { + // create an open lock for the current user + return new static( + user: App::instance()->user(), + ); + } + + // Read the locked user id from the version + if ($userId = ($version->read($language)['lock'] ?? null)) { + $user = App::instance()->user($userId); + } + + return new static( + user: $user ?? null, + modified: $version->modified($language) + ); + } + + /** + * Checks if the lock is still active because + * recent changes have been made to the content + */ + public function isActive(): bool + { + $minutes = 10; + return $this->modified > time() - (60 * $minutes); + } + + /** + * Checks if content locking is enabled at all + */ + public static function isEnabled(): bool + { + return App::instance()->option('content.locking', true) !== false; + } + + /** + * Checks if the lock is coming from an old .lock file + */ + public function isLegacy(): bool + { + return $this->legacy; + } + + /** + * Checks if the lock is actually locked + */ + public function isLocked(): bool + { + // if locking is disabled globally, + // the lock is always open + if (static::isEnabled() === false) { + return false; + } + + if ($this->user === null) { + return false; + } + + // the version is not locked if the editing user + // is the currently logged in user + if ($this->user === App::instance()->user()) { + return false; + } + + // check if the lock is still active due to the + // content currently being edited. + if ($this->isActive() === false) { + return false; + } + + return true; + } + + /** + * Looks for old .lock files and tries to create a + * usable lock instance from them + */ + public static function legacy(ModelWithContent $model): static|null + { + $kirby = $model->kirby(); + $file = static::legacyFile($model); + $id = '/' . $model->id(); + + // no legacy lock file? no lock. + if (file_exists($file) === false) { + return null; + } + + $data = Data::read($file, 'yml', fail: false)[$id] ?? []; + + // no valid lock entry? no lock. + if (isset($data['lock']) === false) { + return null; + } + + // has the lock been unlocked? no lock. + if (isset($data['unlock']) === true) { + return null; + } + + return new static( + user: $kirby->user($data['lock']['user']), + modified: $data['lock']['time'], + legacy: true + ); + } + + /** + * Returns the absolute path to a legacy lock file + */ + public static function legacyFile(ModelWithContent $model): string + { + $root = match ($model::CLASS_ALIAS) { + 'file' => dirname($model->root()), + default => $model->root() + }; + return $root . '/.lock'; + } + + /** + * Returns the timestamp when the locked content has + * been updated. You can pass a format to get a useful, + * formatted date back. + */ + public function modified( + string|null $format = null, + string|null $handler = null + ): int|string|false|null { + if ($this->modified === null) { + return null; + } + + return Str::date($this->modified, $format, $handler); + } + + /** + * Converts the lock info to an array. This is directly + * usable for Panel view props. + */ + public function toArray(): array + { + return [ + 'isLegacy' => $this->isLegacy(), + 'isLocked' => $this->isLocked(), + 'modified' => $this->modified('c', 'date'), + 'user' => [ + 'id' => $this->user?->id(), + 'email' => $this->user?->email() + ] + ]; + } + + /** + * Returns the user to whom this lock belongs + */ + public function user(): User|null + { + return $this->user; + } +} diff --git a/public/kirby/src/Content/LockedContentException.php b/public/kirby/src/Content/LockedContentException.php new file mode 100644 index 0000000..ecf678a --- /dev/null +++ b/public/kirby/src/Content/LockedContentException.php @@ -0,0 +1,31 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LockedContentException extends LogicException +{ + protected static string $defaultKey = 'content.lock'; + protected static string $defaultFallback = 'The version is locked'; + protected static int $defaultHttpCode = 423; + + public function __construct( + Lock $lock, + string|null $key = null, + string|null $message = null, + ) { + parent::__construct( + message: $message, + key: $key, + details: $lock->toArray() + ); + } +} diff --git a/public/kirby/src/Content/MemoryStorage.php b/public/kirby/src/Content/MemoryStorage.php new file mode 100644 index 0000000..4a84d3a --- /dev/null +++ b/public/kirby/src/Content/MemoryStorage.php @@ -0,0 +1,99 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class MemoryStorage extends Storage +{ + /** + * Cache instance, used to store content in memory + */ + protected MemoryCache $cache; + + /** + * Sets up the cache instance + */ + public function __construct(protected ModelWithContent $model) + { + parent::__construct($model); + $this->cache = new MemoryCache(); + } + + /** + * Returns a unique id for a combination + * of the version id, the language code and the model id + */ + protected function cacheId(VersionId $versionId, Language $language): string + { + return $versionId->value() . '/' . $language->code() . '/' . $this->model->id() . '/' . spl_object_hash($this->model); + } + + /** + * Deletes an existing version in an idempotent way if it was already deleted + */ + public function delete(VersionId $versionId, Language $language): void + { + $this->cache->remove($this->cacheId($versionId, $language)); + } + + /** + * Checks if a version exists + */ + public function exists(VersionId $versionId, Language $language): bool + { + return $this->cache->exists($this->cacheId($versionId, $language)); + } + + /** + * Returns the modification timestamp of a version if it exists + */ + public function modified(VersionId $versionId, Language $language): int|null + { + if ($this->exists($versionId, $language) === false) { + return null; + } + + return $this->cache->modified($this->cacheId($versionId, $language)); + } + + /** + * Returns the stored content fields + * + * @return array + */ + public function read(VersionId $versionId, Language $language): array + { + return $this->cache->get($this->cacheId($versionId, $language)) ?? []; + } + + /** + * Updates the modification timestamp of an existing version + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function touch(VersionId $versionId, Language $language): void + { + $fields = $this->read($versionId, $language); + $this->write($versionId, $language, $fields); + } + + /** + * Writes the content fields of an existing version + * + * @param array $fields Content fields + */ + protected function write(VersionId $versionId, Language $language, array $fields): void + { + $this->cache->set($this->cacheId($versionId, $language), $fields); + } +} diff --git a/public/kirby/src/Content/PlainTextContentStorageHandler.php b/public/kirby/src/Content/PlainTextContentStorageHandler.php deleted file mode 100644 index 756816a..0000000 --- a/public/kirby/src/Content/PlainTextContentStorageHandler.php +++ /dev/null @@ -1,253 +0,0 @@ - - * @link https://getkirby.com - * @copyright Bastian Allgeier - * @license https://getkirby.com/license - */ -class PlainTextContentStorageHandler implements ContentStorageHandler -{ - public function __construct(protected ModelWithContent $model) - { - } - - /** - * Creates a new version - * - * @param string $lang Code `'default'` in a single-lang installation - * @param array $fields Content fields - */ - public function create(string $versionType, string $lang, array $fields): void - { - $success = Data::write($this->contentFile($versionType, $lang), $fields); - - // @codeCoverageIgnoreStart - if ($success !== true) { - throw new Exception('Could not write new content file'); - } - // @codeCoverageIgnoreEnd - } - - /** - * Deletes an existing version in an idempotent way if it was already deleted - * - * @param string $lang Code `'default'` in a single-lang installation - */ - public function delete(string $version, string $lang): void - { - $contentFile = $this->contentFile($version, $lang); - $success = F::unlink($contentFile); - - // @codeCoverageIgnoreStart - if ($success !== true) { - throw new Exception('Could not delete content file'); - } - // @codeCoverageIgnoreEnd - - // clean up empty directories - $contentDir = dirname($contentFile); - if ( - Dir::exists($contentDir) === true && - Dir::isEmpty($contentDir) === true - ) { - $success = rmdir($contentDir); - - // @codeCoverageIgnoreStart - if ($success !== true) { - throw new Exception('Could not delete empty content directory'); - } - // @codeCoverageIgnoreEnd - } - } - - /** - * Checks if a version exists - * - * @param string|null $lang Code `'default'` in a single-lang installation; - * checks for "any language" if not provided - */ - public function exists(string $version, string|null $lang): bool - { - if ($lang === null) { - foreach ($this->contentFiles($version) as $file) { - if (is_file($file) === true) { - return true; - } - } - - return false; - } - - return is_file($this->contentFile($version, $lang)) === true; - } - - /** - * Returns the modification timestamp of a version - * if it exists - * - * @param string $lang Code `'default'` in a single-lang installation - */ - public function modified(string $version, string $lang): int|null - { - $modified = F::modified($this->contentFile($version, $lang)); - - if (is_int($modified) === true) { - return $modified; - } - - return null; - } - - /** - * Returns the stored content fields - * - * @param string $lang Code `'default'` in a single-lang installation - * @return array - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function read(string $version, string $lang): array - { - return Data::read($this->contentFile($version, $lang)); - } - - /** - * Updates the modification timestamp of an existing version - * - * @param string $lang Code `'default'` in a single-lang installation - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function touch(string $version, string $lang): void - { - $success = touch($this->contentFile($version, $lang)); - - // @codeCoverageIgnoreStart - if ($success !== true) { - throw new Exception('Could not touch existing content file'); - } - // @codeCoverageIgnoreEnd - } - - /** - * Updates the content fields of an existing version - * - * @param string $lang Code `'default'` in a single-lang installation - * @param array $fields Content fields - * - * @throws \Kirby\Exception\NotFoundException If the version does not exist - */ - public function update(string $version, string $lang, array $fields): void - { - $success = Data::write($this->contentFile($version, $lang), $fields); - - // @codeCoverageIgnoreStart - if ($success !== true) { - throw new Exception('Could not write existing content file'); - } - // @codeCoverageIgnoreEnd - } - - /** - * Returns the absolute path to the content file - * @internal To be made `protected` when the CMS core no longer relies on it - * - * @param string $lang Code `'default'` in a single-lang installation - * - * @throws \Kirby\Exception\LogicException If the model type doesn't have a known content filename - */ - public function contentFile(string $version, string $lang): string - { - if (in_array($version, ['published', 'changes']) !== true) { - throw new InvalidArgumentException('Invalid version identifier "' . $version . '"'); - } - - $extension = $this->model->kirby()->contentExtension(); - $directory = $this->model->root(); - - $directory = match ($this->model::CLASS_ALIAS) { - 'file' => dirname($this->model->root()), - default => $this->model->root() - }; - - $filename = match ($this->model::CLASS_ALIAS) { - 'file' => $this->model->filename(), - 'page' => $this->model->intendedTemplate()->name(), - 'site', - 'user' => $this->model::CLASS_ALIAS, - // @codeCoverageIgnoreStart - default => throw new LogicException('Cannot determine content filename for model type "' . $this->model::CLASS_ALIAS . '"') - // @codeCoverageIgnoreEnd - }; - - if ($this->model::CLASS_ALIAS === 'page' && $this->model->isDraft() === true) { - // changes versions don't need anything extra - // (drafts already have the `_drafts` prefix in their root), - // but a published version is not possible - if ($version === 'published') { - throw new LogicException('Drafts cannot have a published content file'); - } - } elseif ($version === 'changes') { - // other model type or published page that has a changes subfolder - $directory .= '/_changes'; - } - - if ($lang !== 'default') { - return $directory . '/' . $filename . '.' . $lang . '.' . $extension; - } - - return $directory . '/' . $filename . '.' . $extension; - } - - /** - * Returns an array with content files of all languages - * @internal To be made `protected` when the CMS core no longer relies on it - */ - public function contentFiles(string $version): array - { - if ($this->model->kirby()->multilang() === true) { - return $this->model->kirby()->languages()->values( - fn ($lang) => $this->contentFile($version, $lang) - ); - } - - return [ - $this->contentFile($version, 'default') - ]; - } - - /** - * Moves content from one version-language combination to another - * - * @param string $fromLang Code `'default'` in a single-lang installation - * @param string $toLang Code `'default'` in a single-lang installation - */ - public function move( - string $fromVersion, - string $fromLang, - string $toVersion, - string $toLang - ): void { - F::move( - $this->contentFile($fromVersion, $fromLang), - $this->contentFile($toVersion, $toLang) - ); - } -} diff --git a/public/kirby/src/Content/PlainTextStorage.php b/public/kirby/src/Content/PlainTextStorage.php new file mode 100644 index 0000000..c2992b0 --- /dev/null +++ b/public/kirby/src/Content/PlainTextStorage.php @@ -0,0 +1,331 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @unstable + */ +class PlainTextStorage extends Storage +{ + /** + * Creates the absolute directory path for the model + */ + protected function contentDirectory(VersionId $versionId): string + { + $directory = match (true) { + $this->model instanceof File + => dirname($this->model->root()), + default + => $this->model->root() + }; + + if ($versionId->is('changes')) { + $directory .= '/_changes'; + } + + return $directory; + } + + /** + * Returns the absolute path to the content file + * @internal To be made `protected` when the CMS core no longer relies on it + */ + public function contentFile(VersionId $versionId, Language $language): string + { + // get the filename without extension and language code + return match (true) { + $this->model instanceof File => $this->contentFileForFile($this->model, $versionId, $language), + $this->model instanceof Page => $this->contentFileForPage($this->model, $versionId, $language), + $this->model instanceof Site => $this->contentFileForSite($this->model, $versionId, $language), + $this->model instanceof User => $this->contentFileForUser($this->model, $versionId, $language), + // @codeCoverageIgnoreStart + default => throw new LogicException( + message: 'Cannot determine content file for model type "' . $this->model::CLASS_ALIAS . '"' + ) + // @codeCoverageIgnoreEnd + }; + } + + /** + * Returns the absolute path to the content file of a file model + */ + protected function contentFileForFile(File $model, VersionId $versionId, Language $language): string + { + return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->filename(), $language); + } + + /** + * Returns the absolute path to the content file of a page model + */ + protected function contentFileForPage(Page $model, VersionId $versionId, Language $language): string + { + return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->intendedTemplate()->name(), $language); + } + + /** + * Returns the absolute path to the content file of a site model + */ + protected function contentFileForSite(Site $model, VersionId $versionId, Language $language): string + { + return $this->contentDirectory($versionId) . '/' . $this->contentFilename('site', $language); + } + + /** + * Returns the absolute path to the content file of a user model + */ + protected function contentFileForUser(User $model, VersionId $versionId, Language $language): string + { + return $this->contentDirectory($versionId) . '/' . $this->contentFilename('user', $language); + } + + /** + * Creates a filename with extension and optional language code + * in a multi-language installation + */ + protected function contentFilename(string $name, Language $language): string + { + $kirby = $this->model->kirby(); + $extension = $kirby->contentExtension(); + + if ($language->isSingle() === false) { + return $name . '.' . $language->code() . '.' . $extension; + } + + return $name . '.' . $extension; + } + + /** + * Returns an array with content files of all languages + * @internal To be made `protected` when the CMS core no longer relies on it + */ + public function contentFiles(VersionId $versionId): array + { + if ($this->model->kirby()->multilang() === true) { + return $this->model->kirby()->languages()->values( + fn ($language) => $this->contentFile($versionId, $language) + ); + } + + return [ + $this->contentFile($versionId, Language::single()) + ]; + } + + /** + * Deletes an existing version in an idempotent way if it was already deleted + */ + public function delete(VersionId $versionId, Language $language): void + { + $contentFile = $this->contentFile($versionId, $language); + + // @codeCoverageIgnoreStart + if (F::unlink($contentFile) !== true) { + throw new Exception(message: 'Could not delete content file'); + } + // @codeCoverageIgnoreEnd + + $contentDirectory = $this->contentDirectory($versionId); + + // clean up empty content directories (_changes or the page/user directory) + $this->deleteEmptyDirectory($contentDirectory); + + // delete empty _drafts directories for pages + if ( + $versionId->is('latest') === true && + $this->model instanceof Page && + $this->model->isDraft() === true + ) { + $this->deleteEmptyDirectory(dirname($contentDirectory)); + } + } + + /** + * Helper to delete empty _changes directories + * + * @throws \Kirby\Exception\Exception if the directory cannot be deleted + */ + protected function deleteEmptyDirectory(string $directory): void + { + if ( + Dir::exists($directory) === true && + Dir::isEmpty($directory) === true + ) { + // @codeCoverageIgnoreStart + if (Dir::remove($directory) !== true) { + throw new Exception( + message: 'Could not delete empty content directory' + ); + } + // @codeCoverageIgnoreEnd + } + } + + /** + * Checks if a version exists + */ + public function exists(VersionId $versionId, Language $language): bool + { + $contentFile = $this->contentFile($versionId, $language); + + // The version definitely exists, if there's a + // matching content file + if (file_exists($contentFile) === true) { + return true; + } + + // A changed version or non-default language version does not exist + // if the content file was not found + if ( + $versionId->is('latest') === false || + $language->isDefault() === false + ) { + return false; + } + + // Whether the default version exists, + // depends on different cases for each model. + // Page, Site and User exist as soon as the folder is there. + // A File exists as soon as the file is there. + return match (true) { + $this->model instanceof File => is_file($this->model->root()) === true, + $this->model instanceof Page, + $this->model instanceof Site, + $this->model instanceof User => is_dir($this->model->root()) === true, + // @codeCoverageIgnoreStart + default => throw new LogicException( + message: 'Cannot determine existence for model type "' . $this->model::CLASS_ALIAS . '"' + ) + // @codeCoverageIgnoreEnd + }; + } + + /** + * Compare two version-language-storage combinations + */ + public function isSameStorageLocation( + VersionId $fromVersionId, + Language $fromLanguage, + VersionId|null $toVersionId = null, + Language|null $toLanguage = null, + Storage|null $toStorage = null + ) { + // fallbacks to allow keeping the method call lean + $toVersionId ??= $fromVersionId; + $toLanguage ??= $fromLanguage; + $toStorage ??= $this; + + // no need to compare content files if the new + // storage type is different + if ($toStorage instanceof self === false) { + return false; + } + + $contentFileA = $this->contentFile($fromVersionId, $fromLanguage); + $contentFileB = $toStorage->contentFile($toVersionId, $toLanguage); + + return $contentFileA === $contentFileB; + } + + /** + * Returns the modification timestamp of a version + * if it exists + */ + public function modified(VersionId $versionId, Language $language): int|null + { + $modified = F::modified($this->contentFile($versionId, $language)); + + if (is_int($modified) === true) { + return $modified; + } + + return null; + } + + /** + * Returns the stored content fields + * + * @return array + */ + public function read(VersionId $versionId, Language $language): array + { + $contentFile = $this->contentFile($versionId, $language); + + if (file_exists($contentFile) === true) { + return Data::read($contentFile); + } + + // For existing versions that don't have a content file yet, + // we can safely return an empty array that can be filled later. + // This might be the case for pages that only have a directory + // so far, or for files that don't have any metadata yet. + return []; + } + + /** + * Updates the modification timestamp of an existing version + * + * @throws \Kirby\Exception\Exception If the file cannot be touched + */ + public function touch(VersionId $versionId, Language $language): void + { + $success = touch($this->contentFile($versionId, $language)); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception( + message: 'Could not touch existing content file' + ); + } + // @codeCoverageIgnoreEnd + } + + /** + * Writes the content fields of an existing version + * + * @param array $fields Content fields + * + * @throws \Kirby\Exception\Exception If the content cannot be written + */ + protected function write(VersionId $versionId, Language $language, array $fields): void + { + // only store non-null value fields + $fields = array_filter($fields, fn ($field) => $field !== null); + + // Content for files is only stored when there are any fields. + // Otherwise, the storage handler will take care here of cleaning up + // unnecessary content files. + if ($this->model instanceof File && $fields === []) { + $this->delete($versionId, $language); + return; + } + + $success = Data::write($this->contentFile($versionId, $language), $fields); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception(message: 'Could not write the content file'); + } + // @codeCoverageIgnoreEnd + } + +} diff --git a/public/kirby/src/Content/Storage.php b/public/kirby/src/Content/Storage.php new file mode 100644 index 0000000..6d5cda8 --- /dev/null +++ b/public/kirby/src/Content/Storage.php @@ -0,0 +1,325 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @unstable + */ +abstract class Storage +{ + public function __construct(protected ModelWithContent $model) + { + } + + /** + * Returns generator for all existing version-language combinations + * + * @return Generator<\Kirby\Content\VersionId, \Kirby\Cms\Language> + */ + public function all(): Generator + { + foreach (Languages::ensure() as $language) { + foreach ($this->model->versions() as $version) { + if ($this->exists($version->id(), $language) === true) { + yield $version->id() => $language; + } + } + } + } + + /** + * Copies content from one version-language combination to another + */ + public function copy( + VersionId $fromVersionId, + Language $fromLanguage, + VersionId|null $toVersionId = null, + Language|null $toLanguage = null, + Storage|null $toStorage = null + ): void { + // fallbacks to allow keeping the method call lean + $toVersionId ??= $fromVersionId; + $toLanguage ??= $fromLanguage; + $toStorage ??= $this; + + // don't copy content to the same version-language-storage combination + if ($this->isSameStorageLocation( + fromVersionId: $fromVersionId, + fromLanguage: $fromLanguage, + toVersionId: $toVersionId, + toLanguage: $toLanguage, + toStorage: $toStorage + )) { + return; + } + + // read the existing fields + $content = $this->read($fromVersionId, $fromLanguage); + + // create the new version + $toStorage->create($toVersionId, $toLanguage, $content); + } + + /** + * Copies all content to another storage + */ + public function copyAll(Storage $to): void + { + foreach ($this->all() as $versionId => $language) { + $this->copy($versionId, $language, toStorage: $to); + } + } + + /** + * Creates a new version + * + * @param array $fields Content fields + */ + public function create(VersionId $versionId, Language $language, array $fields): void + { + $this->write($versionId, $language, $fields); + } + + /** + * Deletes an existing version in an idempotent way if it was already deleted + */ + abstract public function delete(VersionId $versionId, Language $language): void; + + /** + * Deletes all versions when deleting a language + * @unstable + * @todo Move to `Language` class + */ + public function deleteLanguage(Language $language): void + { + foreach ($this->model->versions() as $version) { + $this->delete($version->id(), $language); + } + } + + /** + * Checks if a version exists + */ + abstract public function exists(VersionId $versionId, Language $language): bool; + + /** + * Creates a new storage instance with all the versions + * from the given storage instance. + */ + public static function from(self $fromStorage): static + { + $toStorage = new static( + model: $fromStorage->model() + ); + + // copy all versions from the given storage instance + // and add them to the new storage instance. + $fromStorage->copyAll($toStorage); + + return $toStorage; + } + + /** + * Compare two version-language-storage combinations + */ + public function isSameStorageLocation( + VersionId $fromVersionId, + Language $fromLanguage, + VersionId|null $toVersionId = null, + Language|null $toLanguage = null, + Storage|null $toStorage = null + ) { + // fallbacks to allow keeping the method call lean + $toVersionId ??= $fromVersionId; + $toLanguage ??= $fromLanguage; + $toStorage ??= $this; + + if ( + $fromVersionId->is($toVersionId) && + $fromLanguage->is($toLanguage) && + $this === $toStorage + ) { + return true; + } + + return false; + } + + /** + * Returns the related model + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * Returns the modification timestamp of a version if it exists + */ + abstract public function modified(VersionId $versionId, Language $language): int|null; + + /** + * Moves content from one version-language combination to another + */ + public function move( + VersionId $fromVersionId, + Language $fromLanguage, + VersionId|null $toVersionId = null, + Language|null $toLanguage = null, + Storage|null $toStorage = null + ): void { + // fallbacks to allow keeping the method call lean + $toVersionId ??= $fromVersionId; + $toLanguage ??= $fromLanguage; + $toStorage ??= $this; + + // don't move content to the same version-language-storage combination + if ($this->isSameStorageLocation( + fromVersionId: $fromVersionId, + fromLanguage: $fromLanguage, + toVersionId: $toVersionId, + toLanguage: $toLanguage, + toStorage: $toStorage + )) { + return; + } + + // copy content to new version + $this->copy( + $fromVersionId, + $fromLanguage, + $toVersionId, + $toLanguage, + $toStorage + ); + + // clean up the old version + $this->delete($fromVersionId, $fromLanguage); + } + + /** + * Moves all content to another storage + */ + public function moveAll(Storage $to): void + { + foreach ($this->all() as $versionId => $language) { + $this->move($versionId, $language, toStorage: $to); + } + } + + /** + * Adapts all versions when converting languages + * @unstable + * @todo Move to `Language` class + */ + public function moveLanguage( + Language $fromLanguage, + Language $toLanguage + ): void { + foreach ($this->model->versions() as $version) { + if ($this->exists($version->id(), $fromLanguage) === true) { + $this->move( + $version->id(), + $fromLanguage, + toLanguage: $toLanguage + ); + } + } + } + + /** + * Returns the stored content fields + * + * @return array + */ + abstract public function read(VersionId $versionId, Language $language): array; + + /** + * Searches and replaces one or multiple strings + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function replaceStrings( + VersionId $versionId, + Language $language, + array $map + ): void { + $fields = $this->read($versionId, $language); + $fields = A::map( + $fields, + function ($value) use ($map) { + // skip fields with null values + if ($value === null) { + return null; + } + + return str_replace( + array_keys($map), + array_values($map), + $value + ); + } + ); + + $this->update($versionId, $language, $fields); + } + + /** + * Updates the modification timestamp of an existing version + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + abstract public function touch(VersionId $versionId, Language $language): void; + + /** + * Touches all versions of a language + * @unstable + * @todo Move to `Language` class + */ + public function touchLanguage(Language $language): void + { + foreach ($this->model->versions() as $version) { + if ($this->exists($version->id(), $language) === true) { + $this->touch($version->id(), $language); + } + } + } + + /** + * Updates the content fields of an existing version + * + * @param array $fields Content fields + * + * @throws \Kirby\Exception\Exception If the file cannot be written + */ + public function update(VersionId $versionId, Language $language, array $fields): void + { + $this->write($versionId, $language, $fields); + } + + /** + * Writes the content fields of an existing version + * + * @param array $fields Content fields + * + * @throws \Kirby\Exception\Exception If the content cannot be written + */ + abstract protected function write(VersionId $versionId, Language $language, array $fields): void; +} diff --git a/public/kirby/src/Content/Translation.php b/public/kirby/src/Content/Translation.php new file mode 100644 index 0000000..e834e4c --- /dev/null +++ b/public/kirby/src/Content/Translation.php @@ -0,0 +1,191 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Translation +{ + /** + * Creates a new translation object + */ + public function __construct( + protected ModelWithContent $model, + protected Version $version, + protected Language $language + ) { + } + + /** + * Improve `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the language code of the + * translation + */ + public function code(): string + { + return $this->language->code(); + } + + /** + * Returns the translation content + * as plain array + */ + public function content(): array + { + return $this->version->content($this->language)->toArray(); + } + + /** + * Absolute path to the translation content file + * + * @deprecated 5.0.0 + */ + public function contentFile(): string + { + Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods'); + return $this->version->contentFile($this->language); + } + + /** + * Creates a new Translation for the given model + * + * @todo Needs to be refactored as soon as Version::create becomes static + * (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408) + */ + public static function create( + ModelWithContent $model, + Version $version, + Language $language, + array $fields, + string|null $slug = null + ): static { + // add the custom slug to the fields array + if ($slug !== null) { + $fields['slug'] = $slug; + } + + $version->save($fields, $language); + + return new static( + model: $model, + version: $version, + language: $language, + ); + } + + /** + * Checks if the translation file exists + */ + public function exists(): bool + { + return $this->version->exists($this->language); + } + + /** + * Returns the translation code as id + */ + public function id(): string + { + return $this->language->code(); + } + + /** + * Checks if the this is the default translation + * of the model + * + * @deprecated 5.0.0 Use `::language()->isDefault()` instead + */ + public function isDefault(): bool + { + Helpers::deprecated('`$translation->isDefault()` has been deprecated. Use `$translation->language()->isDefault()` instead.', 'translation-methods'); + return $this->language->isDefault(); + } + + /** + * Returns the language + */ + public function language(): Language + { + return $this->language; + } + + /** + * Returns the parent page, file or site object + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * @deprecated 5.0.0 Use `$translation->model()` instead + */ + public function parent(): ModelWithContent + { + throw new Exception( + message: '`$translation->parent()` has been deprecated. Please use `$translation->model()` instead' + ); + } + + /** + * Returns the custom translation slug + */ + public function slug(): string|null + { + return $this->version->read($this->language)['slug'] ?? null; + } + + /** + * Converts the most important translation + * props to an array + */ + public function toArray(): array + { + return [ + 'code' => $this->language->code(), + 'content' => $this->content(), + 'exists' => $this->exists(), + 'slug' => $this->slug(), + ]; + } + + /** + * @deprecated 5.0.0 Use `$model->version()->update()` instead + */ + public function update(array|null $data = null, bool $overwrite = false): static + { + throw new Exception( + message: '`$translation->update()` has been deprecated. Please use `$model->version()->update()` instead' + ); + } + + /** + * Returns the version + */ + public function version(): Version + { + return $this->version; + } +} diff --git a/public/kirby/src/Content/Translations.php b/public/kirby/src/Content/Translations.php new file mode 100644 index 0000000..609e21b --- /dev/null +++ b/public/kirby/src/Content/Translations.php @@ -0,0 +1,79 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Content\Translation> + */ +class Translations extends Collection +{ + /** + * Creates a new Translations collection from + * an array of translations properties. This is + * used in ModelWithContent::setTranslations to properly + * normalize an array definition. + * + * @todo Needs to be refactored as soon as Version::create becomes static + * (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408) + */ + public static function create( + ModelWithContent $model, + Version $version, + array $translations + ): static { + foreach ($translations as $translation) { + Translation::create( + model: $model, + version: $version, + language: Language::ensure($translation['code'] ?? 'default'), + fields: $translation['content'] ?? [], + slug: $translation['slug'] ?? null + ); + } + + return static::load( + model: $model, + version: $version + ); + } + + /** + * Simplifies `Translations::find` by allowing to pass + * Language codes that will be properly validated here. + */ + public function findByKey(string $key): Translation|null + { + return parent::get(Language::ensure($key)->code()); + } + + /** + * Loads all available translations for a given model + */ + public static function load( + ModelWithContent $model, + Version $version + ): static { + $translations = []; + + foreach (Languages::ensure() as $language) { + $translations[] = new Translation( + model: $model, + version: $version, + language: $language + ); + } + + return new static($translations); + } +} diff --git a/public/kirby/src/Content/Version.php b/public/kirby/src/Content/Version.php new file mode 100644 index 0000000..5a060af --- /dev/null +++ b/public/kirby/src/Content/Version.php @@ -0,0 +1,687 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class Version +{ + public function __construct( + protected ModelWithContent $model, + protected VersionId $id + ) { + } + + /** + * Returns a Content object for the given language + */ + public function content(Language|string $language = 'default'): Content + { + $language = Language::ensure($language); + $fields = $this->read($language) ?? []; + + // This is where we merge content from the default language + // to provide a fallback for missing/untranslated fields. + // + // @todo This is the critical point that needs to be removed/refactored + // in the future, to provide multi-language support with truly + // individual versions of pages and no longer enforce the fallback. + if ($language->isDefault() === false) { + // merge the fields with the default language + $fields = [ + ...$this->read('default') ?? [], + ...$fields + ]; + } + + // remove fields that should not be used for the Content object + unset($fields['lock']); + + return new Content( + parent: $this->model, + data: $fields, + normalize: false + ); + } + + /** + * Provides simplified access to the absolute content file path. + * This should stay an internal method and be removed as soon as + * the dependency on file storage methods is resolved more clearly. + * + * @internal + */ + public function contentFile(Language|string $language = 'default'): string + { + return $this->model->storage()->contentFile( + $this->id, + Language::ensure($language) + ); + } + + /** + * Make sure that all field names are converted to lower + * case to be able to merge and filter them properly + */ + protected function convertFieldNamesToLowerCase(array $fields): array + { + return array_change_key_case($fields, CASE_LOWER); + } + + /** + * Creates a new version for the given language + * @todo Convert to a static method that creates the version initially with all relevant languages + * + * @param array $fields Content fields + */ + public function create( + array $fields, + Language|string $language = 'default' + ): void { + $language = Language::ensure($language); + + // check if creating is allowed + VersionRules::create($this, $fields, $language); + + // track the changes + if ($this->id->is('changes') === true) { + (new Changes())->track($this->model); + } + + $this->model->storage()->create( + versionId: $this->id, + language: $language, + fields: $this->prepareFieldsBeforeWrite($fields, $language) + ); + + // make sure that an older version does not exist in the cache + VersionCache::remove($this, $language); + } + + /** + * Deletes a version for a specific language + */ + public function delete(Language|string $language = 'default'): void + { + if ($language === '*') { + foreach (Languages::ensure() as $language) { + $this->delete($language); + } + + return; + } + + $language = Language::ensure($language); + + // check if deleting is allowed + VersionRules::delete($this, $language); + + $this->model->storage()->delete($this->id, $language); + + // untrack the changes if the version does no longer exist + // in any of the available languages + if ( + $this->id->is('changes') === true && + $this->exists('*') === false + ) { + (new Changes())->untrack($this->model); + } + + // Remove the version from the cache + VersionCache::remove($this, $language); + } + + /** + * Returns all validation errors for the given language + */ + public function errors(Language|string $language = 'default'): array + { + $fields = Fields::for($this->model, $language); + $fields->fill( + input: $this->content($language)->toArray() + ); + + return $fields->errors(); + } + + /** + * Checks if a version exists for the given language + */ + public function exists(Language|string $language = 'default'): bool + { + // go through all possible languages to check if this + // version exists in any language + if ($language === '*') { + foreach (Languages::ensure() as $language) { + if ($this->exists($language) === true) { + return true; + } + } + + return false; + } + + return $this->model->storage()->exists( + $this->id, + Language::ensure($language) + ); + } + + /** + * Returns the VersionId instance for this version + */ + public function id(): VersionId + { + return $this->id; + } + + /** + * Returns whether the content of both versions + * is identical + */ + public function isIdentical( + Version|VersionId|string $version, + Language|string $language = 'default' + ): bool { + if (is_string($version) === true) { + $version = VersionId::from($version); + } + + if ($version instanceof VersionId) { + $version = $this->sibling($version); + } + + if ($version->id()->is($this->id) === true) { + return true; + } + + $language = Language::ensure($language); + $fields = Fields::for($this->model, $language); + + // read fields low-level from storage + $a = $this->read($language) ?? []; + $b = $version->read($language) ?? []; + + // remove fields that should not be + // considered in the comparison + unset( + $a['lock'], + $b['lock'], + $a['uuid'], + $b['uuid'] + ); + + $a = $fields->reset()->fill(input: $a)->toFormValues(); + $b = $fields->reset()->fill(input: $b)->toFormValues(); + + ksort($a); + ksort($b); + + return $a === $b; + } + + /** + * Checks if the version is the latest version + */ + public function isLatest(): bool + { + return $this->id->is('latest'); + } + + /** + * Checks if the version is locked for the current user + */ + public function isLocked(Language|string $language = 'default'): bool + { + return $this->lock($language)->isLocked(); + } + + /** + * Checks if there are any validation errors for the given language + */ + public function isValid(Language|string $language = 'default'): bool + { + return $this->errors($language) === []; + } + + /** + * Returns the lock object for the version + */ + public function lock(Language|string $language = 'default'): Lock + { + return Lock::for($this, $language); + } + + /** + * Returns the parent model + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * Returns the modification timestamp of a version + * if it exists + */ + public function modified( + Language|string $language = 'default' + ): int|null { + if ($this->exists($language) === true) { + return $this->model->storage()->modified( + $this->id, + Language::ensure($language) + ); + } + + return null; + } + + /** + * Moves the version to a new language and/or version + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function move( + Language|string $fromLanguage, + VersionId|null $toVersionId = null, + Language|string|null $toLanguage = null, + Storage|null $toStorage = null + ): void { + $fromVersion = $this; + $fromLanguage = Language::ensure($fromLanguage); + $toLanguage = Language::ensure($toLanguage ?? $fromLanguage); + $toVersion = $this->sibling($toVersionId ?? $this->id); + + // check if moving is allowed + VersionRules::move( + fromVersion: $fromVersion, + fromLanguage: $fromLanguage, + toVersion: $toVersion, + toLanguage: $toLanguage + ); + + $this->model->storage()->move( + fromVersionId: $fromVersion->id(), + fromLanguage: $fromLanguage, + toVersionId: $toVersion->id(), + toLanguage: $toLanguage, + toStorage: $toStorage + ); + + // remove both versions from the cache + VersionCache::remove($fromVersion, $fromLanguage); + VersionCache::remove($toVersion, $toLanguage); + } + + /** + * Prepare fields to be written by removing unwanted fields + * depending on the language or model and by cleaning the field names + */ + protected function prepareFieldsBeforeWrite( + array $fields, + Language $language + ): array { + // convert all field names to lower case + $fields = $this->convertFieldNamesToLowerCase($fields); + + // make sure to store the right fields for the model + $fields = $this->model->contentFileData($fields, $language); + + // add the editing user + if ( + Lock::isEnabled() === true && + $this->id->is('changes') === true + ) { + $fields['lock'] = $this->model->kirby()->user()?->id(); + + // remove the lock field for any other version or + // if locking is disabled + } else { + unset($fields['lock']); + } + + // the default language stores all fields + if ($language->isDefault() === true) { + return $fields; + } + + // remove all untranslatable fields + foreach ($this->model->blueprint()->fields() as $field) { + if (($field['translate'] ?? true) === false) { + unset($fields[strtolower($field['name'])]); + } + } + + // remove UUID for non-default languages + unset($fields['uuid']); + + return $fields; + } + + /** + * Make sure that reading from storage will always + * return a usable set of fields with clean field names + */ + protected function prepareFieldsAfterRead(array $fields, Language $language): array + { + $fields = $this->convertFieldNamesToLowerCase($fields); + + // ignore all fields with null values + return array_filter($fields, fn ($field) => $field !== null); + } + + /** + * Returns a verification token for the authentication + * of draft and version previews + * @unstable + */ + public function previewToken(): string + { + if ($this->model instanceof Site) { + // the site itself does not render; its preview is the home page + $homePage = $this->model->homePage(); + + if ($homePage === null) { + throw new NotFoundException('The home page does not exist'); + } + + return $homePage->version($this->id)->previewToken(); + } + + if (($this->model instanceof Page) === false) { + throw new LogicException('Invalid model type'); + } + + return $this->previewTokenFromUrl($this->model->url()); + } + + /** + * Returns a verification token for the authentication + * of draft and version previews from a raw URL + */ + protected function previewTokenFromUrl(string $url): string + { + // get rid of all modifiers after the path + $uri = new Uri($url); + $uri->fragment = null; + $uri->params = null; + $uri->query = null; + + $data = [ + 'url' => $uri->toString(), + 'versionId' => $this->id->value() + ]; + + $token = $this->model->kirby()->contentToken( + null, + json_encode($data, JSON_UNESCAPED_SLASHES) + ); + + return substr($token, 0, 10); + } + + /** + * This method can only be applied to the "changes" version. + * It will copy all fields over to the "latest" version and delete + * this version afterwards. + */ + public function publish(Language|string $language = 'default'): void + { + $language = Language::ensure($language); + + // check if publishing is allowed + VersionRules::publish($this, $language); + + $latest = $this->sibling('latest')->read($language) ?? []; + $changes = $this->read($language) ?? []; + + // overwrite all fields that are not in the `changes` version + // with a null value. The ModelWithContent::update method will merge + // the input with the existing content fields and setting null values + // for removed fields will take care of not inheriting old values. + foreach ($latest as $key => $value) { + if (isset($changes[$key]) === false) { + $changes[$key] = null; + } + } + + // update the latest version + $this->model = $this->model->update( + input: $changes, + languageCode: $language->code(), + validate: true + ); + + // delete the changes + $this->delete($language); + } + + /** + * Returns the stored content fields + * + * @return array|null + */ + public function read(Language|string $language = 'default'): array|null + { + $language = Language::ensure($language); + + try { + // make sure that the version exists + VersionRules::read($this, $language); + + $fields = VersionCache::get($this, $language); + + if ($fields === null) { + $fields = $this->model->storage()->read($this->id, $language); + $fields = $this->prepareFieldsAfterRead($fields, $language); + + if ($fields !== null) { + VersionCache::set($this, $language, $fields); + } + } + + return $fields; + } catch (NotFoundException) { + return null; + } + } + + /** + * Replaces the content of the current version with the given fields + * + * @param array $fields Content fields + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function replace( + array $fields, + Language|string $language = 'default' + ): void { + $language = Language::ensure($language); + + // check if replacing is allowed + VersionRules::replace($this, $fields, $language); + + $this->model->storage()->update( + versionId: $this->id, + language: $language, + fields: $this->prepareFieldsBeforeWrite($fields, $language) + ); + + // remove the version from the cache to read + // a fresh version next time + VersionCache::remove($this, $language); + } + + /** + * Convenience wrapper around ::create, ::replace and ::update. + */ + public function save( + array $fields, + Language|string $language = 'default', + bool $overwrite = false + ): void { + if ($this->exists($language) === false) { + $this->create($fields, $language); + return; + } + + if ($overwrite === true) { + $this->replace($fields, $language); + return; + } + + $this->update($fields, $language); + } + + /** + * Returns a sibling version for the same model + */ + public function sibling(VersionId|string $id): Version + { + return new Version( + model: $this->model, + id: VersionId::from($id) + ); + } + + /** + * Updates the modification timestamp of an existing version + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function touch(Language|string $language = 'default'): void + { + $language = Language::ensure($language); + + VersionRules::touch($this, $language); + + $this->model->storage()->touch($this->id, $language); + } + + /** + * Updates the content fields of an existing version + * + * @param array $fields Content fields + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function update( + array $fields, + Language|string $language = 'default' + ): void { + $language = Language::ensure($language); + + // check if updating is allowed + VersionRules::update($this, $fields, $language); + + // merge the previous state with the new state to always + // update to a complete version + $fields = [ + ...$this->read($language), + ...$fields + ]; + + $this->model->storage()->update( + versionId: $this->id, + language: $language, + fields: $this->prepareFieldsBeforeWrite($fields, $language) + ); + + // remove the version from the cache to read + // a fresh version next time + VersionCache::remove($this, $language); + } + + /** + * Returns the preview URL with authentication for drafts and versions + * @unstable + */ + public function url(): string|null + { + if ( + ($this->model instanceof Page || $this->model instanceof Site) === false + ) { + throw new LogicException('Only pages and the site have a content preview URL'); + } + + $url = $this->model->blueprint()->preview(); + + // preview was disabled + if ($url === false) { + return null; + } + + // we only need to add a token for draft and changes previews + if ( + ($this->model instanceof Site || $this->model->isDraft() === false) && + $this->id->is('changes') === false + ) { + return match (true) { + is_string($url) => $url, + default => $this->model->url() + }; + } + + // check if the URL was customized + if (is_string($url) === true) { + return $this->urlFromOption($url); + } + + // it wasn't, use the safer/more reliable model-based preview token + return $this->urlWithQueryParams($this->model->url(), $this->previewToken()); + } + + /** + * Returns the preview URL based on an arbitrary URL from + * the blueprint option + */ + protected function urlFromOption(string $url): string + { + // try to determine a token for a local preview + // (we cannot determine the token for external previews) + if ($token = $this->previewTokenFromUrl($url)) { + return $this->urlWithQueryParams($url, $token); + } + + // fall back to the URL as defined in the blueprint + return $url; + } + + /** + * Assembles the preview URL with the added `_token` and `_version` + * query params, no matter if the base URL already contains query params + */ + protected function urlWithQueryParams(string $baseUrl, string $token): string + { + $uri = new Uri($baseUrl); + $uri->query->_token = $token; + + if ($this->id->is('changes') === true) { + $uri->query->_version = 'changes'; + } + + return $uri->toString(); + } +} diff --git a/public/kirby/src/Content/VersionCache.php b/public/kirby/src/Content/VersionCache.php new file mode 100644 index 0000000..5540a20 --- /dev/null +++ b/public/kirby/src/Content/VersionCache.php @@ -0,0 +1,81 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class VersionCache +{ + /** + * All cache values for all versions + * and language combinations + */ + protected static WeakMap $cache; + + /** + * Tries to receive a fields for a version/language combination + */ + public static function get(Version $version, Language $language): array|null + { + $model = $version->model(); + $key = $version->id() . ':' . $language->code(); + + return static::$cache[$model][$key] ?? null; + } + + /** + * Removes fields for a version/language combination + */ + public static function remove(Version $version, Language $language): void + { + $model = $version->model(); + + if (isset(static::$cache[$model]) === false) { + return; + } + + // Avoid indirect manipulation of WeakMap + $key = $version->id() . ':' . $language->code(); + $map = static::$cache[$model]; + unset($map[$key]); + static::$cache[$model] = $map; + } + + /** + * Resets the cache + */ + public static function reset(): void + { + static::$cache = new WeakMap(); + } + + /** + * Keeps fields for a version/language combination + */ + public static function set( + Version $version, + Language $language, + array $fields = [] + ): void { + $model = $version->model(); + $key = $version->id() . ':' . $language->code(); + + static::$cache ??= new WeakMap(); + static::$cache[$model] ??= []; + static::$cache[$model][$key] = $fields; + } +} diff --git a/public/kirby/src/Content/VersionId.php b/public/kirby/src/Content/VersionId.php new file mode 100644 index 0000000..3317b9f --- /dev/null +++ b/public/kirby/src/Content/VersionId.php @@ -0,0 +1,121 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class VersionId implements Stringable +{ + /** + * Latest stable version of the content + */ + public const LATEST = 'latest'; + + /** + * Latest changes to the content (optional) + */ + public const CHANGES = 'changes'; + + /** + * A global store for a version id that should be + * rendered for each model in a live preview scenario. + */ + public static self|null $render = null; + + /** + * @throws \Kirby\Exception\InvalidArgumentException If the version ID is not valid + */ + public function __construct( + public string $value + ) { + if (in_array($value, [static::CHANGES, static::LATEST], true) === false) { + throw new InvalidArgumentException(message: 'Invalid Version ID'); + } + } + + /** + * Converts the VersionId instance to a simple string value + */ + public function __toString(): string + { + return $this->value; + } + + /** + * Creates a VersionId instance for the latest content changes + */ + public static function changes(): static + { + return new static(static::CHANGES); + } + + /** + * Creates a VersionId instance from a simple string value + */ + public static function from(VersionId|string $value): static + { + if ($value instanceof VersionId) { + return $value; + } + + return new static($value); + } + + /** + * Compares a VersionId object or string value with this id + */ + public function is(VersionId|string $id): bool + { + return static::from($id)->value === $this->value; + } + + /** + * Creates a VersionId instance for the latest stable version of the content + */ + public static function latest(): static + { + return new static(static::LATEST); + } + + /** + * Temporarily sets the version ID for preview rendering + * only for the logic in the callback + */ + public static function render(VersionId|string $versionId, Closure $callback): mixed + { + $original = static::$render; + static::$render = static::from($versionId); + + try { + return $callback(); + } finally { + // ensure that the render version ID is *always* reset + // to the original value, even if an error occurred + static::$render = $original; + } + } + + /** + * Returns the ID value + */ + public function value(): string + { + return $this->value; + } +} diff --git a/public/kirby/src/Content/VersionRules.php b/public/kirby/src/Content/VersionRules.php new file mode 100644 index 0000000..08f8bf1 --- /dev/null +++ b/public/kirby/src/Content/VersionRules.php @@ -0,0 +1,161 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class VersionRules +{ + public static function create( + Version $version, + array $fields, + Language $language + ): void { + if ($version->exists($language) === true) { + throw new LogicException( + message: 'The version already exists' + ); + } + } + + /** + * Checks if a version/language combination exists and otherwise + * will throw a `NotFoundException` + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public static function ensure(Version $version, Language $language): void + { + if ($version->exists($language) === true) { + return; + } + + $message = match($version->model()->kirby()->multilang()) { + true => 'Version "' . $version->id() . ' (' . $language->code() . ')" does not already exist', + false => 'Version "' . $version->id() . '" does not already exist', + }; + + throw new NotFoundException($message); + } + + public static function delete( + Version $version, + Language $language + ): void { + if ($version->isLocked('*') === true) { + throw new LockedContentException( + lock: $version->lock('*'), + key: 'content.lock.delete' + ); + } + } + + public static function move( + Version $fromVersion, + Language $fromLanguage, + Version $toVersion, + Language $toLanguage + ): void { + // make sure that the source version exists + static::ensure($fromVersion, $fromLanguage); + + // check if the source version is locked in any language + if ($fromVersion->isLocked('*') === true) { + throw new LockedContentException( + lock: $fromVersion->lock('*'), + key: 'content.lock.move' + ); + } + + // check if the target version is locked in any language + if ($toVersion->isLocked('*') === true) { + throw new LockedContentException( + lock: $toVersion->lock('*'), + key: 'content.lock.update' + ); + } + } + + public static function publish( + Version $version, + Language $language + ): void { + // the latest version is already published + if ($version->isLatest() === true) { + throw new LogicException( + message: 'This version is already published' + ); + } + + // make sure that the version exists + static::ensure($version, $language); + + // check if the version is locked in any language + if ($version->isLocked('*') === true) { + throw new LockedContentException( + lock: $version->lock('*'), + key: 'content.lock.publish' + ); + } + } + + public static function read( + Version $version, + Language $language + ): void { + static::ensure($version, $language); + } + + public static function replace( + Version $version, + array $fields, + Language $language + ): void { + // make sure that the version exists + static::ensure($version, $language); + + // check if the version is locked in any language + if ($version->isLocked('*') === true) { + throw new LockedContentException( + lock: $version->lock('*'), + key: 'content.lock.replace' + ); + } + } + + public static function touch( + Version $version, + Language $language + ): void { + static::ensure($version, $language); + } + + public static function update( + Version $version, + array $fields, + Language $language + ): void { + static::ensure($version, $language); + + if ($version->isLocked('*') === true) { + throw new LockedContentException( + lock: $version->lock('*'), + key: 'content.lock.update' + ); + } + } +} diff --git a/public/kirby/src/Content/Versions.php b/public/kirby/src/Content/Versions.php new file mode 100644 index 0000000..69ac145 --- /dev/null +++ b/public/kirby/src/Content/Versions.php @@ -0,0 +1,49 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Content\Version> + */ +class Versions extends Collection +{ + /** + * Deletes all versions in the collection + */ + public function delete(): void + { + foreach ($this->data as $version) { + $version->delete('*'); + } + } + + /** + * Loads all available versions for a given model + * + * Versions need to be loaded in the order `changes`, `latest` + * to ensure that models are deleted correctly. The `latest` + * version always needs to be deleted last, otherwise the + * PlainTextStorage handler will not be able to clean up + * content directories. + */ + public static function load( + ModelWithContent $model + ): static { + return new static( + objects: [ + $model->version('changes'), + $model->version('latest'), + ], + parent: $model + ); + } +} diff --git a/public/kirby/src/Data/Data.php b/public/kirby/src/Data/Data.php index b6a1cb6..5a01c90 100644 --- a/public/kirby/src/Data/Data.php +++ b/public/kirby/src/Data/Data.php @@ -4,6 +4,7 @@ namespace Kirby\Data; use Kirby\Exception\Exception; use Kirby\Filesystem\F; +use Throwable; /** * The `Data` class provides readers and @@ -61,13 +62,17 @@ class Data } if ($handler === null || class_exists($handler) === false) { - throw new Exception('Missing handler for type: "' . $type . '"'); + throw new Exception( + message: 'Missing handler for type: "' . $type . '"' + ); } $handler = new $handler(); if ($handler instanceof Handler === false) { - throw new Exception('Handler for type: "' . $type . '" needs to extend Kirby\\Data\\Handler'); + throw new Exception( + message: 'Handler for type: "' . $type . '" needs to extend ' . Handler::class + ); } return $handler; @@ -76,9 +81,20 @@ class Data /** * Decodes data with the specified handler */ - public static function decode($string, string $type): array - { - return static::handler($type)->decode($string); + public static function decode( + $string, + string $type, + bool $fail = true + ): array { + try { + return static::handler($type)->decode($string); + } catch (Throwable $e) { + if ($fail === false) { + return []; + } + + throw $e; + } } /** @@ -94,11 +110,22 @@ class Data * the data handler is automatically chosen by * the extension if not specified */ - public static function read(string $file, string|null $type = null): array - { - $type ??= F::extension($file); - $handler = static::handler($type); - return $handler->read($file); + public static function read( + string $file, + string|null $type = null, + bool $fail = true + ): array { + try { + $type ??= F::extension($file); + $handler = static::handler($type); + return $handler->read($file); + } catch (Throwable $e) { + if ($fail === false) { + return []; + } + + throw $e; + } } /** diff --git a/public/kirby/src/Data/Handler.php b/public/kirby/src/Data/Handler.php index 95dbad3..776060b 100644 --- a/public/kirby/src/Data/Handler.php +++ b/public/kirby/src/Data/Handler.php @@ -38,7 +38,9 @@ abstract class Handler $contents = F::read($file); if ($contents === false) { - throw new Exception('The file "' . $file . '" does not exist or cannot be read'); + throw new Exception( + message: 'The file "' . $file . '" does not exist or cannot be read' + ); } return static::decode($contents); diff --git a/public/kirby/src/Data/Json.php b/public/kirby/src/Data/Json.php index 35fa867..124435f 100644 --- a/public/kirby/src/Data/Json.php +++ b/public/kirby/src/Data/Json.php @@ -18,12 +18,15 @@ class Json extends Handler /** * Converts an array to an encoded JSON string */ - public static function encode($data): string + public static function encode($data, bool $pretty = false): string { - return json_encode( - $data, - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE - ); + $constants = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + + if ($pretty === true) { + $constants |= JSON_PRETTY_PRINT; + } + + return json_encode($data, $constants); } /** @@ -40,7 +43,9 @@ class Json extends Handler } if (is_string($string) === false) { - throw new InvalidArgumentException('Invalid JSON data; please pass a string'); + throw new InvalidArgumentException( + message: 'Invalid JSON data; please pass a string' + ); } $result = json_decode($string, true); @@ -49,6 +54,8 @@ class Json extends Handler return $result; } - throw new InvalidArgumentException('JSON string is invalid'); + throw new InvalidArgumentException( + message: 'JSON string is invalid' + ); } } diff --git a/public/kirby/src/Data/PHP.php b/public/kirby/src/Data/PHP.php index b22d38a..6583bb4 100644 --- a/public/kirby/src/Data/PHP.php +++ b/public/kirby/src/Data/PHP.php @@ -18,30 +18,42 @@ use Kirby\Filesystem\F; class PHP extends Handler { /** - * Converts an array to PHP file content + * Converts data to PHP file content * * @param string $indent For internal use only */ public static function encode($data, string $indent = ''): string { - switch (gettype($data)) { - case 'array': - $indexed = array_keys($data) === range(0, count($data) - 1); - $array = []; + return match (gettype($data)) { + 'array' => static::encodeArray($data, $indent), + 'boolean' => $data ? 'true' : 'false', + 'integer', + 'double' => (string)$data, + default => var_export($data, true) + }; + } - foreach ($data as $key => $value) { - $array[] = "$indent " . ($indexed ? '' : static::encode($key) . ' => ') . static::encode($value, "$indent "); - } + /** + * Converts an array to PHP file content + */ + protected static function encodeArray(array $data, string $indent): string + { + $indexed = array_is_list($data); + $lines = []; - return "[\n" . implode(",\n", $array) . "\n" . $indent . ']'; - case 'boolean': - return $data ? 'true' : 'false'; - case 'integer': - case 'double': - return (string)$data; - default: - return var_export($data, true); + foreach ($data as $key => $value) { + $line = "$indent "; + + if ($indexed === false) { + $line .= static::encode($key) . ' => '; + } + + $line .= static::encode($value, "$indent "); + + $lines[] = $line; } + + return "[\n" . implode(",\n", $lines) . "\n" . $indent . ']'; } /** @@ -49,7 +61,9 @@ class PHP extends Handler */ public static function decode($string): array { - throw new BadMethodCallException('The PHP::decode() method is not implemented'); + throw new BadMethodCallException( + message: 'The PHP::decode() method is not implemented' + ); } /** @@ -58,7 +72,9 @@ class PHP extends Handler public static function read(string $file): array { if (is_file($file) !== true) { - throw new Exception('The file "' . $file . '" does not exist'); + throw new Exception( + message: 'The file "' . $file . '" does not exist' + ); } return (array)F::load($file, [], allowOutput: false); diff --git a/public/kirby/src/Data/Txt.php b/public/kirby/src/Data/Txt.php index 985f5ea..cd0e886 100644 --- a/public/kirby/src/Data/Txt.php +++ b/public/kirby/src/Data/Txt.php @@ -42,13 +42,12 @@ class Txt extends Handler */ protected static function encodeValue(array|string|float $value): string { - // avoid problems with arrays - if (is_array($value) === true) { - $value = Data::encode($value, 'yaml'); - // avoid problems with localized floats - } elseif (is_float($value) === true) { - $value = Str::float($value); - } + // avoid problems with certain values + $value = match (true) { + is_array($value) => Data::encode($value, 'yaml'), + is_float($value) => Str::float($value), + default => $value + }; // escape accidental dividers within a field $value = preg_replace('!(?<=\n|^)----!', '\\----', $value); @@ -64,9 +63,10 @@ class Txt extends Handler $value = trim($value); $result = $key . ':'; - // multi-line content $result .= match (preg_match('!\R!', $value)) { - 1 => "\n\n", + // multi-line content + 1 => "\n\n", + // single line content, just add space after colon default => ' ', }; @@ -89,7 +89,9 @@ class Txt extends Handler } if (is_string($string) === false) { - throw new InvalidArgumentException('Invalid TXT data; please pass a string'); + throw new InvalidArgumentException( + message: 'Invalid TXT data; please pass a string' + ); } // remove Unicode BOM at the beginning of the file diff --git a/public/kirby/src/Data/Xml.php b/public/kirby/src/Data/Xml.php index 68fa511..63d6054 100644 --- a/public/kirby/src/Data/Xml.php +++ b/public/kirby/src/Data/Xml.php @@ -38,7 +38,9 @@ class Xml extends Handler } if (is_string($string) === false) { - throw new InvalidArgumentException('Invalid XML data; please pass a string'); + throw new InvalidArgumentException( + message: 'Invalid XML data; please pass a string' + ); } $result = XmlConverter::parse($string); @@ -53,6 +55,6 @@ class Xml extends Handler return $result; } - throw new InvalidArgumentException('XML string is invalid'); + throw new InvalidArgumentException(message: 'XML string is invalid'); } } diff --git a/public/kirby/src/Data/Yaml.php b/public/kirby/src/Data/Yaml.php index efa9c9c..0c3451d 100644 --- a/public/kirby/src/Data/Yaml.php +++ b/public/kirby/src/Data/Yaml.php @@ -41,7 +41,9 @@ class Yaml extends Handler } if (is_string($string) === false) { - throw new InvalidArgumentException('Invalid YAML data; please pass a string'); + throw new InvalidArgumentException( + message: 'Invalid YAML data; please pass a string' + ); } return match (static::handler()) { @@ -53,7 +55,6 @@ class Yaml extends Handler /** * Returns which YAML parser (`spyc` or `symfony`) * is configured to be used - * @internal */ public static function handler(): string { diff --git a/public/kirby/src/Data/YamlSpyc.php b/public/kirby/src/Data/YamlSpyc.php index a00e92d..cd51e1b 100644 --- a/public/kirby/src/Data/YamlSpyc.php +++ b/public/kirby/src/Data/YamlSpyc.php @@ -38,6 +38,6 @@ class YamlSpyc // apparently Spyc always returns an array, even for invalid YAML syntax // so this Exception should currently never be thrown - throw new InvalidArgumentException('The YAML data cannot be parsed'); // @codeCoverageIgnore + throw new InvalidArgumentException(message: 'The YAML data cannot be parsed'); // @codeCoverageIgnore } } diff --git a/public/kirby/src/Database/Database.php b/public/kirby/src/Database/Database.php index 9db17ae..eb2f4cc 100644 --- a/public/kirby/src/Database/Database.php +++ b/public/kirby/src/Database/Database.php @@ -80,7 +80,7 @@ class Database /** * The last result set */ - protected $lastResult; + protected mixed $lastResult; /** * Optional prefix for table names @@ -145,17 +145,16 @@ class Database */ public function connect(array|null $params = null): PDO|null { - $defaults = [ + $options = [ 'database' => null, 'type' => 'mysql', 'prefix' => null, 'user' => null, 'password' => null, - 'id' => uniqid() + 'id' => uniqid(), + ...$params ]; - $options = array_merge($defaults, $params); - // store the database information $this->database = $options['database']; $this->type = $options['type']; @@ -163,7 +162,9 @@ class Database $this->id = $options['id']; if (isset(static::$types[$this->type]) === false) { - throw new InvalidArgumentException('Invalid database type: ' . $this->type); + throw new InvalidArgumentException( + message: 'Invalid database type: ' . $this->type + ); } // fetch the dsn and store it @@ -305,6 +306,7 @@ class Database // try to prepare and execute the sql try { $this->statement = $this->connection->prepare($query); + // bind parameters to statement foreach ($bindings as $parameter => $value) { // positional parameters start at 1 @@ -361,15 +363,14 @@ class Database array $bindings = [], array $params = [] ) { - $defaults = [ + $options = [ 'flag' => null, 'method' => 'fetchAll', 'fetch' => Obj::class, 'iterator' => Collection::class, + ...$params ]; - $options = array_merge($defaults, $params); - if ($this->hit($query, $bindings) === false) { return false; } @@ -468,7 +469,7 @@ class Database } } - return in_array($table, $this->tables) === true; + return in_array($table, $this->tables, true) === true; } /** @@ -493,7 +494,7 @@ class Database } } - return in_array($column, $this->columnWhitelist[$table]) === true; + return in_array($column, $this->columnWhitelist[$table], true) === true; } /** @@ -513,7 +514,7 @@ class Database } // update cache - if (in_array($table, $this->tables ?? []) !== true) { + if (in_array($table, $this->tables ?? [], true) !== true) { $this->tables[] = $table; } @@ -556,12 +557,19 @@ class Database Database::$types['mysql'] = [ 'sql' => Mysql::class, 'dsn' => function (array $params): string { - if (isset($params['host']) === false && isset($params['socket']) === false) { - throw new InvalidArgumentException('The mysql connection requires either a "host" or a "socket" parameter'); + if ( + isset($params['host']) === false && + isset($params['socket']) === false + ) { + throw new InvalidArgumentException( + message: 'The mysql connection requires either a "host" or a "socket" parameter' + ); } if (isset($params['database']) === false) { - throw new InvalidArgumentException('The mysql connection requires a "database" parameter'); + throw new InvalidArgumentException( + message: 'The mysql connection requires a "database" parameter' + ); } $parts = []; @@ -595,7 +603,9 @@ Database::$types['sqlite'] = [ 'sql' => Sqlite::class, 'dsn' => function (array $params): string { if (isset($params['database']) === false) { - throw new InvalidArgumentException('The sqlite connection requires a "database" parameter'); + throw new InvalidArgumentException( + message: 'The sqlite connection requires a "database" parameter' + ); } return 'sqlite:' . $params['database']; diff --git a/public/kirby/src/Database/Db.php b/public/kirby/src/Database/Db.php index 82d07b5..d7d5c40 100644 --- a/public/kirby/src/Database/Db.php +++ b/public/kirby/src/Database/Db.php @@ -112,7 +112,9 @@ class Db return call_user_func_array([static::$connection, $method], $arguments); } - throw new InvalidArgumentException('Invalid static Db method: ' . $method); + throw new InvalidArgumentException( + message: 'Invalid static Db method: ' . $method + ); } } diff --git a/public/kirby/src/Database/Query.php b/public/kirby/src/Database/Query.php index 73dfe10..659d1d9 100644 --- a/public/kirby/src/Database/Query.php +++ b/public/kirby/src/Database/Query.php @@ -23,11 +23,6 @@ class Query { public const ERROR_INVALID_QUERY_METHOD = 0; - /** - * Parent Database object - */ - protected Database|null $database = null; - /** * The object which should be fetched for each row * or function to call for each row @@ -44,11 +39,6 @@ class Query */ protected array $bindings = []; - /** - * The table name - */ - protected string $table; - /** * The name of the primary key column */ @@ -82,7 +72,7 @@ class Query /** * WHERE clause */ - protected $where = null; + protected string|null $where = null; /** * GROUP BY clause @@ -92,12 +82,12 @@ class Query /** * HAVING clause */ - protected $having = null; + protected string|null $having = null; /** * ORDER BY clause */ - protected $order = null; + protected string|null $order = null; /** * The offset, which should be applied to the select query @@ -115,14 +105,12 @@ class Query protected bool $debug = false; /** - * Constructor - * - * @param \Kirby\Database\Database $database Database object - * @param string $table Optional name of the table, which should be queried + * @param string $table name of the table, which should be queried */ - public function __construct(Database $database, string $table) - { - $this->database = $database; + public function __construct( + protected Database $database, + protected string $table + ) { $this->table($table); } @@ -220,7 +208,9 @@ class Query public function table(string $table): static { if ($this->database->validateTable($table) === false) { - throw new InvalidArgumentException('Invalid table: ' . $table); + throw new InvalidArgumentException( + message: 'Invalid table: ' . $table + ); } $this->table = $table; @@ -335,7 +325,7 @@ class Query public function bindings(array|null $bindings = null): array|static { if (is_array($bindings) === true) { - $this->bindings = array_merge($this->bindings, $bindings); + $this->bindings = [...$this->bindings, ...$bindings]; return $this; } @@ -418,7 +408,6 @@ class Query /** * Attaches an order clause * - * @param string|null $order * @return $this */ public function order(string|null $order = null) @@ -540,8 +529,11 @@ class Query * * @param int $default An optional default value, which should be returned if the query fails */ - public function aggregate(string $method, string $column = '*', int $default = 0) - { + public function aggregate( + string $method, + string $column = '*', + int $default = 0 + ) { // reset the sorting to avoid counting issues $this->order = null; @@ -589,7 +581,11 @@ class Query $this->database->fail(); } - $result = $this->database->query($sql['query'], $sql['bindings'], $params); + $result = $this->database->query( + $sql['query'], + $sql['bindings'], + $params + ); $this->reset(); @@ -720,10 +716,10 @@ class Query // if there isn't already an explicit order, order by the primary key // instead of the column that was requested (which would be implied otherwise) if ($this->order === null) { - $sql = $this->database->sql(); - $primaryKey = $sql->combineIdentifier($this->table, $this->primaryKeyName); + $sql = $this->database->sql(); + $key = $sql->combineIdentifier($this->table, $this->primaryKeyName); - $this->order($primaryKey . ' ASC'); + $this->order($key . ' ASC'); } $results = $this->query($this->select([$column])->build('select'), [ @@ -770,7 +766,9 @@ class Query */ public function insert($values = null) { - $query = $this->execute($this->values($values)->build('insert')); + $query = $this->execute( + $this->values($values)->build('insert') + ); if ($this->debug === true) { return $query; @@ -787,7 +785,9 @@ class Query */ public function update($values = null, $where = null): bool { - return $this->execute($this->values($values)->where($where)->build('update')); + return $this->execute( + $this->values($values)->where($where)->build('update') + ); } /** @@ -797,7 +797,9 @@ class Query */ public function delete($where = null): bool { - return $this->execute($this->where($where)->build('delete')); + return $this->execute( + $this->where($where)->build('delete') + ); } /** @@ -809,7 +811,11 @@ class Query $column = Str::lower($match[1]); return $this->findBy($column, $arguments[0]); } - throw new InvalidArgumentException('Invalid query method: ' . $method, static::ERROR_INVALID_QUERY_METHOD); + + throw new InvalidArgumentException( + message: 'Invalid query method: ' . $method, + code: static::ERROR_INVALID_QUERY_METHOD + ); } /** @@ -818,8 +824,11 @@ class Query * @param array $args Arguments, see where() description * @param mixed $current Current value (like $this->where) */ - protected function filterQuery(array $args, $current, string $mode = 'AND') - { + protected function filterQuery( + array $args, + $current, + string $mode = 'AND' + ) { $result = ''; switch (count($args)) { @@ -827,14 +836,16 @@ class Query if ($args[0] === null) { return $current; + } - // ->where('username like "myuser"'); - } elseif (is_string($args[0]) === true) { + // ->where('username like "myuser"'); + if (is_string($args[0]) === true) { // simply add the entire string to the where clause - // escaping or using bindings has to be done before calling this method + // escaping or using bindings has to be done + // before calling this method $result = $args[0]; - // ->where(['username' => 'myuser']); + // ->where(['username' => 'myuser']); } elseif (is_array($args[0]) === true) { // simple array mode (AND operator) $sql = $this->database->sql()->values($this->table, $args[0], ' AND ', true, true); @@ -852,7 +863,7 @@ class Query call_user_func($args[0], $query); // copy over the bindings from the nested query - $this->bindings = array_merge($this->bindings, $query->bindings); + $this->bindings = [...$this->bindings, ...$query->bindings]; $result = '(' . $query->where . ')'; } @@ -861,15 +872,21 @@ class Query case 2: // ->where('username like :username', ['username' => 'myuser']) - if (is_string($args[0]) === true && is_array($args[1]) === true) { + if ( + is_string($args[0]) === true && + is_array($args[1]) === true + ) { // prepared where clause $result = $args[0]; // store the bindings $this->bindings($args[1]); - // ->where('username like ?', 'myuser') - } elseif (is_string($args[0]) === true && is_scalar($args[1]) === true) { + // ->where('username like ?', 'myuser') + } elseif ( + is_string($args[0]) === true && + is_scalar($args[1]) === true + ) { // prepared where clause $result = $args[0]; @@ -881,7 +898,10 @@ class Query case 3: // ->where('username', 'like', 'myuser'); - if (is_string($args[0]) === true && is_string($args[1]) === true) { + if ( + is_string($args[0]) === true && + is_string($args[1]) === true + ) { // validate column $sql = $this->database->sql(); $key = $sql->columnName($this->table, $args[0]); @@ -890,8 +910,10 @@ class Query // ->where('quantity', 'between', [10, 50]); $predicate = trim(strtoupper($args[1])); if (is_array($args[2]) === true) { - if (in_array($predicate, ['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN']) === false) { - throw new InvalidArgumentException('Invalid predicate ' . $predicate); + if (in_array($predicate, ['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], true) === false) { + throw new InvalidArgumentException( + message: 'Invalid predicate ' . $predicate + ); } // build a list of bound values @@ -913,7 +935,7 @@ class Query }; $result = $key . ' ' . $predicate . ' ' . $values; - // ->where('username', 'like', 'myuser'); + // ->where('username', 'like', 'myuser'); } else { $predicates = [ '=', '>=', '>', '<=', '<', '<>', '!=', '<=>', @@ -923,8 +945,10 @@ class Query 'REGEXP', 'NOT REGEXP' ]; - if (in_array($predicate, $predicates) === false) { - throw new InvalidArgumentException('Invalid predicate/operator ' . $predicate); + if (in_array($predicate, $predicates, true) === false) { + throw new InvalidArgumentException( + message: 'Invalid predicate/operator ' . $predicate + ); } $valueBinding = $sql->bindingName('value'); diff --git a/public/kirby/src/Database/Sql.php b/public/kirby/src/Database/Sql.php index 877d61f..08dfcd1 100644 --- a/public/kirby/src/Database/Sql.php +++ b/public/kirby/src/Database/Sql.php @@ -22,11 +22,6 @@ abstract class Sql */ public static array $literals = ['NOW()', null]; - /** - * The parent database connection - */ - protected Database $database; - /** * List of used bindings; used to avoid * duplicate binding names @@ -37,9 +32,9 @@ abstract class Sql * Constructor * @codeCoverageIgnore */ - public function __construct(Database $database) - { - $this->database = $database; + public function __construct( + protected Database $database + ) { } /** @@ -60,7 +55,7 @@ abstract class Sql // generate random bindings until the name is unique do { $binding = ':' . $label . '_' . Str::random(8, 'alphaNum'); - } while (in_array($binding, $this->bindings) === true); + } while (in_array($binding, $this->bindings, true) === true); // cache the generated binding name for future invocations $this->bindings[] = $binding; @@ -109,14 +104,22 @@ abstract class Sql * @param bool $enforceQualified If true, a qualified identifier is returned in all cases * @return string|null Identifier or null if the table or column is invalid */ - public function columnName(string $table, string $column, bool $enforceQualified = false): string|null - { - // ensure we have clean $table and $column values without qualified identifiers + public function columnName( + string $table, + string $column, + bool $enforceQualified = false + ): string|null { + // ensure we have clean $table and $column values + // without qualified identifiers [$table, $column] = $this->splitIdentifier($table, $column); // combine the identifiers again if ($this->database->validateColumn($table, $column) === true) { - return $this->combineIdentifier($table, $column, $enforceQualified !== true); + return $this->combineIdentifier( + $table, + $column, + $enforceQualified !== true + ); } // the table or column does not exist @@ -145,11 +148,14 @@ abstract class Sql /** * Combines an identifier (table and column) * - * @param $values bool Whether the identifier is going to be used for a VALUES clause; - * only relevant for SQLite + * @param bool $values Whether the identifier is going to be used for a VALUES clause; + * only relevant for SQLite */ - public function combineIdentifier(string $table, string $column, bool $values = false): string - { + public function combineIdentifier( + string $table, + string $column, + bool $values = false + ): string { return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column); } @@ -174,11 +180,17 @@ abstract class Sql { // column type if (isset($column['type']) === false) { - throw new InvalidArgumentException('No column type given for column ' . $name); + throw new InvalidArgumentException( + message: 'No column type given for column ' . $name + ); } + $template = $this->columnTypes()[$column['type']] ?? null; + if (!$template) { - throw new InvalidArgumentException('Unsupported column type: ' . $column['type']); + throw new InvalidArgumentException( + message: 'Unsupported column type: ' . $column['type'] + ); } // null option @@ -198,7 +210,10 @@ abstract class Sql } // unsigned (defaults to true for backwards compatibility) - if (isset($column['unsigned']) === true && $column['unsigned'] === false) { + if ( + isset($column['unsigned']) === true && + $column['unsigned'] === false + ) { $unsigned = ''; } else { $unsigned = 'UNSIGNED'; @@ -207,7 +222,11 @@ abstract class Sql // unique $uniqueKey = false; $uniqueColumn = null; - if (isset($column['unique']) === true && $column['unique'] === true) { + + if ( + isset($column['unique']) === true && + $column['unique'] === true + ) { if (isset($column['key']) === true) { // this column is part of an index, make that unique $uniqueKey = true; @@ -256,7 +275,7 @@ abstract class Sql $sql = $this->createColumn($name, $column); // collect query and bindings - $query[] = $sql['query']; + $query[] = $sql['query']; $bindings += $sql['bindings']; // make a list of keys per key name @@ -266,6 +285,7 @@ abstract class Sql } $keys[$sql['key']][] = $name; + if ($sql['unique'] === true) { $unique[$sql['key']] = true; } @@ -302,8 +322,8 @@ abstract class Sql if ($key === 'primary') { $key = 'PRIMARY KEY'; } else { - $unique = isset($inner['unique'][$key]) === true ? 'UNIQUE ' : ''; - $key = $unique . 'INDEX ' . $this->quoteIdentifier($key); + $unique = isset($inner['unique'][$key]) ? 'UNIQUE ' : ''; + $key = $unique . 'INDEX ' . $this->quoteIdentifier($key); } $inner['query'] .= ',' . PHP_EOL . $key . ' (' . $columns . ')'; @@ -322,13 +342,13 @@ abstract class Sql */ public function delete(array $params = []): array { - $defaults = [ + $options = [ 'table' => '', 'where' => null, - 'bindings' => [] + 'bindings' => [], + ...$params ]; - $options = array_merge($defaults, $params); $bindings = $options['bindings']; $query = ['DELETE']; @@ -359,11 +379,14 @@ abstract class Sql * Extends a given query and bindings * by reference */ - public function extend(array &$query, array &$bindings, array $input): void - { + public function extend( + array &$query, + array &$bindings, + array $input + ): void { if (empty($input['query']) === false) { $query[] = $input['query']; - $bindings = array_merge($bindings, $input['bindings']); + $bindings = [...$bindings, ...$input['bindings']]; } } @@ -419,7 +442,11 @@ abstract class Sql $query = ['INSERT INTO ' . $this->tableName($table)]; // add the values - $this->extend($query, $bindings, $this->values($table, $values, ', ', false)); + $this->extend( + $query, + $bindings, + $this->values($table, $values, ', ', false) + ); return [ 'query' => $this->query($query), @@ -452,8 +479,10 @@ abstract class Sql $type = strtoupper(trim($type)); // validate join type - if (in_array($type, $types) === false) { - throw new InvalidArgumentException('Invalid join type ' . $type); + if (in_array($type, $types, true) === false) { + throw new InvalidArgumentException( + message: 'Invalid join type ' . $type + ); } return [ @@ -471,7 +500,15 @@ abstract class Sql $bindings = []; foreach ((array)$joins as $join) { - $this->extend($query, $bindings, $this->join($join['type'] ?? 'JOIN', $join['table'] ?? null, $join['on'] ?? null)); + $this->extend( + $query, + $bindings, + $this->join( + $join['type'] ?? 'JOIN', + $join['table'] ?? null, + $join['on'] ?? null + ) + ); } return [ @@ -555,7 +592,7 @@ abstract class Sql */ public function select(array $params = []): array { - $defaults = [ + $options = [ 'table' => '', 'columns' => '*', 'join' => null, @@ -566,10 +603,10 @@ abstract class Sql 'order' => null, 'offset' => 0, 'limit' => null, - 'bindings' => [] + 'bindings' => [], + ...$params ]; - $options = array_merge($defaults, $params); $bindings = $options['bindings']; $query = ['SELECT']; @@ -659,7 +696,9 @@ abstract class Sql ], // every other number is an error - default => throw new InvalidArgumentException('Invalid identifier ' . $identifier) + default => throw new InvalidArgumentException( + message: 'Invalid identifier ' . $identifier + ) }; } @@ -678,7 +717,9 @@ abstract class Sql { // validate table if ($this->database->validateTable($table) === false) { - throw new InvalidArgumentException('Invalid table ' . $table); + throw new InvalidArgumentException( + message: 'Invalid table ' . $table + ); } return $this->quoteIdentifier($table); @@ -690,11 +731,17 @@ abstract class Sql public function unquoteIdentifier(string $identifier): string { // remove quotes around the identifier - if (in_array(Str::substr($identifier, 0, 1), ['"', '`']) === true) { + if ( + str_starts_with($identifier, '"') || + str_starts_with($identifier, '`') + ) { $identifier = Str::substr($identifier, 1); } - if (in_array(Str::substr($identifier, -1), ['"', '`']) === true) { + if ( + str_ends_with($identifier, '"') || + str_ends_with($identifier, '`') + ) { $identifier = Str::substr($identifier, 0, -1); } @@ -709,24 +756,32 @@ abstract class Sql */ public function update(array $params = []): array { - $defaults = [ + $options = [ 'table' => null, 'values' => null, 'where' => null, - 'bindings' => [] + 'bindings' => [], + ...$params ]; - $options = array_merge($defaults, $params); $bindings = $options['bindings']; // start the query $query = ['UPDATE ' . $this->tableName($options['table']) . ' SET']; // add the values - $this->extend($query, $bindings, $this->values($options['table'], $options['values'])); + $this->extend( + $query, + $bindings, + $this->values($options['table'], $options['values']) + ); // add the where clause - $this->extend($query, $bindings, $this->where($options['where'])); + $this->extend( + $query, + $bindings, + $this->where($options['where']) + ); return [ 'query' => $this->query($query), @@ -742,7 +797,9 @@ abstract class Sql public function validateColumn(string $table, string $column): bool { if ($this->database->validateColumn($table, $column) !== true) { - throw new InvalidArgumentException('Invalid column ' . $column); + throw new InvalidArgumentException( + message: 'Invalid column ' . $column + ); } return true; @@ -772,10 +829,20 @@ abstract class Sql } if ($set === true) { - return $this->valueSet($table, $values, $separator, $enforceQualified); + return $this->valueSet( + $table, + $values, + $separator, + $enforceQualified + ); } - return $this->valueList($table, $values, $separator, $enforceQualified); + return $this->valueList( + $table, + $values, + $separator, + $enforceQualified + ); } /** @@ -882,10 +949,9 @@ abstract class Sql $query = []; foreach ($where as $key => $value) { - $binding = $this->bindingName('where_' . $key); + $binding = $this->bindingName('where_' . $key); $bindings[$binding] = $value; - - $query[] = $key . ' = ' . $binding; + $query[] = $key . ' = ' . $binding; } return [ diff --git a/public/kirby/src/Database/Sql/Sqlite.php b/public/kirby/src/Database/Sql/Sqlite.php index 267014e..4293bdc 100644 --- a/public/kirby/src/Database/Sql/Sqlite.php +++ b/public/kirby/src/Database/Sql/Sqlite.php @@ -55,8 +55,11 @@ class Sqlite extends Sql * used for a VALUES clause; only relevant * for SQLite */ - public function combineIdentifier(string $table, string $column, bool $values = false): string - { + public function combineIdentifier( + string $table, + string $column, + bool $values = false + ): string { // SQLite doesn't support qualified column names for VALUES clauses if ($values === true) { return $this->quoteIdentifier($column); @@ -72,14 +75,17 @@ class Sqlite extends Sql * @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()` * @return array Array with a `query` string and a `bindings` array */ - public function createTable(string $table, array $columns = []): array - { + public function createTable( + string $table, + array $columns = [] + ): array { $inner = $this->createTableInner($columns); + $keys = []; // add keys - $keys = []; foreach ($inner['keys'] as $key => $columns) { - // quote each column name and make a list string out of the column names + // quote each column name and + // make a list string out of the column names $columns = implode(', ', array_map( fn ($name) => $this->quoteIdentifier($name), $columns @@ -88,15 +94,16 @@ class Sqlite extends Sql if ($key === 'primary') { $inner['query'] .= ',' . PHP_EOL . 'PRIMARY KEY (' . $columns . ')'; } else { - // SQLite only supports index creation using a separate CREATE INDEX query - $unique = isset($inner['unique'][$key]) === true ? 'UNIQUE ' : ''; - $keys[] = 'CREATE ' . $unique . 'INDEX ' . $this->quoteIdentifier($table . '_index_' . $key) . - ' ON ' . $this->quoteIdentifier($table) . ' (' . $columns . ')'; + // SQLite only supports index creation + // using a separate CREATE INDEX query + $unique = isset($inner['unique'][$key]) ? 'UNIQUE ' : ''; + $keys[] = 'CREATE ' . $unique . 'INDEX ' . $this->quoteIdentifier($table . '_index_' . $key) . ' ON ' . $this->quoteIdentifier($table) . ' (' . $columns . ')'; } } $query = 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner['query'] . PHP_EOL . ')'; - if (empty($keys) === false) { + + if ($keys !== []) { $query .= ';' . PHP_EOL . implode(';' . PHP_EOL, $keys); } diff --git a/public/kirby/src/Email/Email.php b/public/kirby/src/Email/Email.php index 2f1b15f..0558d62 100644 --- a/public/kirby/src/Email/Email.php +++ b/public/kirby/src/Email/Email.php @@ -53,7 +53,9 @@ class Email { foreach (['body', 'from', 'to', 'subject'] as $required) { if (isset($props[$required]) === false) { - throw new InvalidArgumentException('The property "' . $required . '" is required'); + throw new InvalidArgumentException( + message: 'The property "' . $required . '" is required' + ); } } diff --git a/public/kirby/src/Email/PHPMailer.php b/public/kirby/src/Email/PHPMailer.php index ee84a40..c04b36e 100644 --- a/public/kirby/src/Email/PHPMailer.php +++ b/public/kirby/src/Email/PHPMailer.php @@ -99,7 +99,9 @@ class PHPMailer extends Email $mailer = $beforeSend->call($this, $mailer) ?? $mailer; if ($mailer instanceof Mailer === false) { - throw new InvalidArgumentException('"beforeSend" option return should be instance of PHPMailer\PHPMailer\PHPMailer class'); + throw new InvalidArgumentException( + message: '"beforeSend" option return should be instance of PHPMailer\PHPMailer\PHPMailer class' + ); } } diff --git a/public/kirby/src/Exception/AuthException.php b/public/kirby/src/Exception/AuthException.php index 1ea6203..325c437 100644 --- a/public/kirby/src/Exception/AuthException.php +++ b/public/kirby/src/Exception/AuthException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * AuthException * Thrown when authentication is required * but no user is logged in. * diff --git a/public/kirby/src/Exception/BadMethodCallException.php b/public/kirby/src/Exception/BadMethodCallException.php index f8a1d1b..58ef466 100644 --- a/public/kirby/src/Exception/BadMethodCallException.php +++ b/public/kirby/src/Exception/BadMethodCallException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * BadMethodCallException * Thrown when a method was called that does not exist * * @package Kirby Exception diff --git a/public/kirby/src/Exception/DuplicateException.php b/public/kirby/src/Exception/DuplicateException.php index 1bea2bf..c04a6c0 100644 --- a/public/kirby/src/Exception/DuplicateException.php +++ b/public/kirby/src/Exception/DuplicateException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * DuplicateException * Thrown when an object could not be created * because it already exists * diff --git a/public/kirby/src/Exception/ErrorPageException.php b/public/kirby/src/Exception/ErrorPageException.php index bd26c6f..12bf385 100644 --- a/public/kirby/src/Exception/ErrorPageException.php +++ b/public/kirby/src/Exception/ErrorPageException.php @@ -3,15 +3,14 @@ namespace Kirby\Exception; /** - * ErrorPageException * Thrown to trigger the CMS error page - * @since 3.3.0 * * @package Kirby Exception * @author Lukas Bestle * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT + * @since 3.3.0 */ class ErrorPageException extends Exception { diff --git a/public/kirby/src/Exception/Exception.php b/public/kirby/src/Exception/Exception.php index ae16686..2b8cbed 100644 --- a/public/kirby/src/Exception/Exception.php +++ b/public/kirby/src/Exception/Exception.php @@ -2,10 +2,10 @@ namespace Kirby\Exception; -use Kirby\Cms\App; use Kirby\Http\Environment; use Kirby\Toolkit\I18n; use Kirby\Toolkit\Str; +use Throwable; /** * Exception @@ -17,6 +17,8 @@ use Kirby\Toolkit\Str; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT + * + * @todo remove $arg array once all exception throws have been refactored */ class Exception extends \Exception { @@ -25,16 +27,16 @@ class Exception extends \Exception */ protected array $data; - /** - * HTTP code that corresponds with the exception - */ - protected int $httpCode; - /** * Additional details that are not included in the exception message */ protected array $details; + /** + * HTTP code that corresponds with the exception + */ + protected int $httpCode; + /** * Whether the exception message could be translated * into the user's language @@ -56,78 +58,88 @@ class Exception extends \Exception */ private static string $prefix = 'error'; - /** - * Class constructor - * - * @param array|string $args Full option array ('key', 'translate', 'fallback', - * 'data', 'httpCode', 'details' and 'previous') or - * just the message string - */ - public function __construct(array|string $args = []) - { - // set data and httpCode from provided arguments or defaults - $this->data = $args['data'] ?? static::$defaultData; - $this->httpCode = $args['httpCode'] ?? static::$defaultHttpCode; - $this->details = $args['details'] ?? static::$defaultDetails; + public function __construct( + array|string $args = [], // @deprecated - // define the Exception key - $key = $args['key'] ?? static::$defaultKey; + string|null $key = null, + array|null $data = null, + array|null $details = null, + string|null $fallback = null, + int|null $httpCode = null, + string|null $message = null, + Throwable|null $previous = null, + bool $translate = true + ) { + $key ??= $args['key'] ?? null; + $fallback ??= $args['fallback'] ?? null; + $previous ??= $args['previous'] ?? null; - if (Str::startsWith($key, self::$prefix . '.') === false) { - $key = self::$prefix . '.' . $key; + $this->data = + $data ?? + $args['data'] ?? + static::$defaultData; + + $this->httpCode = + $httpCode ?? + $args['httpCode'] ?? + static::$defaultHttpCode; + + $this->details = + $details ?? + $args['details'] ?? + static::$defaultDetails; + + // set the Exception code to the key + $this->code = $key ?? static::$defaultKey; + + if (Str::startsWith($this->code, self::$prefix . '.') === false) { + $this->code = self::$prefix . '.' . $this->code; } if (is_string($args) === true) { - $this->isTranslated = false; - parent::__construct($args); - } else { - // define whether message can/should be translated - $translate = - ($args['translate'] ?? true) === true && - class_exists(App::class) === true; - - // fallback waterfall for message string - $message = null; - - if ($translate === true) { - // 1. translation for provided key in current language - // 2. translation for provided key in default language - if (isset($args['key']) === true) { - $message = I18n::translate(self::$prefix . '.' . $args['key']); - $this->isTranslated = true; - } - } - - // 3. provided fallback message - if ($message === null) { - $message = $args['fallback'] ?? null; - $this->isTranslated = false; - } - - if ($translate === true) { - // 4. translation for default key in current language - // 5. translation for default key in default language - if ($message === null) { - $message = I18n::translate(self::$prefix . '.' . static::$defaultKey); - $this->isTranslated = true; - } - } - - // 6. default fallback message - if ($message === null) { - $message = static::$defaultFallback; - $this->isTranslated = false; - } - - // format message with passed data - $message = Str::template($message, $this->data, ['fallback' => '-']); - - // handover to Exception parent class constructor - parent::__construct($message, 0, $args['previous'] ?? null); + $message ??= $args; } - // set the Exception code to the key - $this->code = $key; + if ($message !== null) { + $this->isTranslated = false; + parent::__construct($message); + return; + } + + // define whether message can/should be translated + $translate = $args['translate'] ?? $translate; + + // a. translation for provided key in current language + // b. translation for provided key in default language + if ($translate === true && $key !== null) { + $message = I18n::translate(self::$prefix . '.' . $key); + $this->isTranslated = true; + } + + // c. provided fallback message + if ($message === null) { + $message = $fallback; + $this->isTranslated = false; + } + + // d. translation for default key in current language + // e. translation for default key in default language + if ($translate === true && $message === null) { + $message = I18n::translate(self::$prefix . '.' . static::$defaultKey); + $this->isTranslated = true; + } + + // f. default fallback message + if ($message === null) { + $message = static::$defaultFallback; + $this->isTranslated = false; + } + + // format message with passed data + $message = Str::template($message, $this->data, ['fallback' => '-']); + + // hand over to native Exception class constructor + parent::__construct($message, 0, $previous); } /** @@ -136,14 +148,14 @@ class Exception extends \Exception */ final public function getFileRelative(): string { - $file = $this->getFile(); - $docRoot = Environment::getGlobally('DOCUMENT_ROOT'); + $file = $this->getFile(); + $root = Environment::getGlobally('DOCUMENT_ROOT'); - if (empty($docRoot) === false) { - $file = ltrim(Str::after($file, $docRoot), '/'); + if (empty($root) === true) { + return $file; } - return $file; + return ltrim(Str::after($file, $root), '/'); } /** @@ -160,7 +172,18 @@ class Exception extends \Exception */ final public function getDetails(): array { - return $this->details; + $details = $this->details; + + foreach ($details as $key => $detail) { + if ($detail instanceof Throwable) { + $details[$key] = [ + 'label' => $key, + 'message' => $detail->getMessage(), + ]; + } + } + + return $details; } /** diff --git a/public/kirby/src/Exception/InvalidArgumentException.php b/public/kirby/src/Exception/InvalidArgumentException.php index 25603b7..b7172a7 100644 --- a/public/kirby/src/Exception/InvalidArgumentException.php +++ b/public/kirby/src/Exception/InvalidArgumentException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * InvalidArgumentException * Thrown when a method was called with invalid arguments * * @package Kirby Exception diff --git a/public/kirby/src/Exception/LogicException.php b/public/kirby/src/Exception/LogicException.php index 8fac228..fde83f2 100644 --- a/public/kirby/src/Exception/LogicException.php +++ b/public/kirby/src/Exception/LogicException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * LogicException * Thrown for invalid requests that can't work out * * @package Kirby Exception diff --git a/public/kirby/src/Exception/NotFoundException.php b/public/kirby/src/Exception/NotFoundException.php index 5c7e284..f417f42 100644 --- a/public/kirby/src/Exception/NotFoundException.php +++ b/public/kirby/src/Exception/NotFoundException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * NotFoundException * Thrown when something was not found * * @package Kirby Exception diff --git a/public/kirby/src/Exception/PermissionException.php b/public/kirby/src/Exception/PermissionException.php index ae82a66..352f889 100644 --- a/public/kirby/src/Exception/PermissionException.php +++ b/public/kirby/src/Exception/PermissionException.php @@ -3,7 +3,6 @@ namespace Kirby\Exception; /** - * PermissionException * Thrown when the current user has insufficient * permissions for the action * diff --git a/public/kirby/src/Field/FieldOptions.php b/public/kirby/src/Field/FieldOptions.php index fb98070..395a051 100644 --- a/public/kirby/src/Field/FieldOptions.php +++ b/public/kirby/src/Field/FieldOptions.php @@ -2,7 +2,6 @@ namespace Kirby\Field; -use Kirby\Blueprint\Node; use Kirby\Cms\ModelWithContent; use Kirby\Option\Options; use Kirby\Option\OptionsApi; @@ -18,14 +17,14 @@ use Kirby\Option\OptionsQuery; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class FieldOptions extends Node +class FieldOptions { public function __construct( /** * The option source, either a fixed collection or * a dynamic provider */ - public Options|OptionsProvider|null $options = null, + public Options|OptionsProvider $options = new Options(), /** * Whether to escape special HTML characters in @@ -36,13 +35,6 @@ class FieldOptions extends Node ) { } - public function defaults(): static - { - $this->options ??= new Options(); - - return parent::defaults(); - } - public static function factory(array $props, bool $safeMode = true): static { $options = match ($props['type']) { @@ -67,7 +59,7 @@ class FieldOptions extends Node OptionsQuery::polyfill($props['query'] ?? null), default => - [ 'type' => 'query', 'query' => $props['options']] + ['type' => 'query', 'query' => $props['options']] }; } @@ -87,22 +79,18 @@ class FieldOptions extends Node return $props; } - public function resolve(ModelWithContent $model): Options - { - // apply default values - $this->defaults(); - - // already Options, return - if (is_a($this->options, Options::class) === true) { - return $this->options; - } - - // resolve OptionsProvider (OptionsApi or OptionsQuery) to Options - return $this->options = $this->options->resolve($model, $this->safeMode); - } - public function render(ModelWithContent $model): array { return $this->resolve($model)->render($model); } + + public function resolve(ModelWithContent $model): Options + { + // resolve OptionsProvider (OptionsApi or OptionsQuery) to Options + if ($this->options instanceof OptionsProvider) { + return $this->options->resolve($model, $this->safeMode); + } + + return $this->options; + } } diff --git a/public/kirby/src/Filesystem/Asset.php b/public/kirby/src/Filesystem/Asset.php index 021d6c8..4a3cccb 100644 --- a/public/kirby/src/Filesystem/Asset.php +++ b/public/kirby/src/Filesystem/Asset.php @@ -27,7 +27,7 @@ class Asset /** * Relative file path */ - protected string|null $path; + protected string $path; /** @@ -38,8 +38,12 @@ class Asset $this->root = $this->kirby()->root('index') . '/' . $path; $this->url = $this->kirby()->url('base') . '/' . $path; - $path = dirname($path); - $this->path = $path === '.' ? '' : $path; + // set relative file path + $this->path = dirname($path); + + if ($this->path === '.') { + $this->path = ''; + } } /** @@ -64,7 +68,9 @@ class Asset return $this->callMethod($method, $arguments); } - throw new BadMethodCallException('The method: "' . $method . '" does not exist'); + throw new BadMethodCallException( + message: 'The method: "' . $method . '" does not exist' + ); } /** @@ -75,6 +81,16 @@ class Asset return $this->root(); } + /** + * Returns the absolute path to the media folder + * for the file and its versions + * @since 5.0.0 + */ + public function mediaDir(): string + { + return dirname($this->mediaRoot()); + } + /** * Create a unique media hash */ @@ -86,25 +102,26 @@ class Asset /** * Returns the relative path starting at the media folder */ - public function mediaPath(): string + public function mediaPath(string|null $filename = null): string { - return 'assets/' . $this->path() . '/' . $this->mediaHash() . '/' . $this->filename(); + $filename ??= $this->filename(); + return 'assets/' . $this->path() . '/' . $this->mediaHash() . '/' . $filename; } /** * Returns the absolute path to the file in the public media folder */ - public function mediaRoot(): string + public function mediaRoot(string|null $filename = null): string { - return $this->kirby()->root('media') . '/' . $this->mediaPath(); + return $this->kirby()->root('media') . '/' . $this->mediaPath($filename); } /** * Returns the absolute Url to the file in the public media folder */ - public function mediaUrl(): string + public function mediaUrl(string|null $filename = null): string { - return $this->kirby()->url('media') . '/' . $this->mediaPath(); + return $this->kirby()->url('media') . '/' . $this->mediaPath($filename); } /** diff --git a/public/kirby/src/Filesystem/Dir.php b/public/kirby/src/Filesystem/Dir.php index c12b7d1..34db8bd 100644 --- a/public/kirby/src/Filesystem/Dir.php +++ b/public/kirby/src/Filesystem/Dir.php @@ -76,7 +76,7 @@ class Dir if ( is_array($ignore) === true && - in_array($root, $ignore) === true + in_array($root, $ignore, true) === true ) { continue; } @@ -114,9 +114,14 @@ class Dir /** * Checks if the directory exists on disk */ - public static function exists(string $dir): bool + public static function exists(string $dir, string|null $in = null): bool { - return is_dir($dir) === true; + try { + static::realpath($dir, $in); + return true; + } catch (Exception) { + return false; + } } /** @@ -140,8 +145,8 @@ class Dir /** * Read the directory and all subdirectories * - * @todo Remove support for `$ignore = null` in a major release - * @param array|false|null $ignore Array of absolut file paths; + * @todo Remove support for `$ignore = null` in v6 + * @param array|false|null $ignore Array of absolute file paths; * `false` to disable `Dir::$ignore` list * (passing null is deprecated) */ @@ -160,7 +165,7 @@ class Dir if ( is_array($ignore) === true && - in_array($root, $ignore) === true + in_array($root, $ignore, true) === true ) { continue; } @@ -184,7 +189,7 @@ class Dir */ public static function isEmpty(string $dir): bool { - return count(static::read($dir)) === 0; + return static::read($dir) === []; } /** @@ -211,8 +216,6 @@ class Dir * relevant information. * * Don't use outside the Cms context. - * - * @internal */ public static function inventory( string $dir, @@ -242,7 +245,10 @@ class Dir // loop through all directory items and collect all relevant information foreach ($items as $item) { // ignore all items with a leading dot or underscore - if (in_array(substr($item, 0, 1), ['.', '_']) === true) { + if ( + str_starts_with($item, '.') || + str_starts_with($item, '_') + ) { continue; } @@ -262,7 +268,7 @@ class Dir $extension = pathinfo($item, PATHINFO_EXTENSION); // don't track files with these extensions - if (in_array($extension, ['htm', 'html', 'php']) === true) { + if (in_array($extension, ['htm', 'html', 'php'], true) === true) { continue; } @@ -314,7 +320,7 @@ class Dir } // determine the model - if (empty(Page::$models) === false) { + if (Page::$models !== []) { if ($multilang === true) { $code = App::instance()->defaultLanguage()->code(); $contentExtension = $code . '.' . $contentExtension; @@ -445,7 +451,10 @@ class Dir true => filemtime($dir . '/' . $item), false => static::modified($dir . '/' . $item) }; - $modified = ($newModified > $modified) ? $newModified : $modified; + + if ($newModified > $modified) { + $modified = $newModified; + } } return Str::date($modified, $format, $handler); @@ -510,7 +519,7 @@ class Dir // create the ignore pattern $ignore ??= static::$ignore; - $ignore = array_merge($ignore, ['.', '..']); + $ignore = [...$ignore, '.', '..']; // scan for all files and dirs $result = array_values((array)array_diff(scandir($dir), $ignore)); @@ -523,6 +532,33 @@ class Dir return $result; } + /** + * Returns the absolute path to the directory if the directory can be found. + * @since 4.7.1 + */ + public static function realpath(string $dir, string|null $in = null): string + { + $realpath = realpath($dir); + + if ($realpath === false || is_dir($realpath) === false) { + throw new Exception(sprintf('The directory does not exist at the given path: "%s"', $dir)); + } + + if ($in !== null) { + $parent = realpath($in); + + if ($parent === false || is_dir($parent) === false) { + throw new Exception(sprintf('The parent directory does not exist: "%s"', $in)); + } + + if (substr($realpath, 0, strlen($parent)) !== $parent) { + throw new Exception('The directory is not within the parent directory'); + } + } + + return $realpath; + } + /** * Removes a folder including all containing files and folders */ @@ -539,7 +575,7 @@ class Dir } foreach (scandir($dir) as $childName) { - if (in_array($childName, ['.', '..']) === true) { + if (in_array($childName, ['.', '..'], true) === true) { continue; } diff --git a/public/kirby/src/Filesystem/F.php b/public/kirby/src/Filesystem/F.php index 2f7e6e6..87d8d18 100644 --- a/public/kirby/src/Filesystem/F.php +++ b/public/kirby/src/Filesystem/F.php @@ -146,9 +146,16 @@ class F /** * Copy a file to a new location. */ - public static function copy(string $source, string $target, bool $force = false): bool - { - if (file_exists($source) === false || (file_exists($target) === true && $force === false)) { + public static function copy( + string $source, + string $target, + bool $force = false + ): bool { + if (file_exists($source) === false) { + return false; + } + + if (file_exists($target) === true && $force === false) { return false; } @@ -165,12 +172,10 @@ class F /** * Just an alternative for dirname() to stay consistent * - * - * + * ```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 */ class Path extends Collection { @@ -35,7 +37,7 @@ class Path extends Collection bool $leadingSlash = false, bool $trailingSlash = false ): string { - if (empty($this->data) === true) { + if ($this->data === []) { return ''; } diff --git a/public/kirby/src/Http/Query.php b/public/kirby/src/Http/Query.php index 410e2f0..9d85657 100644 --- a/public/kirby/src/Http/Query.php +++ b/public/kirby/src/Http/Query.php @@ -3,6 +3,7 @@ namespace Kirby\Http; use Kirby\Toolkit\Obj; +use Stringable; /** * A wrapper around a URL query string @@ -15,7 +16,7 @@ use Kirby\Toolkit\Obj; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Query extends Obj +class Query extends Obj implements Stringable { public function __construct(string|array|null $query) { diff --git a/public/kirby/src/Http/Remote.php b/public/kirby/src/Http/Remote.php index 2d596ff..6f2733b 100644 --- a/public/kirby/src/Http/Remote.php +++ b/public/kirby/src/Http/Remote.php @@ -68,11 +68,11 @@ class Remote // update the defaults with App config if set; // request the App instance lazily if ($app = App::instance(null, true)) { - $defaults = array_merge($defaults, $app->option('remote', [])); + $defaults = [...$defaults, ...$app->option('remote', [])]; } // set all options - $this->options = array_merge($defaults, $options); + $this->options = [...$defaults, ...$options]; // add the url $this->options['url'] = $url; @@ -95,11 +95,11 @@ class Remote array $arguments = [] ): static { return new static( - url: $arguments[0], - options: array_merge( - ['method' => strtoupper($method)], - $arguments[1] ?? [] - ) + url: $arguments[0], + options: [ + 'method' => strtoupper($method), + ...$arguments[1] ?? [] + ] ); } @@ -171,7 +171,9 @@ class Remote $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; $this->curlopt[CURLOPT_CAPATH] = $this->options['ca']; } else { - throw new InvalidArgumentException('Invalid "ca" option for the Remote class'); + throw new InvalidArgumentException( + message: 'Invalid "ca" option for the Remote class' + ); } // add the progress @@ -267,13 +269,13 @@ class Remote */ public static function get(string $url, array $params = []): static { - $defaults = [ + $options = [ 'method' => 'GET', 'data' => [], + ...$params ]; - $options = array_merge($defaults, $params); - $query = http_build_query($options['data']); + $query = http_build_query($options['data']); if (empty($query) === false) { $url = match (Url::hasQuery($url)) { diff --git a/public/kirby/src/Http/Request.php b/public/kirby/src/Http/Request.php index 85178a9..d1e0b61 100644 --- a/public/kirby/src/Http/Request.php +++ b/public/kirby/src/Http/Request.php @@ -229,7 +229,7 @@ class Request // the request method can be overwritten with a header $methodOverride = strtoupper(Environment::getGlobally('HTTP_X_HTTP_METHOD_OVERRIDE', '')); - if (in_array($methodOverride, $methods) === true) { + if (in_array($methodOverride, $methods, true) === true) { $method ??= $methodOverride; } @@ -240,7 +240,7 @@ class Request $method = strtoupper($method); // sanitize the method - if (in_array($method, $methods) === false) { + if (in_array($method, $methods, true) === false) { $method = 'GET'; } @@ -310,8 +310,8 @@ class Request foreach (Environment::getGlobally() as $key => $value) { if ( - substr($key, 0, 5) !== 'HTTP_' && - substr($key, 0, 14) !== 'REDIRECT_HTTP_' + str_starts_with($key, 'HTTP_') === false && + str_starts_with($key, 'REDIRECT_HTTP_') === false ) { continue; } diff --git a/public/kirby/src/Http/Request/Auth.php b/public/kirby/src/Http/Request/Auth.php index e73f4da..5b8175d 100644 --- a/public/kirby/src/Http/Request/Auth.php +++ b/public/kirby/src/Http/Request/Auth.php @@ -3,6 +3,7 @@ namespace Kirby\Http\Request; use SensitiveParameter; +use Stringable; /** * Base class for auth types @@ -13,7 +14,7 @@ use SensitiveParameter; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -abstract class Auth +abstract class Auth implements Stringable { /** * @param string $data Raw authentication data after the first space in the `Authorization` header diff --git a/public/kirby/src/Http/Request/Body.php b/public/kirby/src/Http/Request/Body.php index 53bcdd0..ec6f3ce 100644 --- a/public/kirby/src/Http/Request/Body.php +++ b/public/kirby/src/Http/Request/Body.php @@ -2,6 +2,8 @@ namespace Kirby\Http\Request; +use Stringable; + /** * The Body class parses the * request body and provides a nice @@ -14,7 +16,7 @@ namespace Kirby\Http\Request; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Body +class Body implements Stringable { use Data; @@ -84,7 +86,7 @@ class Body return $this->data = $json; } - if (strstr($contents, '=') !== false) { + if (str_contains($contents, '=') === true) { // try to parse the body as query string parse_str($contents, $parsed); diff --git a/public/kirby/src/Http/Request/Query.php b/public/kirby/src/Http/Request/Query.php index 51b004a..ac54457 100644 --- a/public/kirby/src/Http/Request/Query.php +++ b/public/kirby/src/Http/Request/Query.php @@ -2,6 +2,8 @@ namespace Kirby\Http\Request; +use Stringable; + /** * The Query class helps to * parse and inspect URL queries @@ -13,7 +15,7 @@ namespace Kirby\Http\Request; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Query +class Query implements Stringable { use Data; diff --git a/public/kirby/src/Http/Response.php b/public/kirby/src/Http/Response.php index 6650d4a..9962151 100644 --- a/public/kirby/src/Http/Response.php +++ b/public/kirby/src/Http/Response.php @@ -6,6 +6,7 @@ use Closure; use Exception; use Kirby\Exception\LogicException; use Kirby\Filesystem\F; +use Stringable; /** * Representation of an Http response, @@ -18,7 +19,7 @@ use Kirby\Filesystem\F; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Response +class Response implements Stringable { /** * Store for all registered headers, @@ -74,7 +75,7 @@ class Response $this->charset = $charset ?? 'UTF-8'; // automatic mime type detection - if (strpos($this->type, '/') === false) { + if (str_contains($this->type, '/') === false) { $this->type = F::extensionToMime($this->type) ?? 'text/html'; } } @@ -134,7 +135,7 @@ class Response array $props = [] ): static { if (file_exists($file) === false) { - throw new Exception('The file could not be found'); + throw new Exception(message: 'The file could not be found'); } $filename ??= basename($file); @@ -167,10 +168,11 @@ class Response */ public static function file(string $file, array $props = []): static { - $props = array_merge([ + $props = [ 'body' => F::read($file), - 'type' => F::extensionToMime(F::extension($file)) - ], $props); + 'type' => F::extensionToMime(F::extension($file)), + ...$props + ]; // if we couldn't serve a correct MIME type, force // the browser to display the file as plain text to @@ -272,6 +274,23 @@ class Response ]); } + /** + * Creates a refresh response, which will + * send the visitor to the given location + * after the specified number of seconds. + * + * @since 5.0.3 + */ + public static function refresh(string $location = '/', int $code = 302, int $refresh = 0): static + { + return new static([ + 'code' => $code, + 'headers' => [ + 'Refresh' => $refresh . '; url=' . Url::unIdn($location) + ] + ]); + } + /** * Sends all registered headers and * returns the response body diff --git a/public/kirby/src/Http/Route.php b/public/kirby/src/Http/Route.php index 8bc8348..863908b 100644 --- a/public/kirby/src/Http/Route.php +++ b/public/kirby/src/Http/Route.php @@ -131,7 +131,7 @@ class Route */ public static function next(): void { - throw new Exceptions\NextRouteException('next'); + throw new Exceptions\NextRouteException(message: 'next'); } /** @@ -177,7 +177,7 @@ class Route // We only need to check routes with regular expression since all others // would have been able to be matched by the search for literal matches // we just did before we started searching. - if (strpos($pattern, '(') === false) { + if (str_contains($pattern, '(') === false) { return false; } diff --git a/public/kirby/src/Http/Router.php b/public/kirby/src/Http/Router.php index f561bd3..5a9be20 100644 --- a/public/kirby/src/Http/Router.php +++ b/public/kirby/src/Http/Router.php @@ -63,7 +63,9 @@ class Router foreach ($routes as $props) { if (isset($props['pattern'], $props['action']) === false) { - throw new InvalidArgumentException('Invalid route parameters'); + throw new InvalidArgumentException( + message: 'Invalid route parameters' + ); } $patterns = A::wrap($props['pattern']); @@ -163,7 +165,10 @@ class Router array|null $ignore = null ): Route { if (isset($this->routes[$method]) === false) { - throw new InvalidArgumentException('Invalid routing method: ' . $method, 400); + throw new InvalidArgumentException( + message: 'Invalid routing method: ' . $method, + code: 400 + ); } // remove leading and trailing slashes @@ -175,14 +180,17 @@ class Router if ($arguments !== false) { if ( empty($ignore) === true || - in_array($route, $ignore) === false + in_array($route, $ignore, true) === false ) { return $this->route = $route; } } } - throw new Exception('No route found for path: "' . $path . '" and request method: "' . $method . '"', 404); + throw new Exception( + code: 404, + message: 'No route found for path: "' . $path . '" and request method: "' . $method . '"', + ); } /** diff --git a/public/kirby/src/Http/Uri.php b/public/kirby/src/Http/Uri.php index 9cd951c..d640920 100644 --- a/public/kirby/src/Http/Uri.php +++ b/public/kirby/src/Http/Uri.php @@ -5,6 +5,7 @@ namespace Kirby\Http; use Kirby\Cms\App; use Kirby\Exception\InvalidArgumentException; use SensitiveParameter; +use Stringable; use Throwable; /** @@ -16,7 +17,7 @@ use Throwable; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Uri +class Uri implements Stringable { /** * Cache for the current Uri object @@ -100,8 +101,7 @@ class Uri $props['username'] = $props['user'] ?? null; $props['password'] = $props['pass'] ?? null; - - $props = array_merge($props, $inject); + $props = [...$props, ...$inject]; } // parse the path and extract params @@ -245,7 +245,7 @@ class Uri if ( $this->port !== null && - in_array($this->port, [80, 443]) === false + in_array($this->port, [80, 443], true) === false ) { $domain .= ':' . $this->port; } @@ -374,7 +374,9 @@ class Uri if ($port !== null) { if ($port < 1 || $port > 65535) { - throw new InvalidArgumentException('Invalid port format: ' . $port); + throw new InvalidArgumentException( + message: 'Invalid port format: ' . $port + ); } } @@ -396,8 +398,13 @@ class Uri */ public function setScheme(string|null $scheme = null): static { - if ($scheme !== null && in_array($scheme, static::$schemes) === false) { - throw new InvalidArgumentException('Invalid URL scheme: ' . $scheme); + if ( + $scheme !== null && + in_array($scheme, static::$schemes, true) === false + ) { + throw new InvalidArgumentException( + message: 'Invalid URL scheme: ' . $scheme + ); } $this->scheme = $scheme; @@ -465,7 +472,7 @@ class Uri $path = $this->path->toString($slash) . $this->params->toString(true); - if ($this->slash && $slash === true) { + if ($this->slash && ($path !== '' || $slash === true)) { $path .= '/'; } @@ -515,7 +522,7 @@ class Uri // use the full path; // automatically detect the trailing slash from it if possible if (is_string($props['path']) === true) { - $props['slash'] = substr($props['path'], -1, 1) === '/'; + $props['slash'] = str_ends_with($props['path'], '/') === true; } return $props; diff --git a/public/kirby/src/Http/Url.php b/public/kirby/src/Http/Url.php index 8139b94..930f93d 100644 --- a/public/kirby/src/Http/Url.php +++ b/public/kirby/src/Http/Url.php @@ -116,7 +116,7 @@ class Url return $home ?? static::home(); } - if (substr($path, 0, 1) === '#') { + if (str_starts_with($path, '#') === true) { return $path; } @@ -226,7 +226,10 @@ class Url $path ??= ''; // keep relative urls - if (substr($path, 0, 2) === './' || substr($path, 0, 3) === '../') { + if ( + str_starts_with($path, './') === true || + str_starts_with($path, '../') === true + ) { return $path; } diff --git a/public/kirby/src/Http/Visitor.php b/public/kirby/src/Http/Visitor.php index ea2ac2e..1bbdcf6 100644 --- a/public/kirby/src/Http/Visitor.php +++ b/public/kirby/src/Http/Visitor.php @@ -167,7 +167,7 @@ class Visitor { foreach ($this->acceptedMimeTypes() as $acceptedMime) { // look for direct matches - if (in_array($acceptedMime->type(), $mimeTypes)) { + if (in_array($acceptedMime->type(), $mimeTypes, true)) { return $acceptedMime->type(); } diff --git a/public/kirby/src/Image/Camera.php b/public/kirby/src/Image/Camera.php index c149c2e..dd5e044 100644 --- a/public/kirby/src/Image/Camera.php +++ b/public/kirby/src/Image/Camera.php @@ -2,6 +2,8 @@ namespace Kirby\Image; +use Stringable; + /** * Small class which hold info about the camera * @@ -11,7 +13,7 @@ namespace Kirby\Image; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Camera +class Camera implements Stringable { protected string|null $make; protected string|null $model; diff --git a/public/kirby/src/Image/Darkroom.php b/public/kirby/src/Image/Darkroom.php index 7d727e7..3142631 100644 --- a/public/kirby/src/Image/Darkroom.php +++ b/public/kirby/src/Image/Darkroom.php @@ -26,7 +26,7 @@ class Darkroom public function __construct( protected array $settings = [] ) { - $this->settings = array_merge($this->defaults(), $settings); + $this->settings = [...$this->defaults(), ...$settings]; } /** @@ -38,7 +38,7 @@ class Darkroom public static function factory(string $type, array $settings = []): object { if (isset(static::$types[$type]) === false) { - throw new Exception('Invalid Darkroom type'); + throw new Exception(message: 'Invalid Darkroom type'); } $class = static::$types[$type]; @@ -51,7 +51,6 @@ class Darkroom protected function defaults(): array { return [ - 'autoOrient' => true, 'blur' => false, 'crop' => false, 'format' => null, @@ -70,7 +69,7 @@ class Darkroom */ protected function options(array $options = []): array { - $options = array_merge($this->settings, $options); + $options = [...$this->settings, ...$options]; // normalize the crop option if ($options['crop'] === true) { diff --git a/public/kirby/src/Image/Darkroom/GdLib.php b/public/kirby/src/Image/Darkroom/GdLib.php index 0e837d6..de94831 100644 --- a/public/kirby/src/Image/Darkroom/GdLib.php +++ b/public/kirby/src/Image/Darkroom/GdLib.php @@ -28,9 +28,9 @@ class GdLib extends Darkroom $image = new SimpleImage(); $image->fromFile($file); + $image->autoOrient(); $image = $this->resize($image, $options); - $image = $this->autoOrient($image, $options); $image = $this->blur($image, $options); $image = $this->grayscale($image, $options); $image = $this->sharpen($image, $options); @@ -40,19 +40,6 @@ class GdLib extends Darkroom return $options; } - /** - * Activates the autoOrient option in SimpleImage - * unless this is deactivated - */ - protected function autoOrient(SimpleImage $image, array $options): SimpleImage - { - if ($options['autoOrient'] === false) { - return $image; - } - - return $image->autoOrient(); - } - /** * Wrapper around SimpleImage's resize and crop methods */ diff --git a/public/kirby/src/Image/Darkroom/ImageMagick.php b/public/kirby/src/Image/Darkroom/ImageMagick.php index 1fc767a..c117bb0 100644 --- a/public/kirby/src/Image/Darkroom/ImageMagick.php +++ b/public/kirby/src/Image/Darkroom/ImageMagick.php @@ -18,19 +18,6 @@ use Kirby\Image\Focus; */ class ImageMagick extends Darkroom { - /** - * Activates imagemagick's auto-orient feature unless - * it is deactivated via the options - */ - protected function autoOrient(string $file, array $options): string|null - { - if ($options['autoOrient'] === true) { - return '-auto-orient'; - } - - return null; - } - /** * Applies the blur settings */ @@ -135,7 +122,7 @@ class ImageMagick extends Darkroom $command[] = $this->interlace($file, $options); $command[] = $this->coalesce($file, $options); $command[] = $this->grayscale($file, $options); - $command[] = $this->autoOrient($file, $options); + $command[] = '-auto-orient'; $command[] = $this->resize($file, $options); $command[] = $this->quality($file, $options); $command[] = $this->blur($file, $options); @@ -150,7 +137,7 @@ class ImageMagick extends Darkroom // log broken commands if ($return !== 0) { - throw new Exception('The imagemagick convert command could not be executed: ' . $command); + throw new Exception(message: 'The imagemagick convert command could not be executed: ' . $command); } return $options; diff --git a/public/kirby/src/Image/Dimensions.php b/public/kirby/src/Image/Dimensions.php index cf6d334..a8188b6 100644 --- a/public/kirby/src/Image/Dimensions.php +++ b/public/kirby/src/Image/Dimensions.php @@ -3,6 +3,7 @@ namespace Kirby\Image; use Kirby\Toolkit\Str; +use Stringable; /** * The Dimension class is used to provide additional @@ -16,7 +17,7 @@ use Kirby\Toolkit\Str; * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT */ -class Dimensions +class Dimensions implements Stringable { public function __construct( public int $width, @@ -69,8 +70,7 @@ class Dimensions /** * Recalculates the width and height to fit into the given box. * - * - * + * ```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 + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ class ChangesDialog { - public function changes(array $ids = []): array - { - $kirby = App::instance(); - $multilang = $kirby->multilang(); - $changes = []; - - foreach ($ids as $id) { - try { - // parse the given ID to extract - // the path and an optional query - $uri = new Uri($id); - $path = $uri->path()->toString(); - $query = $uri->query(); - $model = Find::parent($path); - $item = $model->panel()->dropdownOption(); - - // add the language to each option, if it is included in the query - // of the given ID and the language actually exists - if ( - $multilang && - $query->language && - $language = $kirby->language($query->language) - ) { - $item['text'] .= ' (' . $language->code() . ')'; - $item['link'] .= '?language=' . $language->code(); - } - - $item['text'] = Escape::html($item['text']); - - $changes[] = $item; - } catch (Throwable) { - continue; - } - } - - return $changes; + public function __construct( + protected Changes $changes = new Changes() + ) { } + /** + * Returns the item props for all changed files + */ + public function files(): array + { + return $this->items($this->changes->files()); + } + + /** + * Helper method to return item props for the given models + */ + public function items(Collection $models): array + { + return $models->values( + fn ($model) => $model->panel()->dropdownOption() + ); + } + + /** + * Returns the backend full definition for dialog + */ public function load(): array { - return $this->state(); - } + if ($this->changes->cacheExists() === false) { + $this->changes->generateCache(); + } - public function state(bool $loading = true, array $changes = []) - { return [ 'component' => 'k-changes-dialog', 'props' => [ - 'changes' => $changes, - 'loading' => $loading + 'files' => $this->files(), + 'pages' => $this->pages(), + 'users' => $this->users(), ] ]; } - public function submit(array $ids): array + /** + * Returns the item props for all changed pages + */ + public function pages(): array { - return $this->state(false, $this->changes($ids)); + return $this->items($this->changes->pages()); + } + + /** + * Returns the item props for all changed users + */ + public function users(): array + { + return $this->items($this->changes->users()); } } diff --git a/public/kirby/src/Panel/Controller/PageTree.php b/public/kirby/src/Panel/Controller/PageTree.php new file mode 100644 index 0000000..4916239 --- /dev/null +++ b/public/kirby/src/Panel/Controller/PageTree.php @@ -0,0 +1,113 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageTree +{ + protected Site $site; + + public function __construct( + ) { + $this->site = App::instance()->site(); + } + + /** + * Returns children for the parent as entries + */ + public function children( + string|null $parent = null, + string|null $moving = null + ): array { + if ($moving !== null) { + $moving = Find::parent($moving); + } + + if ($parent === null) { + return [ + $this->entry($this->site, $moving) + ]; + } + + return Find::parent($parent) + ->childrenAndDrafts() + ->filterBy('isListable', true) + ->values( + fn ($child) => $this->entry($child, $moving) + ); + } + + /** + * Returns the properties to display the site or page + * as an entry in the page tree component + */ + public function entry( + Site|Page $entry, + Page|null $moving = null + ): array { + $panel = $entry->panel(); + $id = $entry->id() ?? '/'; + $uuid = $entry->uuid()?->toString(); + $url = $entry->url(); + $value = $uuid ?? $id; + + return [ + 'children' => $panel->url(true), + 'disabled' => $moving?->isMovableTo($entry) === false, + 'hasChildren' => + $entry->hasChildren() === true || + $entry->hasDrafts() === true, + 'icon' => match (true) { + $entry instanceof Site => 'home', + default => $panel->image()['icon'] ?? null + }, + 'id' => $id, + 'open' => false, + 'label' => match (true) { + $entry instanceof Site => I18n::translate('view.site'), + default => $entry->title()->value() + }, + 'url' => $url, + 'uuid' => $uuid, + 'value' => $value + ]; + } + + /** + * Returns the UUIDs/ids for all parents of the page + */ + public function parents( + string|null $page = null, + bool $includeSite = false, + ): array { + $page = $this->site->page($page); + $parents = $page?->parents()->flip(); + $parents = $parents?->values( + fn ($parent) => $parent->uuid()?->toString() ?? $parent->id() + ); + $parents ??= []; + + if ($includeSite === true) { + array_unshift($parents, $this->site->uuid()?->toString() ?? '/'); + } + + return [ + 'data' => $parents + ]; + } +} diff --git a/public/kirby/src/Panel/Controller/Search.php b/public/kirby/src/Panel/Controller/Search.php new file mode 100644 index 0000000..0baa6da --- /dev/null +++ b/public/kirby/src/Panel/Controller/Search.php @@ -0,0 +1,104 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @unstable + */ +class Search +{ + public static function files( + string|null $query = null, + int|null $limit = null, + int $page = 1 + ): array { + $kirby = App::instance(); + $files = $kirby->site() + ->index(true) + ->filter('isListable', true) + ->files(); + + // add site files which aren't considered by the index + $files = $files->add($kirby->site()->files()); + + // filter and search among those files + $files = $files->filter('isListable', true)->search($query); + + if ($limit !== null) { + $files = $files->paginate($limit, $page); + } + + return [ + 'results' => $files->values(fn ($file) => [ + 'image' => $file->panel()->image(), + 'text' => Escape::html($file->filename()), + 'link' => $file->panel()->url(true), + 'info' => Escape::html($file->id()), + 'uuid' => $file->uuid()->toString(), + ]), + 'pagination' => $files->pagination()?->toArray() + ]; + } + + public static function pages( + string|null $query = null, + int|null $limit = null, + int $page = 1 + ): array { + $kirby = App::instance(); + $pages = $kirby->site() + ->index(true) + ->search($query) + ->filter('isListable', true); + + if ($limit !== null) { + $pages = $pages->paginate($limit, $page); + } + + return [ + 'results' => $pages->values(fn ($page) => [ + 'image' => $page->panel()->image(), + 'text' => Escape::html($page->title()->value()), + 'link' => $page->panel()->url(true), + 'info' => Escape::html($page->id()), + 'uuid' => $page->uuid()?->toString(), + ]), + 'pagination' => $pages->pagination()?->toArray() + ]; + } + + public static function users( + string|null $query = null, + int|null $limit = null, + int $page = 1 + ): array { + $kirby = App::instance(); + $users = $kirby->users()->search($query); + + if ($limit !== null) { + $users = $users->paginate($limit, $page); + } + + return [ + 'results' => $users->values(fn ($user) => [ + 'image' => $user->panel()->image(), + 'text' => Escape::html($user->username()), + 'link' => $user->panel()->url(true), + 'info' => Escape::html($user->role()->title()), + 'uuid' => $user->uuid()->toString(), + ]), + 'pagination' => $users->pagination()?->toArray() + ]; + } +} diff --git a/public/kirby/src/Panel/Field.php b/public/kirby/src/Panel/Field.php index d792fc0..169b60c 100644 --- a/public/kirby/src/Panel/Field.php +++ b/public/kirby/src/Panel/Field.php @@ -41,11 +41,14 @@ class Field $routes = []; foreach ($field->dialogs() as $dialogId => $dialog) { - $routes = array_merge($routes, Dialog::routes( - id: $dialogId, - areaId: 'site', - options: $dialog - )); + $routes = [ + ...$routes, + ...Dialog::routes( + id: $dialogId, + areaId: 'site', + options: $dialog + ) + ]; } return Router::execute($path, $method, $routes); @@ -67,11 +70,14 @@ class Field $routes = []; foreach ($field->drawers() as $drawerId => $drawer) { - $routes = array_merge($routes, Drawer::routes( - id: $drawerId, - areaId: 'site', - options: $drawer - )); + $routes = [ + ...$routes, + ...Drawer::routes( + id: $drawerId, + areaId: 'site', + options: $drawer + ) + ]; } return Router::execute($path, $method, $routes); @@ -82,11 +88,12 @@ class Field */ public static function email(array $props = []): array { - return array_merge([ + return [ 'label' => I18n::translate('email'), 'type' => 'email', 'counter' => false, - ], $props); + ...$props + ]; } /** @@ -119,12 +126,13 @@ class Field 'text' => $index ]; - return array_merge([ + return [ 'label' => I18n::translate('file.sort'), 'type' => 'select', 'empty' => false, - 'options' => $options - ], $props); + 'options' => $options, + ...$props + ]; } @@ -170,12 +178,13 @@ class Field return static::hidden(); } - return array_merge([ + return [ 'label' => I18n::translate('page.changeStatus.position'), 'type' => 'select', 'empty' => false, 'options' => $options, - ], $props); + ...$props + ]; } /** @@ -183,10 +192,11 @@ class Field */ public static function password(array $props = []): array { - return array_merge([ + return [ 'label' => I18n::translate('password'), - 'type' => 'password' - ], $props); + 'type' => 'password', + ...$props + ]; } /** @@ -216,20 +226,22 @@ class Field 'value' => $role->name() ]); - return array_merge([ - 'label' => I18n::translate('role'), - 'type' => count($roles) < 1 ? 'hidden' : 'radio', - 'options' => $roles - ], $props); + return [ + 'label' => I18n::translate('role'), + 'type' => count($roles) < 1 ? 'hidden' : 'radio', + 'options' => $roles, + ...$props + ]; } public static function slug(array $props = []): array { - return array_merge([ + return [ 'label' => I18n::translate('slug'), 'type' => 'slug', - 'allow' => Str::$defaults['slug']['allowed'] - ], $props); + 'allow' => Str::$defaults['slug']['allowed'], + ...$props + ]; } public static function template( @@ -245,23 +257,25 @@ class Field ]; } - return array_merge([ + return [ 'label' => I18n::translate('template'), 'type' => 'select', 'empty' => false, 'options' => $options, 'icon' => 'template', - 'disabled' => count($options) <= 1 - ], $props); + 'disabled' => count($options) <= 1, + ...$props + ]; } public static function title(array $props = []): array { - return array_merge([ + return [ 'label' => I18n::translate('title'), 'type' => 'text', 'icon' => 'title', - ], $props); + ...$props + ]; } /** @@ -277,21 +291,23 @@ class Field ]; } - return array_merge([ - 'label' => I18n::translate('language'), - 'type' => 'select', - 'icon' => 'translate', - 'options' => $translations, - 'empty' => false - ], $props); + return [ + 'label' => I18n::translate('language'), + 'type' => 'select', + 'icon' => 'translate', + 'options' => $translations, + 'empty' => false, + ...$props + ]; } public static function username(array $props = []): array { - return array_merge([ + return [ 'icon' => 'user', 'label' => I18n::translate('name'), 'type' => 'text', - ], $props); + ...$props + ]; } } diff --git a/public/kirby/src/Panel/File.php b/public/kirby/src/Panel/File.php index de31234..0fdc574 100644 --- a/public/kirby/src/Panel/File.php +++ b/public/kirby/src/Panel/File.php @@ -5,6 +5,8 @@ namespace Kirby\Panel; use Kirby\Cms\File as CmsFile; use Kirby\Cms\ModelWithContent; use Kirby\Filesystem\Asset; +use Kirby\Panel\Ui\Buttons\ViewButtons; +use Kirby\Panel\Ui\FilePreview; use Kirby\Toolkit\I18n; use Throwable; @@ -64,36 +66,45 @@ class File extends Model return $breadcrumb; } + /** + * Returns header button names which should be displayed + * on the file view + */ + public function buttons(): array + { + return ViewButtons::view($this)->defaults( + 'open', + 'settings', + 'languages' + )->render(); + } + /** * Provides a kirbytag or markdown * tag for the file, which will be * used in the panel, when the file * gets dragged onto a textarea * - * @internal * @param string|null $type (`auto`|`kirbytext`|`markdown`) */ public function dragText( - string|null $type = null, + string|null $type = 'auto', bool $absolute = false ): string { $type = $this->dragTextType($type); - $url = $this->model->filename(); $file = $this->model->type(); + $url = match ($type) { + 'markdown' => $this->model->permalink(), + default => $this->model->uuid() + }; - // By default only the filename is added as relative URL. - // If an absolute URL is required, either use the permalink - // for markdown notation or the UUID for Kirbytext (since - // Kirbytags support can resolve UUIDs directly) - if ($absolute === true) { - $url = match ($type) { - 'markdown' => $this->model->permalink(), - default => $this->model->uuid() - }; + // if UUIDs are disabled, fall back to the filename + // as relative link or the full absolute URL + $url ??= match ($absolute) { + false => $this->model->filename(), + default => $this->model->url() + }; - // if UUIDs are disabled, fall back to URL - $url ??= $this->model->url(); - } if ($callback = $this->dragTextFromCallback($type, $url)) { return $callback; @@ -119,8 +130,8 @@ class File extends Model { $file = $this->model; $request = $file->kirby()->request(); - $defaults = $request->get(['view', 'update', 'delete']); - $options = array_merge($defaults, $options); + $defaults = $request->get(['delete', 'sort', 'view']); + $options = [...$defaults, ...$options]; $permissions = $this->options(['preview']); $view = $options['view'] ?? 'view'; @@ -149,7 +160,7 @@ class File extends Model 'dialog' => $url . '/changeSort', 'icon' => 'sort', 'text' => I18n::translate('file.sort'), - 'disabled' => $this->isDisabledDropdownOption('update', $options, $permissions) + 'disabled' => $this->isDisabledDropdownOption('sort', $options, $permissions) ]; } @@ -228,10 +239,11 @@ class File extends Model */ protected function imageDefaults(): array { - return array_merge(parent::imageDefaults(), [ + return [ + ...parent::imageDefaults(), 'color' => $this->imageColor(), 'icon' => $this->imageIcon(), - ]); + ]; } /** @@ -267,7 +279,6 @@ class File extends Model /** * Returns the image file object based on provided query - * @internal */ protected function imageSource( string|null $query = null @@ -285,7 +296,7 @@ class File extends Model public function isFocusable(): bool { // blueprint option - $option = $this->model->blueprint()->focus(); + $option = $this->model->blueprint()->focus(); // fallback to whether the file is viewable // (images should be focusable by default, others not) $option ??= $this->model->isViewable(); @@ -365,88 +376,57 @@ class File extends Model $params['text'] ??= '{{ file.filename }}'; - return array_merge(parent::pickerData($params), [ - 'dragText' => $this->dragText('auto', $absolute ?? false), + return [ + ...parent::pickerData($params), + 'dragText' => $this->dragText('auto', absolute: $absolute ?? false), 'filename' => $name, 'id' => $id, 'type' => $this->model->type(), 'url' => $this->model->url() - ]); + ]; } /** - * Returns the data array for the - * view's component props - * @internal + * Returns the data array for the view's component props */ public function props(): array { - $file = $this->model; - $dimensions = $file->dimensions(); + $props = parent::props(); + $file = $this->model; - return array_merge( - parent::props(), - $this->prevNext(), - [ - 'blueprint' => $this->model->template() ?? 'default', - 'model' => [ - 'content' => $this->content(), - 'dimensions' => $dimensions->toArray(), - 'extension' => $file->extension(), - 'filename' => $file->filename(), - 'link' => $this->url(true), - 'mime' => $file->mime(), - 'niceSize' => $file->niceSize(), - 'id' => $id = $file->id(), - 'parent' => $file->parent()->panel()->path(), - 'template' => $file->template(), - 'type' => $file->type(), - 'url' => $file->url(), - 'uuid' => fn () => $file->uuid()?->toString(), - ], - 'preview' => [ - 'focusable' => $this->isFocusable(), - 'image' => $this->image([ - 'back' => 'transparent', - 'ratio' => '1/1' - ], 'cards'), - 'url' => $url = $file->previewUrl(), - 'details' => [ - [ - 'title' => I18n::translate('template'), - 'text' => $file->template() ?? '—' - ], - [ - 'title' => I18n::translate('mime'), - 'text' => $file->mime() - ], - [ - 'title' => I18n::translate('url'), - 'text' => $id, - 'link' => $url - ], - [ - 'title' => I18n::translate('size'), - 'text' => $file->niceSize() - ], - [ - 'title' => I18n::translate('dimensions'), - 'text' => $file->type() === 'image' ? $file->dimensions() . ' ' . I18n::translate('pixel') : '—' - ], - [ - 'title' => I18n::translate('orientation'), - 'text' => $file->type() === 'image' ? I18n::translate('orientation.' . $dimensions->orientation()) : '—' - ], - ] - ] - ] - ); + // Additional model information + // @deprecated Use the top-level props instead + $model = [ + 'dimensions' => $file->dimensions()->toArray(), + 'extension' => $file->extension(), + 'filename' => $file->filename(), + 'link' => $props['link'], + 'mime' => $file->mime(), + 'niceSize' => $file->niceSize(), + 'id' => $props['id'], + 'parent' => $file->parent()->panel()->path(), + 'template' => $file->template(), + 'type' => $file->type(), + 'url' => $file->url(), + 'uuid' => $props['uuid'], + ]; + + return [ + ...$props, + ...$this->prevNext(), + 'blueprint' => $this->model->template() ?? 'default', + 'extension' => $model['extension'], + 'filename' => $model['filename'], + 'mime' => $model['mime'], + 'model' => $model, + 'preview' => FilePreview::factory($this->model)->render(), + 'type' => $model['type'], + 'url' => $model['url'], + ]; } /** - * Returns navigation array with - * previous and next file - * @internal + * Returns navigation array with previous and next file */ public function prevNext(): array { @@ -480,9 +460,7 @@ class File extends Model } /** - * Returns the data array for - * this model's Panel view - * @internal + * Returns the data array for this model's Panel view */ public function view(): array { diff --git a/public/kirby/src/Panel/Home.php b/public/kirby/src/Panel/Home.php index 3abb1c4..cac3ad2 100644 --- a/public/kirby/src/Panel/Home.php +++ b/public/kirby/src/Panel/Home.php @@ -80,7 +80,9 @@ class Home return Panel::url($menuItem['link']); } - throw new NotFoundException('There’s no available Panel page to redirect to'); + throw new NotFoundException( + message: 'There’s no available Panel page to redirect to' + ); } /** @@ -219,7 +221,9 @@ class Home // compare domains to avoid external redirects if (static::hasValidDomain($uri) !== true) { - throw new InvalidArgumentException('External URLs are not allowed for Panel redirects'); + throw new InvalidArgumentException( + message: 'External URLs are not allowed for Panel redirects' + ); } // remove all params to avoid diff --git a/public/kirby/src/Panel/Json.php b/public/kirby/src/Panel/Json.php index 2cfa895..d13fcd3 100644 --- a/public/kirby/src/Panel/Json.php +++ b/public/kirby/src/Panel/Json.php @@ -75,7 +75,7 @@ abstract class Json return static::error('Invalid response', 500); } - if (empty($data) === true) { + if ($data === []) { return static::error('The response is empty', 404); } diff --git a/public/kirby/src/Panel/Lab/Category.php b/public/kirby/src/Panel/Lab/Category.php index 4926737..7932871 100644 --- a/public/kirby/src/Panel/Lab/Category.php +++ b/public/kirby/src/Panel/Lab/Category.php @@ -4,6 +4,7 @@ namespace Kirby\Panel\Lab; use Kirby\Cms\App; use Kirby\Filesystem\Dir; +use Kirby\Filesystem\F; use Kirby\Toolkit\A; use Kirby\Toolkit\Str; @@ -11,15 +12,14 @@ use Kirby\Toolkit\Str; * Category of lab examples located in * `kirby/panel/lab` and `site/lab`. * - * @internal - * @since 4.0.0 - * @codeCoverageIgnore - * * @package Kirby Panel * @author Nico Hoffmann * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore */ class Category { @@ -32,7 +32,7 @@ class Category ) { $this->root = $root ?? static::base() . '/' . $this->id; - if (file_exists($this->root . '/index.php') === true) { + if (F::exists($this->root . '/index.php', static::base()) === true) { $this->props = array_merge( require $this->root . '/index.php', $this->props @@ -92,7 +92,7 @@ class Category return $this->id; } - public static function installed(): bool + public static function isInstalled(): bool { return Dir::exists(static::base()) === true; } diff --git a/public/kirby/src/Panel/Lab/Doc.php b/public/kirby/src/Panel/Lab/Doc.php new file mode 100644 index 0000000..2245bce --- /dev/null +++ b/public/kirby/src/Panel/Lab/Doc.php @@ -0,0 +1,194 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @internal + * @codeCoverageIgnore + */ +class Doc +{ + protected array $data; + + public function __construct( + public string $name, + public string $source, + public string|null $description = null, + public string|null $deprecated = null, + public string|null $docBlock = null, + public array $events = [], + public array $examples = [], + public bool $isUnstable = false, + public array $methods = [], + public array $props = [], + public string|null $since = null, + public array $slots = [], + ) { + $this->description = Doc::kt($this->description ?? ''); + $this->deprecated = Doc::kt($this->deprecated ?? ''); + $this->docBlock = Doc::kt($this->docBlock ?? ''); + } + + /** + * Checks if a documentation file exists for the component + */ + public static function exists(string $name): bool + { + return + file_exists(static::file($name, 'dist')) || + file_exists(static::file($name, 'dev')); + } + + public static function factory(string $name): static|null + { + // protect against path traversal + $name = basename($name); + + // read data + $file = static::file($name, 'dev'); + + if (file_exists($file) === false) { + $file = static::file($name, 'dist'); + } + + $data = Data::read($file); + + // filter internal components + if (isset($data['tags']['internal']) === true) { + return null; + } + + // helper function for gathering parts + $gather = function (string $part, string $class) use ($data) { + $parts = A::map( + $data[$part] ?? [], + fn ($x) => $class::factory($x)?->toArray() + ); + + $parts = array_filter($parts); + usort($parts, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $parts; + }; + + return new static( + name: $name, + source: $data['sourceFile'], + description: $data['description'] ?? null, + deprecated: $data['tags']['deprecated'][0]['description'] ?? null, + docBlock: $data['docsBlocks'][0] ?? null, + examples: $data['tags']['examples'] ?? [], + events: $gather('events', Event::class), + isUnstable: isset($data['tags']['unstable']) === true, + methods: $gather('methods', Method::class), + props: $gather('props', Prop::class), + since: $data['tags']['since'][0]['description'] ?? null, + slots: $gather('slots', Slot::class) + ); + } + + /** + * Returns the path to the documentation file for the component + */ + public static function file(string $name, string $context): string + { + $root = match ($context) { + 'dev' => App::instance()->root('panel') . '/tmp', + 'dist' => App::instance()->root('panel') . '/dist/ui', + }; + + $name = Str::after($name, 'k-'); + $name = Str::kebabToCamel($name); + return $root . '/' . $name . '.json'; + } + + /** + * Helper to resolve KirbyText + */ + public static function kt(string $text, bool $inline = false): string + { + return App::instance()->kirbytext($text, [ + 'markdown' => [ + 'breaks' => false, + 'inline' => $inline, + ] + ]); + } + + /** + * Returns the path to the Lab examples, if available + */ + public function lab(): string|null + { + $root = App::instance()->root('panel') . '/lab'; + + foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) { + $props = require $example; + + if (($props['docs'] ?? null) === $this->name) { + return Str::before(Str::after($example, $root), 'index.php'); + } + } + + return null; + } + + public function source(): string + { + return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->source; + } + + /** + * Returns the data for this documentation + */ + public function toArray(): array + { + return [ + 'component' => $this->name, + 'deprecated' => $this->deprecated, + 'description' => $this->description, + 'docBlock' => $this->docBlock, + 'events' => $this->events, + 'examples' => $this->examples, + 'isUnstable' => $this->isUnstable, + 'methods' => $this->methods, + 'props' => $this->props, + 'since' => $this->since, + 'slots' => $this->slots, + 'source' => $this->source(), + ]; + } + + /** + * Returns the information to display as + * entry in a collection (e.g. on the Lab index view) + */ + public function toItem(): array + { + return [ + 'image' => [ + 'icon' => $this->isUnstable ? 'lab' : 'book', + 'back' => 'light-dark(white, var(--color-gray-800))', + ], + 'text' => $this->name, + 'link' => '/lab/docs/' . $this->name, + ]; + } +} diff --git a/public/kirby/src/Panel/Lab/Doc/Argument.php b/public/kirby/src/Panel/Lab/Doc/Argument.php new file mode 100644 index 0000000..f6e256f --- /dev/null +++ b/public/kirby/src/Panel/Lab/Doc/Argument.php @@ -0,0 +1,46 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @internal + * @codeCoverageIgnore + */ +class Argument +{ + public function __construct( + public string $name, + public string|null $type = null, + public string|null $description = null, + ) { + $this->description = Doc::kt($this->description ?? '', true); + } + + public static function factory(array $data): static + { + return new static( + name: $data['name'], + type: $data['type']['names'][0] ?? null, + description: $data['description'] ?? null, + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'description' => $this->description, + 'type' => $this->type, + ]; + } +} diff --git a/public/kirby/src/Panel/Lab/Doc/Event.php b/public/kirby/src/Panel/Lab/Doc/Event.php new file mode 100644 index 0000000..6cb68e5 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Doc/Event.php @@ -0,0 +1,57 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @internal + * @codeCoverageIgnore + */ +class Event +{ + public function __construct( + public string $name, + public string|null $description = null, + public string|null $deprecated = null, + public string|null $since = null, + public array $properties = [], + ) { + $this->description = Doc::kt($this->description ?? ''); + $this->deprecated = Doc::kt($this->deprecated ?? ''); + } + + public static function factory(array $data): static + { + return new static( + name: $data['name'], + description: $data['description'] ?? null, + deprecated: $data['tags']['deprecated'][0]['description'] ?? null, + since: $data['tags']['since'][0]['description'] ?? null, + properties: A::map( + $data['properties'] ?? [], + fn ($property) => Argument::factory($property) + ) + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'description' => $this->description, + 'deprecated' => $this->deprecated, + 'properties' => $this->properties, + 'since' => $this->since, + ]; + } +} diff --git a/public/kirby/src/Panel/Lab/Doc/Method.php b/public/kirby/src/Panel/Lab/Doc/Method.php new file mode 100644 index 0000000..b96b140 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Doc/Method.php @@ -0,0 +1,60 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @internal + * @codeCoverageIgnore + */ +class Method +{ + public function __construct( + public string $name, + public string|null $description = null, + public string|null $deprecated = null, + public string|null $since = null, + public string|null $returns = null, + public array $params = [], + ) { + $this->description = Doc::kt($this->description ?? ''); + $this->deprecated = Doc::kt($this->deprecated ?? ''); + } + + public static function factory(array $data): static + { + return new static( + name: $data['name'], + description: $data['description'] ?? null, + deprecated: $data['tags']['deprecated'][0]['description'] ?? null, + since: $data['tags']['since'][0]['description'] ?? null, + returns: $data['returns']['type']['name'] ?? null, + params: A::map( + $data['params'] ?? [], + fn ($param) => Argument::factory($param) + ), + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'description' => $this->description, + 'deprecated' => $this->deprecated, + 'params' => $this->params, + 'returns' => $this->returns, + 'since' => $this->since, + ]; + } +} diff --git a/public/kirby/src/Panel/Lab/Doc/Prop.php b/public/kirby/src/Panel/Lab/Doc/Prop.php new file mode 100644 index 0000000..a439f53 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Doc/Prop.php @@ -0,0 +1,113 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @internal + * @codeCoverageIgnore + */ +class Prop +{ + public function __construct( + public string $name, + public string|null $type = null, + public string|null $description = null, + public string|null $default = null, + public string|null $deprecated = null, + public string|null $example = null, + public bool $required = false, + public string|null $since = null, + public string|null $value = null, + public array $values = [] + ) { + $this->description = Doc::kt($this->description ?? ''); + $this->deprecated = Doc::kt($this->deprecated ?? ''); + } + + public static function factory(array $data): static|null + { + // filter internal props + if (isset($data['tags']['internal']) === true) { + return null; + } + + // filter unset props + if (($type = $data['type']['name'] ?? null) === 'null') { + return null; + } + + return new static( + name: $data['name'], + type: $type, + default: self::normalizeDefault($data['defaultValue']['value'] ?? null, $type), + description: $data['description'] ?? null, + deprecated: $data['tags']['deprecated'][0]['description'] ?? null, + example: $data['tags']['example'][0]['description'] ?? null, + required: $data['required'] ?? false, + since: $data['tags']['since'][0]['description'] ?? null, + value: $data['tags']['value'][0]['description'] ?? null, + values: $data['values'] ?? [] + ); + } + + protected static function normalizeDefault( + string|null $default, + string|null $type + ): string|null { + if ($default === null) { + // if type is boolean primarily and no default + // value has been set, add `false` as default + // for clarity + if (Str::startsWith($type, 'boolean')) { + return 'false'; + } + + return null; + } + + // normalize longform function + if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) { + return $matches[1]; + } + + // normalize object shorthand function + if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) { + return $matches[1]; + } + + // normalize all other defaults from shorthand function + if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) { + return $matches[1]; + } + + return $default; + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'default' => $this->default, + 'description' => $this->description, + 'deprecated' => $this->deprecated, + 'example' => $this->example, + 'required' => $this->required, + 'since' => $this->since, + 'type' => $this->type, + 'value' => $this->value, + 'values' => $this->values, + ]; + } +} diff --git a/public/kirby/src/Panel/Lab/Doc/Slot.php b/public/kirby/src/Panel/Lab/Doc/Slot.php new file mode 100644 index 0000000..b7aa5a9 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Doc/Slot.php @@ -0,0 +1,57 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @internal + * @codeCoverageIgnore + */ +class Slot +{ + public function __construct( + public string $name, + public string|null $description = null, + public string|null $deprecated = null, + public string|null $since = null, + public array $bindings = [], + ) { + $this->description = Doc::kt($this->description ?? ''); + $this->deprecated = Doc::kt($this->deprecated ?? ''); + } + + public static function factory(array $data): static + { + return new static( + name: $data['name'], + description: $data['description'] ?? null, + deprecated: $data['tags']['deprecated'][0]['description'] ?? null, + since: $data['tags']['since'][0]['description'] ?? null, + bindings: A::map( + $data['bindings'] ?? [], + fn ($binding) => Argument::factory($binding) + ) + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'bindings' => $this->bindings, + 'description' => $this->description, + 'deprecated' => $this->deprecated, + 'since' => $this->since, + ]; + } +} diff --git a/public/kirby/src/Panel/Lab/Docs.php b/public/kirby/src/Panel/Lab/Docs.php index 44a6af0..e0c5c82 100644 --- a/public/kirby/src/Panel/Lab/Docs.php +++ b/public/kirby/src/Panel/Lab/Docs.php @@ -3,286 +3,66 @@ namespace Kirby\Panel\Lab; use Kirby\Cms\App; -use Kirby\Data\Data; use Kirby\Filesystem\Dir; use Kirby\Filesystem\F; use Kirby\Toolkit\A; use Kirby\Toolkit\Str; /** - * Docs for a single Vue component - * - * @internal - * @since 4.0.0 - * @codeCoverageIgnore + * Docs for Vue components * * @package Kirby Panel * @author Nico Hoffmann * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore */ class Docs { - protected array $json; - protected App $kirby; - - public function __construct( - protected string $name - ) { - $this->kirby = App::instance(); - $this->json = $this->read(); - } - + /** + * Returns list of all component docs + * for the Lab index view + */ public static function all(): array { + $docs = []; $dist = static::root(); $tmp = static::root(true); $files = Dir::inventory($dist)['files']; if (Dir::exists($tmp) === true) { - $files = [...Dir::inventory($tmp)['files'], ...$files]; + $files = [...$files, ...Dir::inventory($tmp)['files']]; } $docs = A::map( $files, function ($file) { $component = 'k-' . Str::camelToKebab(F::name($file['filename'])); - - return [ - 'image' => [ - 'icon' => 'book', - 'back' => 'white', - ], - 'text' => $component, - 'link' => '/lab/docs/' . $component, - ]; + return Doc::factory($component)?->toItem(); } ); + $docs = array_filter($docs); usort($docs, fn ($a, $b) => $a['text'] <=> $b['text']); - return array_values($docs); + return $docs; } - public function deprecated(): string|null - { - return $this->kt($this->json['tags']['deprecated'][0]['description'] ?? ''); - } - - public function description(): string - { - return $this->kt($this->json['description'] ?? ''); - } - - public function docBlock(): string - { - return $this->kt($this->json['docsBlocks'][0] ?? ''); - } - - public function events(): array - { - $events = A::map( - $this->json['events'] ?? [], - fn ($event) => [ - 'name' => $event['name'], - 'description' => $this->kt($event['description'] ?? ''), - 'deprecated' => $this->kt($event['tags']['deprecated'][0]['description'] ?? ''), - 'since' => $event['tags']['since'][0]['description'] ?? null, - 'properties' => A::map( - $event['properties'] ?? [], - fn ($property) => [ - 'name' => $property['name'], - 'type' => $property['type']['names'][0] ?? '', - 'description' => $this->kt($property['description'] ?? '', true), - ] - ), - ] - ); - - usort($events, fn ($a, $b) => $a['name'] <=> $b['name']); - - return $events; - } - - public function examples(): array - { - if (empty($this->json['tags']['examples']) === false) { - return $this->json['tags']['examples']; - } - - return []; - } - - public function file(string $context): string - { - $root = match ($context) { - 'dev' => $this->kirby->root('panel') . '/tmp', - 'dist' => $this->kirby->root('panel') . '/dist/ui', - }; - - $name = Str::after($this->name, 'k-'); - $name = Str::kebabToCamel($name); - return $root . '/' . $name . '.json'; - } - - public function github(): string - { - return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->json['sourceFile']; - } - - public static function installed(): bool + /** + * Whether the Lab docs are installed + */ + public static function isInstalled(): bool { return Dir::exists(static::root()) === true; } - protected function kt(string $text, bool $inline = false): string - { - return $this->kirby->kirbytext($text, [ - 'markdown' => [ - 'breaks' => false, - 'inline' => $inline, - ] - ]); - } - - public function lab(): string|null - { - $root = $this->kirby->root('panel') . '/lab'; - - foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) { - $props = require $example; - - if (($props['docs'] ?? null) === $this->name) { - return Str::before(Str::after($example, $root), 'index.php'); - } - } - - return null; - } - - public function methods(): array - { - $methods = A::map( - $this->json['methods'] ?? [], - fn ($method) => [ - 'name' => $method['name'], - 'description' => $this->kt($method['description'] ?? ''), - 'deprecated' => $this->kt($method['tags']['deprecated'][0]['description'] ?? ''), - 'since' => $method['tags']['since'][0]['description'] ?? null, - 'params' => A::map( - $method['params'] ?? [], - fn ($param) => [ - 'name' => $param['name'], - 'type' => $param['type']['name'] ?? '', - 'description' => $this->kt($param['description'] ?? '', true), - ] - ), - 'returns' => $method['returns']['type']['name'] ?? null, - ] - ); - - usort($methods, fn ($a, $b) => $a['name'] <=> $b['name']); - - return $methods; - } - - public function name(): string - { - return $this->name; - } - - public function prop(string|int $key): array|null - { - $prop = $this->json['props'][$key]; - - // filter private props - if (($prop['tags']['access'][0]['description'] ?? null) === 'private') { - return null; - } - - // filter unset props - if (($type = $prop['type']['name'] ?? null) === 'null') { - return null; - } - - $default = $prop['defaultValue']['value'] ?? null; - $deprecated = $this->kt($prop['tags']['deprecated'][0]['description'] ?? ''); - - return [ - 'name' => Str::camelToKebab($prop['name']), - 'type' => $type, - 'description' => $this->kt($prop['description'] ?? ''), - 'default' => $this->propDefault($default, $type), - 'deprecated' => $deprecated, - 'example' => $prop['tags']['example'][0]['description'] ?? null, - 'required' => $prop['required'] ?? false, - 'since' => $prop['tags']['since'][0]['description'] ?? null, - 'value' => $prop['tags']['value'][0]['description'] ?? null, - 'values' => $prop['values'] ?? null, - ]; - } - - protected function propDefault( - string|null $default, - string|null $type - ): string|null { - if ($default !== null) { - // normalize longform function - if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) { - return $matches[1]; - } - - // normalize object shorthand function - if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) { - return $matches[1]; - } - - // normalize all other defaults from shorthand function - if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) { - return $matches[1]; - } - - return $default; - } - - // if type is boolean primarily and no default - // value has been set, add `false` as default - // for clarity - if (Str::startsWith($type, 'boolean')) { - return 'false'; - } - - return null; - } - - public function props(): array - { - $props = A::map( - array_keys($this->json['props'] ?? []), - fn ($key) => $this->prop($key) - ); - - // remove empty props - $props = array_filter($props); - - usort($props, fn ($a, $b) => $a['name'] <=> $b['name']); - - // always return an array - return array_values($props); - } - - protected function read(): array - { - $file = $this->file('dev'); - - if (file_exists($file) === false) { - $file = $this->file('dist'); - } - - return Data::read($file); - } - + /** + * Returns the root path to directory where + * the JSON files for each component are stored by vite + */ public static function root(bool $tmp = false): string { return App::instance()->root('panel') . '/' . match ($tmp) { @@ -290,51 +70,4 @@ class Docs default => 'dist/ui', }; } - - public function since(): string|null - { - return $this->json['tags']['since'][0]['description'] ?? null; - } - - public function slots(): array - { - $slots = A::map( - $this->json['slots'] ?? [], - fn ($slot) => [ - 'name' => $slot['name'], - 'description' => $this->kt($slot['description'] ?? ''), - 'deprecated' => $this->kt($slot['tags']['deprecated'][0]['description'] ?? ''), - 'since' => $slot['tags']['since'][0]['description'] ?? null, - 'bindings' => A::map( - $slot['bindings'] ?? [], - fn ($binding) => [ - 'name' => $binding['name'], - 'type' => $binding['type']['name'] ?? '', - 'description' => $this->kt($binding['description'] ?? '', true), - ] - ), - ] - ); - - usort($slots, fn ($a, $b) => $a['name'] <=> $b['name']); - - return $slots; - } - - public function toArray(): array - { - return [ - 'component' => $this->name(), - 'deprecated' => $this->deprecated(), - 'description' => $this->description(), - 'docBlock' => $this->docBlock(), - 'events' => $this->events(), - 'examples' => $this->examples(), - 'github' => $this->github(), - 'methods' => $this->methods(), - 'props' => $this->props(), - 'since' => $this->since(), - 'slots' => $this->slots(), - ]; - } } diff --git a/public/kirby/src/Panel/Lab/Example.php b/public/kirby/src/Panel/Lab/Example.php index 153bb12..6e52aff 100644 --- a/public/kirby/src/Panel/Lab/Example.php +++ b/public/kirby/src/Panel/Lab/Example.php @@ -10,15 +10,14 @@ use Kirby\Http\Response; /** * One or multiple lab examples with one or multiple tabs * - * @internal - * @since 4.0.0 - * @codeCoverageIgnore - * * @package Kirby Panel * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore */ class Example { @@ -34,7 +33,9 @@ class Example $this->root = $this->parent->root() . '/' . $this->id; if ($this->exists() === false) { - throw new NotFoundException('The example could not be found'); + throw new NotFoundException( + message: 'The example could not be found' + ); } $this->tabs = $this->collectTabs(); @@ -43,7 +44,7 @@ class Example public function collectTab(string|null $tab): string|null { - if (empty($this->tabs) === true) { + if ($this->tabs === []) { return null; } @@ -71,7 +72,7 @@ class Example public function exists(): bool { - return is_dir($this->root) === true; + return Dir::exists($this->root, $this->parent->root()) === true; } public function file(string $filename): string @@ -171,7 +172,7 @@ class Example return [ 'image' => [ 'icon' => $this->parent->icon(), - 'back' => 'white', + 'back' => 'light-dark(white, var(--color-gray-800))', ], 'text' => $this->title(), 'link' => $this->url() diff --git a/public/kirby/src/Panel/Lab/Snippet.php b/public/kirby/src/Panel/Lab/Snippet.php index 0739eb7..527cc59 100644 --- a/public/kirby/src/Panel/Lab/Snippet.php +++ b/public/kirby/src/Panel/Lab/Snippet.php @@ -7,15 +7,14 @@ use Kirby\Template\Snippet as BaseSnippet; /** * Custom snippet class for lab examples * - * @internal - * @since 4.0.0 - * @codeCoverageIgnore - * * @package Kirby Panel * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore */ class Snippet extends BaseSnippet { diff --git a/public/kirby/src/Panel/Lab/Template.php b/public/kirby/src/Panel/Lab/Template.php index 71cf484..15c5612 100644 --- a/public/kirby/src/Panel/Lab/Template.php +++ b/public/kirby/src/Panel/Lab/Template.php @@ -7,15 +7,14 @@ use Kirby\Template\Template as BaseTemplate; /** * Custom template class for lab examples * - * @internal - * @since 4.0.0 - * @codeCoverageIgnore - * * @package Kirby Panel * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore */ class Template extends BaseTemplate { diff --git a/public/kirby/src/Panel/Menu.php b/public/kirby/src/Panel/Menu.php index c1c4498..a5e8cbd 100644 --- a/public/kirby/src/Panel/Menu.php +++ b/public/kirby/src/Panel/Menu.php @@ -9,13 +9,14 @@ use Kirby\Toolkit\I18n; /** * The Menu class takes care of gathering * all menu entries for the Panel - * @since 4.0.0 * * @package Kirby Panel * @author Nico Hoffmann * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 4.0.0 + * @unstable */ class Menu { @@ -28,7 +29,6 @@ class Menu /** * Returns all areas that are configured for the menu - * @internal */ public function areas(): array { @@ -46,7 +46,7 @@ class Menu $defaults = ['site', 'languages', 'users', 'system']; // add all other areas after that $additionals = array_diff(array_keys($this->areas), $defaults); - $areas = array_merge($defaults, $additionals); + $areas = [...$defaults, ...$additionals]; } $result = []; @@ -73,12 +73,11 @@ class Menu // merge area definition (e.g. from config) // with global area definition if (is_array($area) === true) { - $area = array_merge( - $this->areas[$id] ?? [], - ['menu' => true], - $area - ); - $area = Panel::area($id, $area); + $area = Panel::area($id, [ + ...$this->areas[$id] ?? [], + 'menu' => true, + ...$area + ]); } $result[] = $area; @@ -89,7 +88,6 @@ class Menu /** * Transforms an area definition into a menu entry - * @internal */ public function entry(array $area): array|false { @@ -119,7 +117,7 @@ class Menu default => $menu }; - $entry = array_merge([ + $entry = [ 'current' => $this->isCurrent( $area['id'], $area['current'] ?? null @@ -128,8 +126,11 @@ class Menu 'link' => $area['link'] ?? null, 'dialog' => $area['dialog'] ?? null, 'drawer' => $area['drawer'] ?? null, - 'text' => I18n::translate($area['label'], $area['label']) - ], $menu); + 'target' => $area['target'] ?? null, + 'text' => I18n::translate($area['label'], $area['label']), + 'title' => I18n::translate($area['title'] ?? null, $area['title'] ?? null), + ...$menu + ]; // unset the link (which is always added by default to an area) // if a dialog or drawer should be opened instead @@ -158,13 +159,12 @@ class Menu $entries[] = '-'; - return array_merge($entries, $this->options()); + return [...$entries, ...$this->options()]; } /** * Checks if the access permission to a specific area is granted. * Defaults to allow access. - * @internal */ public function hasPermission(string $id): bool { @@ -173,7 +173,6 @@ class Menu /** * Whether the menu entry should receive aria-current - * @internal */ public function isCurrent( string $id, @@ -192,7 +191,6 @@ class Menu /** * Default options entries for bottom of menu - * @internal */ public function options(): array { diff --git a/public/kirby/src/Panel/Model.php b/public/kirby/src/Panel/Model.php index b00873f..75a5fc0 100644 --- a/public/kirby/src/Panel/Model.php +++ b/public/kirby/src/Panel/Model.php @@ -4,9 +4,10 @@ namespace Kirby\Panel; use Closure; use Kirby\Cms\File as CmsFile; +use Kirby\Cms\Language; use Kirby\Cms\ModelWithContent; use Kirby\Filesystem\Asset; -use Kirby\Form\Form; +use Kirby\Form\Fields; use Kirby\Http\Uri; use Kirby\Toolkit\A; @@ -27,12 +28,19 @@ abstract class Model ) { } + /** + * Returns header button names which should be displayed + */ + abstract public function buttons(): array; + /** * Get the content values for the model + * + * @deprecated 5.0.0 Use `self::versions()` instead */ public function content(): array { - return Form::for($this->model)->values(); + return $this->versions()['changes']; } /** @@ -63,7 +71,7 @@ abstract class Model * * @param string|null $type (`auto`|`kirbytext`|`markdown`) */ - public function dragTextType(string|null $type = null): string + public function dragTextType(string|null $type = 'auto'): string { $type ??= 'auto'; @@ -92,7 +100,6 @@ abstract class Model /** * Returns the Panel image definition - * @internal */ public function image( string|array|false|null $settings = [], @@ -132,11 +139,11 @@ abstract class Model } // merge with defaults and blueprint option - $settings = array_merge( - $this->imageDefaults(), - $settings ?? [], - $blueprint ?? [], - ); + $settings = [ + ...$this->imageDefaults(), + ...$settings ?? [], + ...$blueprint ?? [], + ]; if ($image = $this->imageSource($settings['query'] ?? null)) { // main url @@ -178,7 +185,6 @@ abstract class Model /** * Data URI placeholder string for Panel image - * @internal */ public static function imagePlaceholder(): string { @@ -187,7 +193,6 @@ abstract class Model /** * Returns the image file object based on provided query - * @internal */ protected function imageSource( string|null $query = null @@ -208,7 +213,6 @@ abstract class Model /** * Provides the correct srcset string based on * the layout and settings - * @internal */ protected function imageSrcset( CmsFile|Asset $image, @@ -231,8 +235,12 @@ abstract class Model // for card layouts with `cover: true` provide // crops based on the card ratio if ($layout === 'cards') { - $ratio = explode('/', $settings['ratio'] ?? '1/1'); - $ratio = $ratio[0] / $ratio[1]; + $ratio = $settings['ratio'] ?? '1/1'; + + if (is_numeric($ratio) === false) { + $ratio = explode('/', $ratio); + $ratio = $ratio[0] / $ratio[1]; + } return $image->srcset([ $sizes[0] . 'w' => [ @@ -287,14 +295,12 @@ abstract class Model } /** - * Returns lock info for the Panel - * - * @return array|false array with lock info, - * false if locking is not supported + * Returns the corresponding model object + * @since 5.0.0 */ - public function lock(): array|false + public function model(): ModelWithContent { - return $this->model->lock()?->toArray() ?? false; + return $this->model; } /** @@ -308,9 +314,9 @@ abstract class Model { $options = $this->model->permissions()->toArray(); - if ($this->model->isLocked()) { + if ($this->model->lock()->isLocked() === true) { foreach ($options as $key => $value) { - if (in_array($key, $unlock)) { + if (in_array($key, $unlock, true)) { continue; } @@ -347,21 +353,30 @@ abstract class Model } /** - * Returns the data array for the - * view's component props - * @internal + * Returns the data array for the view's component props */ public function props(): array { $blueprint = $this->model->blueprint(); + $link = $this->url(true); $request = $this->model->kirby()->request(); $tabs = $blueprint->tabs(); $tab = $blueprint->tab($request->get('tab')) ?? $tabs[0] ?? null; + $versions = $this->versions(); $props = [ - 'lock' => $this->lock(), + 'api' => $link, + 'buttons' => fn () => $this->buttons(), + 'id' => $this->model->id(), + 'link' => $link, + 'lock' => $this->model->lock()->toArray(), 'permissions' => $this->model->permissions()->toArray(), 'tabs' => $tabs, + 'uuid' => fn () => $this->model->uuid()?->toString(), + 'versions' => [ + 'latest' => (object)$versions['latest'], + 'changes' => (object)$versions['changes'] + ] ]; // only send the tab if it exists @@ -377,7 +392,6 @@ abstract class Model /** * Returns link url and title * for model (e.g. used for prev/next navigation) - * @internal */ public function toLink(string $title = 'title'): array { @@ -391,8 +405,6 @@ abstract class Model * Returns link url and title * for optional sibling model and * preserves tab selection - * - * @internal */ protected function toPrevNextLink( ModelWithContent|null $model = null, @@ -418,8 +430,6 @@ abstract class Model /** * Returns the url to the editing view * in the Panel - * - * @internal */ public function url(bool $relative = false): string { @@ -431,10 +441,37 @@ abstract class Model } /** - * Returns the data array for - * this model's Panel view + * Creates an array with two versions of the content: + * `latest` and `changes`. * - * @internal + * The content is passed through the Fields class + * to ensure that the content is in the correct format + * for the Panel. If there's no `changes` version, the `latest` + * version is used for both. + */ + public function versions(): array + { + $language = Language::ensure('current'); + $fields = Fields::for($this->model, $language); + + $latestVersion = $this->model->version('latest'); + $changesVersion = $this->model->version('changes'); + + $latestContent = $latestVersion->content($language)->toArray(); + $changesContent = $latestContent; + + if ($changesVersion->exists($language) === true) { + $changesContent = $changesVersion->content($language)->toArray(); + } + + return [ + 'latest' => $fields->reset()->fill($latestContent)->toFormValues(), + 'changes' => $fields->reset()->fill($changesContent)->toFormValues() + ]; + } + + /** + * Returns the data array for this model's Panel view */ abstract public function view(): array; } diff --git a/public/kirby/src/Panel/Page.php b/public/kirby/src/Panel/Page.php index 40801c9..269d83f 100644 --- a/public/kirby/src/Panel/Page.php +++ b/public/kirby/src/Panel/Page.php @@ -5,6 +5,7 @@ namespace Kirby\Panel; use Kirby\Cms\File as CmsFile; use Kirby\Cms\ModelWithContent; use Kirby\Filesystem\Asset; +use Kirby\Panel\Ui\Buttons\ViewButtons; use Kirby\Toolkit\I18n; /** @@ -39,13 +40,27 @@ class Page extends Model ); } + /** + * Returns header buttons which should be displayed + * on the page view + */ + public function buttons(): array + { + return ViewButtons::view($this)->defaults( + 'open', + 'preview', + 'settings', + 'languages', + 'status' + )->render(); + } + /** * Provides a kirbytag or markdown * tag for the page, which will be * used in the panel, when the page * gets dragged onto a textarea * - * @internal * @param string|null $type (`auto`|`kirbytext`|`markdown`) */ public function dragText(string|null $type = null): string @@ -74,24 +89,31 @@ class Page extends Model */ public function dropdown(array $options = []): array { - $page = $this->model; - $request = $page->kirby()->request(); - $defaults = $request->get(['view', 'sort', 'delete']); - $options = array_merge($defaults, $options); - + $page = $this->model; + $request = $page->kirby()->request(); + $defaults = $request->get(['view', 'sort', 'delete']); + $options = [...$defaults, ...$options]; $permissions = $this->options(['preview']); $view = $options['view'] ?? 'view'; $url = $this->url(true); $result = []; if ($view === 'list') { - $result['preview'] = [ + $result['open'] = [ 'link' => $page->previewUrl(), 'target' => '_blank', 'icon' => 'open', 'text' => I18n::translate('open'), - 'disabled' => $this->isDisabledDropdownOption('preview', $options, $permissions) + 'disabled' => $isPreviewDisabled = $this->isDisabledDropdownOption('preview', $options, $permissions) ]; + + $result['preview'] = [ + 'icon' => 'window', + 'link' => $page->panel()->url(true) . '/preview/changes', + 'text' => I18n::translate('preview'), + 'disabled' => $isPreviewDisabled + ]; + $result[] = '-'; } @@ -202,13 +224,14 @@ class Page extends Model $defaults['icon'] = $icon; } - return array_merge(parent::imageDefaults(), $defaults); + return [ + ...parent::imageDefaults(), + ...$defaults + ]; } /** * Returns the image file object based on provided query - * - * @internal */ protected function imageSource( string|null $query = null @@ -219,8 +242,6 @@ class Page extends Model /** * Returns the full path without leading slash - * - * @internal */ public function path(): string { @@ -235,11 +256,12 @@ class Page extends Model { $params['text'] ??= '{{ page.title }}'; - return array_merge(parent::pickerData($params), [ + return [ + ...parent::pickerData($params), 'dragText' => $this->dragText(), 'hasChildren' => $this->model->hasChildren(), 'url' => $this->model->url() - ]); + ]; } /** @@ -257,8 +279,6 @@ class Page extends Model * Returns navigation array with * previous and next page * based on blueprint definition - * - * @internal */ public function prevNext(): array { @@ -266,7 +286,7 @@ class Page extends Model // create siblings collection based on // blueprint navigation - $siblings = function (string $direction) use ($page) { + $siblings = static function (string $direction) use ($page) { $navigation = $page->blueprint()->navigation(); $sortBy = $navigation['sortBy'] ?? null; $status = $navigation['status'] ?? null; @@ -293,12 +313,12 @@ class Page extends Model $templates = (array)($template ?? $page->intendedTemplate()); // do not filter if template navigation is all - if (in_array('all', $templates) === false) { + if (in_array('all', $templates, true) === false) { $siblings = $siblings->filter('intendedTemplate', 'in', $templates); } // do not filter if status navigation is all - if (in_array('all', $statuses) === false) { + if (in_array('all', $statuses, true) === false) { $siblings = $siblings->filter('status', 'in', $statuses); } } else { @@ -317,54 +337,43 @@ class Page extends Model } /** - * Returns the data array for the - * view's component props - * - * @internal + * Returns the data array for the view's component props */ public function props(): array { - $page = $this->model; + $props = parent::props(); - return array_merge( - parent::props(), - $this->prevNext(), - [ - 'blueprint' => $page->intendedTemplate()->name(), - 'model' => [ - 'content' => $this->content(), - 'id' => $page->id(), - 'link' => $this->url(true), - 'parent' => $page->parentModel()->panel()->url(true), - 'previewUrl' => $page->previewUrl(), - 'status' => $page->status(), - 'title' => $page->title()->toString(), - 'uuid' => fn () => $page->uuid()?->toString(), - ], - 'status' => function () use ($page) { - if ($status = $page->status()) { - return $page->blueprint()->status()[$status] ?? null; - } - }, - ] - ); + // Additional model information + // @deprecated Use the top-level props instead + $model = [ + 'id' => $props['id'], + 'link' => $props['link'], + 'parent' => $this->model->parentModel()->panel()->url(true), + 'previewUrl' => $this->model->previewUrl(), + 'status' => $this->model->status(), + 'title' => $this->model->title()->toString(), + 'uuid' => $props['uuid'], + ]; + + return [ + ...$props, + ...$this->prevNext(), + 'blueprint' => $this->model->intendedTemplate()->name(), + 'model' => $model, + 'title' => $model['title'], + ]; } /** - * Returns the data array for - * this model's Panel view - * - * @internal + * Returns the data array for this model's Panel view */ public function view(): array { - $page = $this->model; - return [ - 'breadcrumb' => $page->panel()->breadcrumb(), + 'breadcrumb' => $this->model->panel()->breadcrumb(), 'component' => 'k-page-view', - 'props' => $this->props(), - 'title' => $page->title()->toString(), + 'props' => $props = $this->props(), + 'title' => $props['title'], ]; } } diff --git a/public/kirby/src/Panel/PageCreateDialog.php b/public/kirby/src/Panel/PageCreateDialog.php index a2aa712..772f51c 100644 --- a/public/kirby/src/Panel/PageCreateDialog.php +++ b/public/kirby/src/Panel/PageCreateDialog.php @@ -114,7 +114,9 @@ class PageCreateDialog $slug = $this->blueprint()->create()['slug'] ?? null; if ($title === false || $slug === false) { - throw new InvalidArgumentException('Page create dialog: title and slug must not be false'); + throw new InvalidArgumentException( + message: 'Page create dialog: title and slug must not be false' + ); } // title field @@ -158,15 +160,21 @@ class PageCreateDialog foreach ($blueprint->create()['fields'] ?? [] as $name) { if (!$field = ($fields[$name] ?? null)) { - throw new InvalidArgumentException('Unknown field "' . $name . '" in create dialog'); + throw new InvalidArgumentException( + message: 'Unknown field "' . $name . '" in create dialog' + ); } - if (in_array($field['type'], static::$fieldTypes) === false) { - throw new InvalidArgumentException('Field type "' . $field['type'] . '" not supported in create dialog'); + if (in_array($field['type'], static::$fieldTypes, true) === false) { + throw new InvalidArgumentException( + message: 'Field type "' . $field['type'] . '" not supported in create dialog' + ); } - if (in_array($name, $ignore) === true) { - throw new InvalidArgumentException('Field name "' . $name . '" not allowed as custom field in create dialog'); + if (in_array($name, $ignore, true) === true) { + throw new InvalidArgumentException( + message: 'Field name "' . $name . '" not allowed as custom field in create dialog' + ); } // switch all fields to 1/1 @@ -178,13 +186,12 @@ class PageCreateDialog // create form so that field props, options etc. // can be properly resolved - $form = new Form([ - 'fields' => $custom, - 'model' => $this->model(), - 'strict' => true - ]); + $form = new Form( + fields: $custom, + model: $this->model() + ); - return $form->fields()->toArray(); + return $form->fields()->toProps(); } /** @@ -220,7 +227,7 @@ class PageCreateDialog ); // immediately submit the dialog if there is no editable field - if (count($visible) === 0 && count($blueprints) < 2) { + if ($visible === [] && count($blueprints) < 2) { $input = $this->value(); $response = $this->submit($input); $response['redirect'] ??= $this->parent->panel()->url(true); @@ -248,7 +255,6 @@ class PageCreateDialog */ public function model(): Page { - // TODO: use actual in-memory page in v5 return $this->model ??= Page::factory([ 'slug' => '__new__', 'template' => $this->template, @@ -298,7 +304,7 @@ class PageCreateDialog // create temporary form to sanitize the input // and add default values - $form = Form::for($this->model(), ['values' => $content]); + $form = Form::for($this->model())->fill(input: $content); return [ 'content' => $form->strings(true), @@ -351,12 +357,12 @@ class PageCreateDialog // ensure that all field validations are met if ($status !== 'draft') { // create temporary form to validate the input - $form = Form::for($this->model(), ['values' => $input['content']]); + $form = Form::for($this->model())->fill(input: $input['content']); if ($form->isInvalid() === true) { - throw new InvalidArgumentException([ - 'key' => 'page.changeStatus.incomplete' - ]); + throw new InvalidArgumentException( + key: 'page.changeStatus.incomplete' + ); } } diff --git a/public/kirby/src/Panel/Panel.php b/public/kirby/src/Panel/Panel.php index b88bcab..0babb24 100644 --- a/public/kirby/src/Panel/Panel.php +++ b/public/kirby/src/Panel/Panel.php @@ -3,6 +3,7 @@ namespace Kirby\Panel; use Closure; +use Kirby\Api\Upload; use Kirby\Cms\App; use Kirby\Cms\Url as CmsUrl; use Kirby\Cms\User; @@ -13,6 +14,7 @@ use Kirby\Http\Response; use Kirby\Http\Router; use Kirby\Http\Uri; use Kirby\Http\Url; +use Kirby\Toolkit\A; use Kirby\Toolkit\Str; use Kirby\Toolkit\Tpl; use Throwable; @@ -99,6 +101,20 @@ class Panel return $result; } + /** + * Collect all registered buttons from areas + * @since 5.0.0 + */ + public static function buttons(): array + { + return array_merge(...array_values( + A::map( + Panel::areas(), + fn ($area) => $area['buttons'] ?? [] + ) + )); + } + /** * Check for access permissions */ @@ -108,7 +124,9 @@ class Panel ): bool { // a user has to be logged in if ($user === null) { - throw new PermissionException(['key' => 'access.panel']); + throw new PermissionException( + key: 'access.panel' + ); } // get all access permissions for the user role @@ -116,7 +134,9 @@ class Panel // check for general panel access if (($permissions['panel'] ?? true) !== true) { - throw new PermissionException(['key' => 'access.panel']); + throw new PermissionException( + key: 'access.panel' + ); } // don't check if the area is not defined @@ -131,12 +151,29 @@ class Panel // no access if ($permissions[$areaId] !== true) { - throw new PermissionException(['key' => 'access.view']); + throw new PermissionException( + key: 'access.view' + ); } return true; } + /** + * Garbage collection which runs with a probability + * of 10% on each Panel request + * + * @since 5.0.0 + * @codeCoverageIgnore + */ + protected static function garbage(): void + { + // run garbage collection with a chance of 10%; + if (mt_rand(1, 10000) <= 0.1 * 10000) { + // clean up leftover upload chunks + Upload::cleanTmpDir(); + } + } /** * Redirect to a Panel url @@ -144,9 +181,9 @@ class Panel * @throws \Kirby\Panel\Redirect * @codeCoverageIgnore */ - public static function go(string|null $url = null, int $code = 302): void + public static function go(string|null $url = null, int $code = 302, int|false $refresh = false): void { - throw new Redirect(static::url($url), $code); + throw new Redirect(static::url($url), $code, $refresh); } /** @@ -233,9 +270,11 @@ class Panel // interpret missing/empty results as not found if ($result === null || $result === false) { - $result = new NotFoundException('The data could not be found'); + $result = new NotFoundException( + message: 'The data could not be found' + ); - // interpret strings as errors + // interpret strings as errors } elseif (is_string($result) === true) { $result = new Exception($result); } @@ -262,6 +301,9 @@ class Panel return null; } + // run garbage collection + static::garbage(); + // set the translation for Panel UI before // gathering areas and routes, so that the // `t()` helper can already be used @@ -286,8 +328,7 @@ class Panel // trigger hook $route = $kirby->apply( 'panel.route:before', - compact('route', 'path', 'method'), - 'route' + compact('route', 'path', 'method') ); // check for access before executing area routes @@ -337,15 +378,15 @@ class Panel // register all routes from areas foreach ($areas as $areaId => $area) { - $routes = array_merge( - $routes, - static::routesForViews($areaId, $area), - static::routesForSearches($areaId, $area), - static::routesForDialogs($areaId, $area), - static::routesForDrawers($areaId, $area), - static::routesForDropdowns($areaId, $area), - static::routesForRequests($areaId, $area), - ); + $routes = [ + ...$routes, + ...static::routesForViews($areaId, $area), + ...static::routesForSearches($areaId, $area), + ...static::routesForDialogs($areaId, $area), + ...static::routesForDrawers($areaId, $area), + ...static::routesForDropdowns($areaId, $area), + ...static::routesForRequests($areaId, $area), + ]; } // if the Panel is already installed and/or the @@ -380,12 +421,15 @@ class Panel $routes = []; foreach ($dialogs as $dialogId => $dialog) { - $routes = array_merge($routes, Dialog::routes( - id: $dialogId, - areaId: $areaId, - prefix: 'dialogs', - options: $dialog - )); + $routes = [ + ...$routes, + ...Dialog::routes( + id: $dialogId, + areaId: $areaId, + prefix: 'dialogs', + options: $dialog + ) + ]; } return $routes; @@ -400,12 +444,15 @@ class Panel $routes = []; foreach ($drawers as $drawerId => $drawer) { - $routes = array_merge($routes, Drawer::routes( - id: $drawerId, - areaId: $areaId, - prefix: 'drawers', - options: $drawer - )); + $routes = [ + ...$routes, + ...Drawer::routes( + id: $drawerId, + areaId: $areaId, + prefix: 'drawers', + options: $drawer + ) + ]; } return $routes; @@ -420,12 +467,15 @@ class Panel $routes = []; foreach ($dropdowns as $dropdownId => $dropdown) { - $routes = array_merge($routes, Dropdown::routes( - id: $dropdownId, - areaId: $areaId, - prefix: 'dropdowns', - options: $dropdown - )); + $routes = [ + ...$routes, + ...Dropdown::routes( + id: $dropdownId, + areaId: $areaId, + prefix: 'dropdowns', + options: $dropdown + ) + ]; } return $routes; @@ -569,7 +619,7 @@ class Panel if (Url::isAbsolute($url) === false) { $kirby = App::instance(); $slug = $kirby->option('panel.slug', 'panel'); - $path = trim($url, '/'); + $path = trim($url ?? '', '/'); $baseUri = new Uri($kirby->url()); $basePath = trim($baseUri->path()->toString(), '/'); diff --git a/public/kirby/src/Panel/Plugins.php b/public/kirby/src/Panel/Plugins.php index f264f73..03af2e0 100644 --- a/public/kirby/src/Panel/Plugins.php +++ b/public/kirby/src/Panel/Plugins.php @@ -116,7 +116,7 @@ class Plugins // of loading an empty array; this is because the module loader code uses // top level await, which is not compatible with Kirby's minimum browser // version requirements and therefore must not appear in a default setup - if (empty($dist)) { + if ($dist === []) { return ''; } diff --git a/public/kirby/src/Panel/Redirect.php b/public/kirby/src/Panel/Redirect.php index d00cd6f..98e43b3 100644 --- a/public/kirby/src/Panel/Redirect.php +++ b/public/kirby/src/Panel/Redirect.php @@ -3,6 +3,7 @@ namespace Kirby\Panel; use Exception; +use Throwable; /** * The Redirect exception can be thrown in all Fiber @@ -18,6 +19,15 @@ use Exception; */ class Redirect extends Exception { + public function __construct( + string $location, + int $code = 302, + protected int|false $refresh = false, + Throwable|null $previous = null + ) { + parent::__construct($location, $code, $previous); + } + /** * Returns the HTTP code for the redirect */ @@ -25,7 +35,7 @@ class Redirect extends Exception { $codes = [301, 302, 303, 307, 308]; - if (in_array($this->getCode(), $codes) === true) { + if (in_array($this->getCode(), $codes, true) === true) { return $this->getCode(); } @@ -39,4 +49,12 @@ class Redirect extends Exception { return $this->getMessage(); } + + /** + * Returns the refresh time in seconds + */ + public function refresh(): int|false + { + return $this->refresh; + } } diff --git a/public/kirby/src/Panel/Site.php b/public/kirby/src/Panel/Site.php index 7286098..e39ac58 100644 --- a/public/kirby/src/Panel/Site.php +++ b/public/kirby/src/Panel/Site.php @@ -5,6 +5,7 @@ namespace Kirby\Panel; use Kirby\Cms\File as CmsFile; use Kirby\Cms\ModelWithContent; use Kirby\Filesystem\Asset; +use Kirby\Panel\Ui\Buttons\ViewButtons; /** * Provides information about the site model for the Panel @@ -23,6 +24,19 @@ class Site extends Model */ protected ModelWithContent $model; + /** + * Returns header buttons which should be displayed + * on the site view + */ + public function buttons(): array + { + return ViewButtons::view($this)->defaults( + 'open', + 'preview', + 'languages' + )->render(); + } + /** * Returns the setup for a dropdown option * which is used in the changes dropdown @@ -38,8 +52,6 @@ class Site extends Model /** * Returns the image file object based on provided query - * - * @internal */ protected function imageSource( string|null $query = null @@ -57,30 +69,36 @@ class Site extends Model } /** - * Returns the data array for the - * view's component props - * - * @internal + * Returns the data array for the view's component props */ public function props(): array { - return array_merge(parent::props(), [ - 'blueprint' => 'site', - 'model' => [ - 'content' => $this->content(), - 'link' => $this->url(true), - 'previewUrl' => $this->model->previewUrl(), - 'title' => $this->model->title()->toString(), - 'uuid' => fn () => $this->model->uuid()?->toString(), - ] - ]); + $props = parent::props(); + + // Additional model information + // @deprecated Use the top-level props instead + $model = [ + 'link' => $props['link'], + 'previewUrl' => $this->model->previewUrl(), + 'title' => $this->model->title()->toString(), + 'uuid' => $props['uuid'], + ]; + + return [ + ...$props, + 'blueprint' => 'site', + 'id' => '/', + 'model' => $model, + 'title' => $model['title'], + 'permissions' => [ + ...$props['permissions'], + 'preview' => $this->model->homePage()?->permissions()->can('preview') === true, + ], + ]; } /** - * Returns the data array for - * this model's Panel view - * - * @internal + * Returns the data array for this model's Panel view */ public function view(): array { diff --git a/public/kirby/src/Panel/Ui/Button.php b/public/kirby/src/Panel/Ui/Button.php new file mode 100644 index 0000000..3947049 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Button.php @@ -0,0 +1,64 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +class Button extends Component +{ + public function __construct( + public string $component = 'k-button', + public array|null $badge = null, + public string|null $class = null, + public string|bool|null $current = null, + public string|null $dialog = null, + public bool $disabled = false, + public string|null $drawer = null, + public bool|null $dropdown = null, + public string|null $icon = null, + public string|null $link = null, + public bool|string $responsive = true, + public string|null $size = null, + public string|null $style = null, + public string|null $target = null, + public string|array|null $text = null, + public string|null $theme = null, + public string|array|null $title = null, + public string $type = 'button', + public string|null $variant = null, + ...$attrs + ) { + $this->attrs = $attrs; + } + + public function props(): array + { + return [ + ...parent::props(), + 'badge' => $this->badge, + 'current' => $this->current, + 'dialog' => $this->dialog, + 'disabled' => $this->disabled, + 'drawer' => $this->drawer, + 'dropdown' => $this->dropdown, + 'icon' => $this->icon, + 'link' => $this->link, + 'responsive' => $this->responsive, + 'size' => $this->size, + 'target' => $this->target, + 'text' => I18n::translate($this->text, $this->text), + 'theme' => $this->theme, + 'title' => I18n::translate($this->title, $this->title), + 'type' => $this->type, + 'variant' => $this->variant, + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php b/public/kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php new file mode 100644 index 0000000..d9b340e --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php @@ -0,0 +1,33 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class LanguageCreateButton extends ViewButton +{ + public function __construct() + { + $user = App::instance()->user(); + $permission = $user?->role()->permissions()->for('languages', 'create'); + + parent::__construct( + dialog: 'languages/create', + disabled: $permission !== true, + icon: 'add', + text: I18n::translate('language.create'), + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php b/public/kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php new file mode 100644 index 0000000..a2cddc3 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class LanguageDeleteButton extends ViewButton +{ + public function __construct(Language $language) + { + $user = App::instance()->user(); + $permission = $user?->role()->permissions()->for('languages', 'delete'); + + parent::__construct( + dialog: 'languages/' . $language->id() . '/delete', + disabled: $permission !== true, + icon: 'trash', + title: I18n::translate('delete'), + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php b/public/kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php new file mode 100644 index 0000000..a9f8a94 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class LanguageSettingsButton extends ViewButton +{ + public function __construct(Language $language) + { + $user = App::instance()->user(); + $permission = $user?->role()->permissions()->for('languages', 'update'); + + parent::__construct( + dialog: 'languages/' . $language->id() . '/update', + disabled: $permission !== true, + icon: 'cog', + title: I18n::translate('settings'), + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php b/public/kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php new file mode 100644 index 0000000..8ea413d --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php @@ -0,0 +1,120 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class LanguagesDropdown extends ViewButton +{ + protected App $kirby; + + public function __construct( + ModelWithContent $model + ) { + $this->kirby = $model->kirby(); + + parent::__construct( + component: 'k-languages-dropdown', + model: $model, + class: 'k-languages-dropdown', + icon: 'translate', + // Fiber dropdown endpoint to load options + // only when dropdown is opened + options: $model->panel()->url(true) . '/languages', + responsive: 'text', + text: Str::upper($this->kirby->language()?->code()) + ); + } + + /** + * Returns if any translation other than the current one has unsaved changes + * (the current language has to be handled in `k-languages-dropdown` as its + * state can change dynamically without another backend request) + */ + public function hasDiff(): bool + { + foreach (Languages::ensure() as $language) { + if ($this->kirby->language()?->code() !== $language->code()) { + if ($this->model->version('changes')->exists($language) === true) { + return true; + } + } + } + + return false; + } + + public function option(Language $language): array + { + $changes = $this->model->version('changes'); + + return [ + 'text' => $language->name(), + 'code' => $language->code(), + 'current' => $language->code() === $this->kirby->language()?->code(), + 'default' => $language->isDefault(), + 'changes' => $changes->exists($language), + 'lock' => $changes->isLocked('*') + ]; + } + + /** + * Options are used in the Fiber dropdown routes + */ + public function options(): array + { + $languages = $this->kirby->languages(); + $options = []; + + if ($this->kirby->multilang() === false) { + return $options; + } + + // add the primary/default language first + if ($default = $languages->default()) { + $options[] = $this->option($default); + $options[] = '-'; + $languages = $languages->not($default); + } + + // add all secondary languages after the separator + foreach ($languages as $language) { + $options[] = $this->option($language); + } + + return $options; + } + + public function props(): array + { + return [ + ...parent::props(), + 'hasDiff' => $this->hasDiff() + ]; + } + + public function render(): array|null + { + // hides the language selector when there are less than 2 languages + if ($this->kirby->languages()->count() < 2) { + return null; + } + + return parent::render(); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/OpenButton.php b/public/kirby/src/Panel/Ui/Buttons/OpenButton.php new file mode 100644 index 0000000..1625c08 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/OpenButton.php @@ -0,0 +1,32 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class OpenButton extends ViewButton +{ + public function __construct( + public string|null $link, + public string|null $target = '_blank' + ) { + parent::__construct( + class: 'k-open-view-button', + icon: 'open', + link: $link, + target: $target, + title: I18n::translate('open') + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/PageStatusButton.php b/public/kirby/src/Panel/Ui/Buttons/PageStatusButton.php new file mode 100644 index 0000000..37f9de4 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/PageStatusButton.php @@ -0,0 +1,50 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class PageStatusButton extends ViewButton +{ + public function __construct( + Page $page + ) { + $status = $page->status(); + $blueprint = $page->blueprint()->status()[$status] ?? null; + $disabled = $page->permissions()->cannot('changeStatus'); + $text = $blueprint['label'] ?? I18n::translate('page.status.' . $status); + $title = I18n::translate('page.status') . ': ' . $text; + + if ($disabled === true) { + $title .= ' (' . I18n::translate('disabled') . ')'; + } + + parent::__construct( + class: 'k-status-view-button k-page-status-button', + component: 'k-status-view-button', + dialog: $page->panel()->url(true) . '/changeStatus', + disabled: $disabled, + icon: 'status-' . $status, + style: '--icon-size: 15px', + text: $text, + title: $title, + theme: match($status) { + 'draft' => 'negative-icon', + 'unlisted' => 'info-icon', + 'listed' => 'positive-icon' + } + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/PreviewButton.php b/public/kirby/src/Panel/Ui/Buttons/PreviewButton.php new file mode 100644 index 0000000..35780d2 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/PreviewButton.php @@ -0,0 +1,30 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class PreviewButton extends ViewButton +{ + public function __construct( + public string|null $link + ) { + parent::__construct( + class: 'k-preview-view-button', + icon: 'window', + link: $link, + title: I18n::translate('preview') + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/SettingsButton.php b/public/kirby/src/Panel/Ui/Buttons/SettingsButton.php new file mode 100644 index 0000000..3c0539c --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/SettingsButton.php @@ -0,0 +1,32 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class SettingsButton extends ViewButton +{ + public function __construct( + ModelWithContent $model + ) { + parent::__construct( + component: 'k-settings-view-button', + class: 'k-settings-view-button', + icon: 'cog', + options: $model->panel()->url(true), + title: I18n::translate('settings'), + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/VersionsButton.php b/public/kirby/src/Panel/Ui/Buttons/VersionsButton.php new file mode 100644 index 0000000..8272a7e --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/VersionsButton.php @@ -0,0 +1,57 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class VersionsButton extends ViewButton +{ + public function __construct( + ModelWithContent $model, + VersionId|string $versionId = 'latest' + ) { + $versionId = $versionId === 'compare' ? 'compare' : VersionId::from($versionId)->value(); + $viewUrl = $model->panel()->url(true) . '/preview'; + + parent::__construct( + class: 'k-versions-view-button', + icon: $versionId === 'compare' ? 'layout-columns' : 'git-branch', + options: [ + [ + 'label' => I18n::translate('version.latest'), + 'icon' => 'git-branch', + 'link' => $viewUrl . '/latest', + 'current' => $versionId === 'latest' + ], + [ + 'label' => I18n::translate('version.changes'), + 'icon' => 'git-branch', + 'link' => $viewUrl . '/changes', + 'current' => $versionId === 'changes' + ], + '-', + [ + 'label' => I18n::translate('version.compare'), + 'icon' => 'layout-columns', + 'link' => $viewUrl . '/compare', + 'current' => $versionId === 'compare' + ], + + ], + text: I18n::translate('version.' . $versionId), + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/ViewButton.php b/public/kirby/src/Panel/Ui/Buttons/ViewButton.php new file mode 100644 index 0000000..254d57f --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/ViewButton.php @@ -0,0 +1,215 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +class ViewButton extends Button +{ + public function __construct( + public string $component = 'k-view-button', + public readonly ModelWithContent|Language|null $model = null, + public array|null $badge = null, + public string|null $class = null, + public string|bool|null $current = null, + public string|null $dialog = null, + public bool $disabled = false, + public string|null $drawer = null, + public bool|null $dropdown = null, + public string|null $icon = null, + public string|null $link = null, + public array|string|null $options = null, + public bool|string $responsive = true, + public string|null $size = 'sm', + public string|null $style = null, + public string|null $target = null, + public string|array|null $text = null, + public string|null $theme = null, + public string|array|null $title = null, + public string $type = 'button', + public string|null $variant = 'filled', + ...$attrs + ) { + $this->attrs = $attrs; + } + + /** + * Creates new view button by looking up + * the button in all areas, if referenced by name + * and resolving to proper instance + */ + public static function factory( + string|array|Closure|bool $button = true, + string|int|null $name = null, + string|null $view = null, + ModelWithContent|Language|null $model = null, + array $data = [] + ): static|null { + // if referenced by name (`name: false`), + // don't render anything + if ($button === false) { + return null; + } + + // transform `- name` notation to `name: true` + if ( + is_string($name) === false && + is_string($button) === true + ) { + $name = $button; + $button = true; + } + + // if referenced by name (`name: true`), + // try to get button definition from areas or config + if ($button === true) { + $button = static::find($name, $view); + } + + // resolve Closure to button object or array + if ($button instanceof Closure) { + $button = static::resolve($button, $model, $data); + } + + if ( + $button === null || + $button instanceof ViewButton + ) { + return $button; + } + + // flatten array into list of arguments for this class + $button = static::normalize($button); + + // if button definition has a name, use it for the component name + if (is_string($name) === true) { + // if this specific component does not exist, + // `k-view-buttons` will fall back to `k-view-button` again + $button['component'] ??= 'k-' . $name . '-view-button'; + } + + return new static(...$button, model: $model); + } + + /** + * Finds a view button by name + * among the defined buttons from all areas + * @unstable + */ + public static function find( + string $name, + string|null $view = null + ): array|Closure { + // collect all buttons from areas and config + $buttons = [ + ...Panel::buttons(), + ...App::instance()->option('panel.viewButtons.' . $view, []) + ]; + + // try to find by full name (view-prefixed) + if ($view && $button = $buttons[$view . '.' . $name] ?? null) { + return $button; + } + + // try to find by just name + if ($button = $buttons[$name] ?? null) { + return $button; + } + + // assume it must be a custom view button component + return ['component' => 'k-' . $name . '-view-button']; + } + + /** + * Transforms an array to be used as + * named arguments in the constructor + * @unstable + */ + public static function normalize(array $button): array + { + // if component and props are both not set, assume shortcut + // where props were directly passed on top-level + if ( + isset($button['component']) === false && + isset($button['props']) === false + ) { + return $button; + } + + // flatten array + if ($props = $button['props'] ?? null) { + $button = [...$props, ...$button]; + unset($button['props']); + } + + return $button; + } + + public function props(): array + { + // helper for props that support Kirby queries + $resolve = fn ($value) => + $value ? + $this->model?->toSafeString($value) ?? $value : + null; + + return [ + ...$props = parent::props(), + 'dialog' => $resolve($props['dialog']), + 'drawer' => $resolve($props['drawer']), + 'icon' => $resolve($props['icon']), + 'link' => $resolve($props['link']), + 'text' => $resolve($props['text']), + 'theme' => $resolve($props['theme']), + 'options' => $this->options + ]; + } + + /** + * Transforms a closure to the actual view button + * by calling it with the provided arguments + */ + public static function resolve( + Closure $button, + ModelWithContent|Language|null $model = null, + array $data = [] + ): static|array|null { + $kirby = App::instance(); + $controller = new Controller($button); + + if ( + $model instanceof ModelWithContent || + $model instanceof Language + ) { + $data = [ + 'model' => $model, + $model::CLASS_ALIAS => $model, + ...$data + ]; + } + + return $controller->call(data: [ + 'kirby' => $kirby, + 'site' => $kirby->site(), + 'user' => $kirby->user(), + ...$data + ]); + } +} diff --git a/public/kirby/src/Panel/Ui/Buttons/ViewButtons.php b/public/kirby/src/Panel/Ui/Buttons/ViewButtons.php new file mode 100644 index 0000000..7cf1f03 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Buttons/ViewButtons.php @@ -0,0 +1,104 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class ViewButtons +{ + public function __construct( + public readonly string $view, + public readonly ModelWithContent|Language|null $model = null, + public array|false|null $buttons = null, + public array $data = [] + ) { + // if no specific buttons are passed, + // use default buttons for this view from config + $this->buttons ??= App::instance()->option( + 'panel.viewButtons.' . $view + ); + } + + /** + * Adds data passed to view button closures + * + * @return $this + */ + public function bind(array $data): static + { + $this->data = [...$this->data, ...$data]; + return $this; + } + + + /** + * Sets the default buttons + * + * @return $this + */ + public function defaults(string ...$defaults): static + { + $this->buttons ??= $defaults; + return $this; + } + + /** + * Returns array of button component-props definitions + */ + public function render(): array + { + // hides all buttons when `buttons: false` set + if ($this->buttons === false) { + return []; + } + + $buttons = []; + + foreach ($this->buttons ?? [] as $name => $button) { + $buttons[] = ViewButton::factory( + button: $button, + name: $name, + view: $this->view, + model: $this->model, + data: $this->data + )?->render(); + } + + return array_values(array_filter($buttons)); + } + + /** + * Creates new instance for a view + * with special support for model views + */ + public static function view( + string|Model $view, + ModelWithContent|Language|null $model = null + ): static { + if ($view instanceof Model) { + $model = $view->model(); + $blueprint = $model->blueprint()->buttons(); + $view = $model::CLASS_ALIAS; + } + + return new static( + view: $view, + model: $model ?? null, + buttons: $blueprint ?? null + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Component.php b/public/kirby/src/Panel/Ui/Component.php new file mode 100644 index 0000000..d271bfd --- /dev/null +++ b/public/kirby/src/Panel/Ui/Component.php @@ -0,0 +1,90 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +abstract class Component +{ + protected string $key; + public array $attrs = []; + + public function __construct( + public string $component, + public string|null $class = null, + public string|null $style = null, + ...$attrs + ) { + $this->attrs = $attrs; + } + + /** + * Magic setter and getter for component properties + * + * ```php + * $component->class('my-class') + * ``` + */ + public function __call(string $name, array $args = []) + { + if (property_exists($this, $name) === false) { + throw new LogicException( + message: 'The property "' . $name . '" does not exist on the UI component "' . $this->component . '"' + ); + } + + // getter + if ($args === []) { + return $this->$name; + } + + // setter + $this->$name = $args[0]; + return $this; + } + + /** + * Returns a (unique) key that can be used + * for Vue's `:key` attribute + */ + public function key(): string + { + return $this->key ??= Str::random(10, 'alphaNum'); + } + + /** + * Returns the props that will be passed to the Vue component + */ + public function props(): array + { + return [ + 'class' => $this->class, + 'style' => $this->style, + ...$this->attrs + ]; + } + + /** + * Returns array with the Vue component name and props array + */ + public function render(): array|null + { + return [ + 'component' => $this->component, + 'key' => $this->key(), + 'props' => array_filter($this->props()) + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/FilePreview.php b/public/kirby/src/Panel/Ui/FilePreview.php new file mode 100644 index 0000000..bdc058e --- /dev/null +++ b/public/kirby/src/Panel/Ui/FilePreview.php @@ -0,0 +1,105 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +abstract class FilePreview extends Component +{ + public function __construct( + public File $file, + public string $component + ) { + } + + /** + * Returns true if this class should + * handle the preview of this file + */ + abstract public static function accepts(File $file): bool; + + /** + * Returns detail information about the file + */ + public function details(): array + { + return [ + [ + 'title' => I18n::translate('template'), + 'text' => $this->file->template() ?? '—' + ], + [ + 'title' => I18n::translate('mime'), + 'text' => $this->file->mime() + ], + [ + 'title' => I18n::translate('url'), + 'link' => $link = $this->file->previewUrl(), + 'text' => $link, + ], + [ + 'title' => I18n::translate('size'), + 'text' => $this->file->niceSize() + ], + ]; + } + + /** + * Returns a file preview instance by going through all + * available handler classes and finding the first that + * accepts the file + */ + final public static function factory(File $file): static + { + // get file preview classes providers from plugins + $handlers = App::instance()->extensions('filePreviews'); + + foreach ($handlers as $handler) { + if (is_subclass_of($handler, self::class) === false) { + throw new InvalidArgumentException( + message: 'File preview handler "' . $handler . '" must extend ' . self::class + ); + } + + if ($handler::accepts($file) === true) { + return new $handler($file); + } + } + + return new DefaultFilePreview($file); + } + + /** + * Icon or image to display as thumbnail + */ + public function image(): array|null + { + return $this->file->panel()->image([ + 'back' => 'transparent', + 'ratio' => '1/1' + ], 'cards'); + } + + public function props(): array + { + return [ + 'details' => $this->details(), + 'image' => $this->image(), + 'url' => $this->file->previewUrl() + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php b/public/kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php new file mode 100644 index 0000000..0b32467 --- /dev/null +++ b/public/kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php @@ -0,0 +1,29 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class AudioFilePreview extends FilePreview +{ + public function __construct( + public File $file, + public string $component = 'k-audio-file-preview' + ) { + } + + public static function accepts(File $file): bool + { + return $file->type() === 'audio'; + } +} diff --git a/public/kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php b/public/kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php new file mode 100644 index 0000000..732d67b --- /dev/null +++ b/public/kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php @@ -0,0 +1,42 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class DefaultFilePreview extends FilePreview +{ + public function __construct( + public File $file, + public string $component = 'k-default-file-preview' + ) { + } + + /** + * Accepts any file as last resort + */ + public static function accepts(File $file): bool + { + return true; + } + + public function props(): array + { + return [ + ...parent::props(), + 'image' => $this->image() + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php b/public/kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php new file mode 100644 index 0000000..a69bfb0 --- /dev/null +++ b/public/kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php @@ -0,0 +1,53 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class ImageFilePreview extends FilePreview +{ + public function __construct( + public File $file, + public string $component = 'k-image-file-preview' + ) { + } + + public static function accepts(File $file): bool + { + return $file->type() === 'image'; + } + + public function details(): array + { + return [ + ...parent::details(), + [ + 'title' => I18n::translate('dimensions'), + 'text' => $this->file->dimensions() . ' ' . I18n::translate('pixel') + ], + [ + 'title' => I18n::translate('orientation'), + 'text' => I18n::translate('orientation.' . $this->file->dimensions()->orientation()) + ] + ]; + } + + public function props(): array + { + return [ + ...parent::props(), + 'focusable' => $this->file->panel()->isFocusable() + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php b/public/kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php new file mode 100644 index 0000000..46c41af --- /dev/null +++ b/public/kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php @@ -0,0 +1,29 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class PdfFilePreview extends FilePreview +{ + public function __construct( + public File $file, + public string $component = 'k-pdf-file-preview' + ) { + } + + public static function accepts(File $file): bool + { + return $file->extension() === 'pdf'; + } +} diff --git a/public/kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php b/public/kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php new file mode 100644 index 0000000..ad3f103 --- /dev/null +++ b/public/kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php @@ -0,0 +1,29 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + * @unstable + */ +class VideoFilePreview extends FilePreview +{ + public function __construct( + public File $file, + public string $component = 'k-video-file-preview' + ) { + } + + public static function accepts(File $file): bool + { + return $file->type() === 'video'; + } +} diff --git a/public/kirby/src/Panel/User.php b/public/kirby/src/Panel/User.php index 17f641e..62df928 100644 --- a/public/kirby/src/Panel/User.php +++ b/public/kirby/src/Panel/User.php @@ -7,6 +7,7 @@ use Kirby\Cms\ModelWithContent; use Kirby\Cms\Translation; use Kirby\Cms\Url; use Kirby\Filesystem\Asset; +use Kirby\Panel\Ui\Buttons\ViewButtons; use Kirby\Toolkit\I18n; /** @@ -39,6 +40,19 @@ class User extends Model ]; } + /** + * Returns header buttons which should be displayed + * on the user view + */ + public function buttons(): array + { + return ViewButtons::view($this)->defaults( + 'theme', + 'settings', + 'languages' + )->render(); + } + /** * Provides options for the user dropdown */ @@ -147,16 +161,16 @@ class User extends Model */ protected function imageDefaults(): array { - return array_merge(parent::imageDefaults(), [ + return [ + ...parent::imageDefaults(), 'back' => 'black', 'icon' => 'user', 'ratio' => '1/1', - ]); + ]; } /** * Returns the image file object based on provided query - * @internal */ protected function imageSource( string|null $query = null @@ -188,17 +202,16 @@ class User extends Model { $params['text'] ??= '{{ user.username }}'; - return array_merge(parent::pickerData($params), [ + return [ + ...parent::pickerData($params), 'email' => $this->model->email(), 'username' => $this->model->username(), - ]); + ]; } /** * Returns navigation array with * previous and next user - * - * @internal */ public function prevNext(): array { @@ -211,41 +224,45 @@ class User extends Model } /** - * Returns the data array for the - * view's component props - * - * @internal + * Returns the data array for the view's component props */ public function props(): array { + $props = parent::props(); $user = $this->model; - $account = $user->isLoggedIn(); $permissions = $this->options(); - return array_merge( - parent::props(), - $this->prevNext(), - [ - 'blueprint' => $this->model->role()->name(), - 'canChangeEmail' => $permissions['changeEmail'], - 'canChangeLanguage' => $permissions['changeLanguage'], - 'canChangeName' => $permissions['changeName'], - 'canChangeRole' => $this->model->roles()->count() > 1, - 'model' => [ - 'account' => $account, - 'avatar' => $user->avatar()?->url(), - 'content' => $this->content(), - 'email' => $user->email(), - 'id' => $user->id(), - 'language' => $this->translation()->name(), - 'link' => $this->url(true), - 'name' => $user->name()->toString(), - 'role' => $user->role()->title(), - 'username' => $user->username(), - 'uuid' => fn () => $user->uuid()?->toString() - ] - ] - ); + // Additional model information + // @deprecated Use the top-level props instead + $model = [ + 'account' => $user->isLoggedIn(), + 'avatar' => $user->avatar()?->url(), + 'email' => $user->email(), + 'id' => $props['id'], + 'language' => $this->translation()->name(), + 'link' => $props['link'], + 'name' => $user->name()->toString(), + 'role' => $user->role()->title(), + 'username' => $user->username(), + 'uuid' => $props['uuid'], + ]; + + return [ + ...parent::props(), + ...$this->prevNext(), + 'avatar' => $model['avatar'], + 'blueprint' => $this->model->role()->name(), + 'canChangeEmail' => $permissions['changeEmail'], + 'canChangeLanguage' => $permissions['changeLanguage'], + 'canChangeName' => $permissions['changeName'], + 'canChangeRole' => $this->model->roles()->count() > 1, + 'email' => $model['email'], + 'language' => $model['language'], + 'model' => $model, + 'name' => $model['name'], + 'role' => $model['role'], + 'username' => $model['username'], + ]; } /** @@ -260,10 +277,7 @@ class User extends Model } /** - * Returns the data array for - * this model's Panel view - * - * @internal + * Returns the data array for this model's Panel view */ public function view(): array { diff --git a/public/kirby/src/Panel/UserTotpDisableDialog.php b/public/kirby/src/Panel/UserTotpDisableDialog.php index 7050cc7..7b318db 100644 --- a/public/kirby/src/Panel/UserTotpDisableDialog.php +++ b/public/kirby/src/Panel/UserTotpDisableDialog.php @@ -90,7 +90,9 @@ class UserTotpDisableDialog if ($this->kirby->user()->is($this->user) === true) { $this->user->validatePassword($password); } elseif ($this->kirby->user()->isAdmin() === false) { - throw new PermissionException('You are not allowed to disable TOTP for other users'); + throw new PermissionException( + message: 'You are not allowed to disable TOTP for other users' + ); } // Remove the TOTP secret from the account @@ -103,12 +105,12 @@ class UserTotpDisableDialog // Catch and re-throw exception so that any // Unauthenticated exception for incorrect passwords // does not trigger a logout - throw new InvalidArgumentException([ - 'key' => $e->getKey(), - 'data' => $e->getData(), - 'fallback' => $e->getMessage(), - 'previous' => $e - ]); + throw new InvalidArgumentException( + key: $e->getKey(), + data: $e->getData(), + fallback: $e->getMessage(), + previous: $e + ); } } } diff --git a/public/kirby/src/Panel/View.php b/public/kirby/src/Panel/View.php index c857710..0d51ee7 100644 --- a/public/kirby/src/Panel/View.php +++ b/public/kirby/src/Panel/View.php @@ -2,10 +2,12 @@ namespace Kirby\Panel; +use Kirby\Api\Upload; use Kirby\Cms\App; use Kirby\Exception\Exception; use Kirby\Http\Response; use Kirby\Toolkit\A; +use Kirby\Toolkit\Date; use Kirby\Toolkit\Str; use Throwable; @@ -64,7 +66,7 @@ class View $globalKeys = Str::split($globals, ','); // add requested globals - if (empty($globalKeys) === true) { + if ($globalKeys === []) { return $data; } @@ -96,7 +98,7 @@ class View $onlyKeys = Str::split($only, ','); // if a full request is made, return all data - if (empty($onlyKeys) === true) { + if ($onlyKeys === []) { return $data; } @@ -160,29 +162,15 @@ class View }, '$dialog' => null, '$drawer' => null, - '$language' => function () use ($kirby, $multilang, $language) { - if ($multilang === true && $language) { - return [ - 'code' => $language->code(), - 'default' => $language->isDefault(), - 'direction' => $language->direction(), - 'name' => $language->name(), - 'rules' => $language->rules(), - ]; - } + '$language' => fn () => match ($multilang) { + false => null, + true => $language?->toArray() }, - '$languages' => function () use ($kirby, $multilang): array { - if ($multilang === true) { - return $kirby->languages()->values(fn ($language) => [ - 'code' => $language->code(), - 'default' => $language->isDefault(), - 'direction' => $language->direction(), - 'name' => $language->name(), - 'rules' => $language->rules(), - ]); - } - - return []; + '$languages' => fn (): array => match ($multilang) { + false => [], + true => $kirby->languages()->values( + fn ($language) => $language->toArray() + ) }, '$menu' => function () use ($options, $permissions) { $menu = new Menu( @@ -197,18 +185,15 @@ class View '$multilang' => $multilang, '$searches' => static::searches($options['areas'] ?? [], $permissions), '$url' => $kirby->request()->url()->toString(), - '$user' => function () use ($user) { - if ($user) { - return [ - 'email' => $user->email(), - 'id' => $user->id(), - 'language' => $user->language(), - 'role' => $user->role()->id(), - 'username' => $user->username(), - ]; - } - - return null; + '$user' => fn () => match ($user) { + null => null, + default => [ + 'email' => $user->email(), + 'id' => $user->id(), + 'language' => $user->language(), + 'role' => $user->role()->id(), + 'username' => $user->username(), + ] }, '$view' => function () use ($kirby, $options, $view) { $defaults = [ @@ -230,6 +215,7 @@ class View // make sure that views and dialogs are gone unset( + $view['buttons'], $view['dialogs'], $view['drawers'], $view['dropdowns'], @@ -276,11 +262,12 @@ class View return [ '$config' => fn () => [ 'api' => [ - 'methodOverwrite' => $kirby->option('api.methodOverwrite', true) + 'methodOverride' => $kirby->option('api.methodOverride', true) ], 'debug' => $kirby->option('debug', false), 'kirbytext' => $kirby->option('panel.kirbytext', true), 'translation' => $kirby->option('panel.language', 'en'), + 'upload' => Upload::chunkSize(), ], '$system' => function () use ($kirby) { $locales = []; @@ -299,17 +286,17 @@ class View ]; }, '$translation' => function () use ($kirby) { - if ($user = $kirby->user()) { - $translation = $kirby->translation($user->language()); - } else { - $translation = $kirby->translation($kirby->panelLanguage()); - } + $translation = match ($user = $kirby->user()) { + null => $kirby->translation($kirby->panelLanguage()), + default => $kirby->translation($user->language()) + }; return [ 'code' => $translation->code(), 'data' => $translation->dataWithFallback(), 'direction' => $translation->direction(), 'name' => $translation->name(), + 'weekday' => Date::firstWeekday($translation->locale()) ]; }, '$urls' => fn () => [ @@ -328,17 +315,23 @@ class View { // handle redirects if ($data instanceof Redirect) { - return Response::redirect($data->location(), $data->code()); + // if the redirect is a refresh, return a refresh response + if ($data->refresh() !== false) { + return Response::refresh($data->location(), $data->code(), $data->refresh()); + } - // handle Kirby exceptions - } elseif ($data instanceof Exception) { + return Response::redirect($data->location(), $data->code()); + } + + // handle Kirby exceptions + if ($data instanceof Exception) { $data = static::error($data->getMessage(), $data->getHttpCode()); - // handle regular exceptions + // handle regular exceptions } elseif ($data instanceof Throwable) { $data = static::error($data->getMessage(), 500); - // only expect arrays from here on + // only expect arrays from here on } elseif (is_array($data) === false) { $data = static::error('Invalid Panel response', 500); } diff --git a/public/kirby/src/Parsley/Element.php b/public/kirby/src/Parsley/Element.php index 649c5a3..02a04d5 100644 --- a/public/kirby/src/Parsley/Element.php +++ b/public/kirby/src/Parsley/Element.php @@ -8,16 +8,14 @@ use DOMXPath; use Kirby\Toolkit\Str; /** - * Represents a block level element - * in an HTML document - * - * @since 3.5.0 + * Represents a block level element in an HTML document * * @package Kirby Parsley - * @author Bastian Allgeier , + * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 3.5.0 */ class Element { diff --git a/public/kirby/src/Parsley/Inline.php b/public/kirby/src/Parsley/Inline.php index 32e6b08..63efaf1 100644 --- a/public/kirby/src/Parsley/Inline.php +++ b/public/kirby/src/Parsley/Inline.php @@ -9,16 +9,14 @@ use DOMText; use Kirby\Toolkit\Html; /** - * Represents an inline element - * in an HTML document - * - * @since 3.5.0 + * Represents an inline element in an HTML document * * @package Kirby Parsley - * @author Bastian Allgeier , + * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 3.5.0 */ class Inline { diff --git a/public/kirby/src/Parsley/Parsley.php b/public/kirby/src/Parsley/Parsley.php index b6d834b..392cfcb 100644 --- a/public/kirby/src/Parsley/Parsley.php +++ b/public/kirby/src/Parsley/Parsley.php @@ -2,7 +2,9 @@ namespace Kirby\Parsley; +use DOMComment; use DOMDocument; +use DOMDocumentType; use DOMElement; use DOMNode; use DOMText; @@ -13,13 +15,12 @@ use Kirby\Toolkit\Dom; * HTML parser to extract the best possible blocks * from any kind of HTML document * - * @since 3.5.0 - * * @package Kirby Parsley - * @author Bastian Allgeier , + * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 3.5.0 */ class Parsley { @@ -123,7 +124,7 @@ class Parsley */ public function endInlineBlock(): void { - if (empty($this->inline) === true) { + if ($this->inline === []) { return; } @@ -193,7 +194,7 @@ class Parsley } $marks = array_column($this->marks, 'tag'); - return in_array($element->tagName, $marks); + return in_array($element->tagName, $marks, true); } return false; @@ -212,7 +213,7 @@ class Parsley ) { $this->blocks[$lastIndex]['content']['text'] .= ' ' . $block['content']['text']; - // append + // append } else { $this->blocks[] = $block; } @@ -224,10 +225,11 @@ class Parsley */ public function parseNode(DOMNode $element): bool { - $skip = ['DOMComment', 'DOMDocumentType']; - // unwanted element types - if (in_array(get_class($element), $skip) === true) { + if ( + $element instanceof DOMComment || + $element instanceof DOMDocumentType + ) { return false; } @@ -239,12 +241,13 @@ class Parsley $this->endInlineBlock(); + // known block nodes if ($this->isBlock($element) === true) { /** * @var DOMElement $element */ - if ($parser = ($this->nodes[$element->tagName]['parse'] ?? null)) { + if ($parser = $this->nodes[$element->tagName]['parse'] ?? null) { if ($result = $parser(new Element($element, $this->marks))) { $this->blocks[] = $result; } @@ -257,7 +260,7 @@ class Parsley /** * @var DOMElement $element */ - if (in_array($element->tagName, $this->skip) === true) { + if (in_array($element->tagName, $this->skip, true) === true) { return false; } @@ -270,7 +273,7 @@ class Parsley // wrapper elements should never be converted // to a simple fallback block. Their children // have to be parsed individually. - if (in_array($element->tagName, $wrappers) === false) { + if (in_array($element->tagName, $wrappers, true) === false) { $node = new Element($element, $this->marks); if ($block = $this->fallback($node)) { diff --git a/public/kirby/src/Parsley/Schema.php b/public/kirby/src/Parsley/Schema.php index 3cf74f1..404d9ca 100644 --- a/public/kirby/src/Parsley/Schema.php +++ b/public/kirby/src/Parsley/Schema.php @@ -3,15 +3,15 @@ namespace Kirby\Parsley; /** - * Block schema definition - * - * @since 3.5.0 + * Schema definition how to parse + * the HTML document into blocks * * @package Kirby Parsley - * @author Bastian Allgeier , + * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 3.5.0 */ class Schema { diff --git a/public/kirby/src/Parsley/Schema/Blocks.php b/public/kirby/src/Parsley/Schema/Blocks.php index a72106c..7ad6fc7 100644 --- a/public/kirby/src/Parsley/Schema/Blocks.php +++ b/public/kirby/src/Parsley/Schema/Blocks.php @@ -8,16 +8,15 @@ use Kirby\Parsley\Element; use Kirby\Toolkit\Str; /** - * The plain schema definition converts - * the entire document into simple text blocks - * - * @since 3.5.0 + * The blocks schema definition converts + * the entire document into blocks for the blocks field * * @package Kirby Parsley - * @author Bastian Allgeier , + * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 3.5.0 */ class Blocks extends Plain { @@ -187,7 +186,7 @@ class Blocks extends Plain } elseif ($child instanceof DOMElement) { $child = new Element($child); $list = ['ul', 'ol']; - $innerHtml .= match (in_array($child->tagName(), $list)) { + $innerHtml .= match (in_array($child->tagName(), $list, true)) { true => $this->list($child), default => $child->innerHTML($this->marks()) }; diff --git a/public/kirby/src/Parsley/Schema/Plain.php b/public/kirby/src/Parsley/Schema/Plain.php index 183556f..32f0d71 100644 --- a/public/kirby/src/Parsley/Schema/Plain.php +++ b/public/kirby/src/Parsley/Schema/Plain.php @@ -10,13 +10,12 @@ use Kirby\Toolkit\Str; * The plain schema definition converts * the entire document into simple text blocks * - * @since 3.5.0 - * * @package Kirby Parsley - * @author Bastian Allgeier , + * @author Bastian Allgeier * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * @since 3.5.0 */ class Plain extends Schema { diff --git a/public/kirby/src/Cms/PluginAsset.php b/public/kirby/src/Plugin/Asset.php similarity index 88% rename from public/kirby/src/Cms/PluginAsset.php rename to public/kirby/src/Plugin/Asset.php index f5f7f69..e49f149 100644 --- a/public/kirby/src/Cms/PluginAsset.php +++ b/public/kirby/src/Plugin/Asset.php @@ -1,21 +1,22 @@ * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license */ -class PluginAsset +class Asset implements Stringable { public function __construct( protected string $path, @@ -93,8 +94,11 @@ class PluginAsset */ public function publishAt(string $path): void { - $media = $this->plugin()->mediaRoot() . '/' . $path; - F::link($this->root(), $media, 'symlink'); + F::link( + $this->root(), + $this->plugin()->mediaRoot() . '/' . $path, + 'symlink' + ); } public function root(): string @@ -103,7 +107,7 @@ class PluginAsset } /** - * @see ::mediaUrl + * @see self::mediaUrl() */ public function url(): string { @@ -111,7 +115,7 @@ class PluginAsset } /** - * @see ::url + * @see self::url() */ public function __toString(): string { diff --git a/public/kirby/src/Cms/PluginAssets.php b/public/kirby/src/Plugin/Assets.php similarity index 94% rename from public/kirby/src/Cms/PluginAssets.php rename to public/kirby/src/Plugin/Assets.php index 45c415f..7eaa122 100644 --- a/public/kirby/src/Cms/PluginAssets.php +++ b/public/kirby/src/Plugin/Assets.php @@ -1,8 +1,10 @@ * @author Nico Hoffmann * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Plugin\Asset> */ -class PluginAssets extends Collection +class Assets extends Collection { /** * Clean old/deprecated assets on every resolve @@ -121,7 +125,7 @@ class PluginAssets extends Collection $collection = new static([], $plugin); foreach ($assets as $path => $root) { - $collection->data[$path] = new PluginAsset($path, $root, $plugin); + $collection->data[$path] = new Asset($path, $root, $plugin); } return $collection; diff --git a/public/kirby/src/Plugin/License.php b/public/kirby/src/Plugin/License.php new file mode 100644 index 0000000..aed0162 --- /dev/null +++ b/public/kirby/src/Plugin/License.php @@ -0,0 +1,112 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +class License implements Stringable +{ + protected LicenseStatus $status; + + public function __construct( + protected Plugin $plugin, + protected string $name, + protected string|null $link = null, + LicenseStatus|null $status = null + ) { + $this->status = $status ?? LicenseStatus::from('unknown'); + } + + /** + * Returns the string representation of the license + */ + public function __toString(): string + { + return $this->name(); + } + + /** + * Creates a license instance from a given value + */ + public static function from( + Plugin $plugin, + Closure|array|string|null $license + ): static { + if ($license instanceof Closure) { + return $license($plugin); + } + + if (is_array($license)) { + return new static( + plugin: $plugin, + name: $license['name'] ?? '', + link: $license['link'] ?? null, + status: LicenseStatus::from($license['status'] ?? 'active') + ); + } + + if ($license === null || $license === '-') { + return new static( + plugin: $plugin, + name: '-', + status: LicenseStatus::from('unknown') + ); + } + + return new static( + plugin: $plugin, + name: $license, + status: LicenseStatus::from('active') + ); + } + + /** + * Get the license link. This can be the + * license terms or a link to a shop to + * purchase a license. + */ + public function link(): string|null + { + return $this->link; + } + + /** + * Get the license name + */ + public function name(): string + { + return $this->name; + } + + /** + * Get the license status + */ + public function status(): LicenseStatus + { + return $this->status; + } + + /** + * Returns the license information as an array + */ + public function toArray(): array + { + return [ + 'link' => $this->link(), + 'name' => $this->name(), + 'status' => $this->status()->toArray() + ]; + } +} diff --git a/public/kirby/src/Plugin/LicenseStatus.php b/public/kirby/src/Plugin/LicenseStatus.php new file mode 100644 index 0000000..7f268b2 --- /dev/null +++ b/public/kirby/src/Plugin/LicenseStatus.php @@ -0,0 +1,135 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.0.0 + */ +class LicenseStatus implements Stringable +{ + public function __construct( + protected string $value, + protected string $icon, + protected string $label, + protected string|null $link = null, + protected string|null $dialog = null, + protected string|null $drawer = null, + protected string|null $theme = null + ) { + } + + /** + * Returns the status label + */ + public function __toString(): string + { + return $this->label(); + } + + /** + * Returns the status dialog + */ + public function dialog(): string|null + { + return $this->dialog; + } + + /** + * Returns the status drawer + */ + public function drawer(): string|null + { + return $this->drawer; + } + + /** + * Returns a status by its name + */ + public static function from(LicenseStatus|string|array|null $status): static + { + if ($status instanceof LicenseStatus) { + return $status; + } + + if (is_array($status) === true) { + return new static(...$status); + } + + $status = SystemLicenseStatus::from($status ?? 'unknown'); + $status ??= SystemLicenseStatus::Unknown; + + return new static( + value: $status->value, + icon: $status->icon(), + label: $status->label(), + theme: $status->theme() + ); + } + + /** + * Returns the status icon + */ + public function icon(): string + { + return $this->icon; + } + + /** + * Returns the status label + */ + public function label(): string + { + return $this->label; + } + + /** + * Returns the status link + */ + public function link(): string|null + { + return $this->link; + } + + /** + * Returns the theme + */ + public function theme(): string|null + { + return $this->theme; + } + + /** + * Returns the status information as an array + */ + public function toArray(): array + { + return [ + 'dialog' => $this->dialog(), + 'drawer' => $this->drawer(), + 'icon' => $this->icon(), + 'label' => $this->label(), + 'link' => $this->link(), + 'theme' => $this->theme(), + 'value' => $this->value(), + ]; + } + + /** + * Returns the status value + */ + public function value(): string + { + return $this->value; + } +} diff --git a/public/kirby/src/Cms/Plugin.php b/public/kirby/src/Plugin/Plugin.php similarity index 87% rename from public/kirby/src/Cms/Plugin.php rename to public/kirby/src/Plugin/Plugin.php index cc6cc8f..ab3ee6e 100644 --- a/public/kirby/src/Cms/Plugin.php +++ b/public/kirby/src/Plugin/Plugin.php @@ -1,9 +1,11 @@ * @link https://getkirby.com * @copyright Bastian Allgeier @@ -25,7 +27,8 @@ use Throwable; */ class Plugin { - protected PluginAssets $assets; + protected Assets $assets; + protected License|Closure|array|string $license; protected UpdateStatus|null $updateStatus = null; /** @@ -38,6 +41,7 @@ class Plugin protected string $name, protected array $extends = [], protected array $info = [], + Closure|string|array|null $license = null, protected string|null $root = null, protected string|null $version = null, ) { @@ -64,14 +68,9 @@ class Plugin } // read composer.json and use as info fallback - try { - $info = Data::read($this->manifest()); - } catch (Exception) { - // there is no manifest file or it is invalid - $info = []; - } - - $this->info = [...$info, ...$this->info]; + $info = Data::read($this->manifest(), fail: false); + $this->info = [...$info, ...$this->info]; + $this->license = $license ?? $this->info['license'] ?? '-'; } /** @@ -85,7 +84,7 @@ class Plugin /** * Returns the plugin asset object for a specific asset */ - public function asset(string $path): PluginAsset|null + public function asset(string $path): Asset|null { return $this->assets()->get($path); } @@ -93,9 +92,9 @@ class Plugin /** * Returns the plugin assets collection */ - public function assets(): PluginAssets + public function assets(): Assets { - return $this->assets ??= PluginAssets::factory($this); + return $this->assets ??= Assets::factory($this); } /** @@ -169,6 +168,18 @@ class Plugin return V::url($link) ? $link : null; } + /** + * Returns the license object + */ + public function license(): License + { + // resolve license info from Closure, array or string + return License::from( + plugin: $this, + license: $this->license + ); + } + /** * Returns the path to the plugin's composer.json */ @@ -182,7 +193,7 @@ class Plugin */ public function mediaRoot(): string { - return App::instance()->root('media') . '/plugins/' . $this->name(); + return $this->kirby()->root('media') . '/plugins/' . $this->name(); } /** @@ -190,7 +201,7 @@ class Plugin */ public function mediaUrl(): string { - return App::instance()->url('media') . '/plugins/' . $this->name(); + return $this->kirby()->url('media') . '/plugins/' . $this->name(); } /** @@ -234,7 +245,7 @@ class Plugin 'authors' => $this->authors(), 'description' => $this->description(), 'name' => $this->name(), - 'license' => $this->license(), + 'license' => $this->license()->toArray(), 'link' => $this->link(), 'root' => $this->root(), 'version' => $this->version() @@ -269,7 +280,7 @@ class Plugin $keys = array_map('strlen', array_keys($option)); array_multisort($keys, SORT_DESC, $option); - if (count($option) > 0) { + if ($option !== []) { // use the first and therefore longest key (= most specific match) $option = reset($option); } else { @@ -296,7 +307,9 @@ class Plugin public static function validateName(string $name): void { if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) { - throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"'); + throw new InvalidArgumentException( + message: 'The plugin name must follow the format "a-z0-9-/a-z0-9-"' + ); } } diff --git a/public/kirby/src/Query/Argument.php b/public/kirby/src/Query/Argument.php index d9ca493..b213277 100644 --- a/public/kirby/src/Query/Argument.php +++ b/public/kirby/src/Query/Argument.php @@ -70,7 +70,7 @@ class Argument // numeric if (is_numeric($argument) === true) { - if (strpos($argument, '.') === false) { + if (str_contains($argument, '.') === false) { return new static((int)$argument); } diff --git a/public/kirby/src/Query/Arguments.php b/public/kirby/src/Query/Arguments.php index c78dd22..93dd9c3 100644 --- a/public/kirby/src/Query/Arguments.php +++ b/public/kirby/src/Query/Arguments.php @@ -14,6 +14,8 @@ use Kirby\Toolkit\Collection; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT + * + * @extends \Kirby\Toolkit\Collection<\Kirby\Query\Argument> */ class Arguments extends Collection { diff --git a/public/kirby/src/Query/Expression.php b/public/kirby/src/Query/Expression.php index 84928c3..8d2e114 100644 --- a/public/kirby/src/Query/Expression.php +++ b/public/kirby/src/Query/Expression.php @@ -41,10 +41,10 @@ class Expression // into actual types and treats all other parts as their own queries $parts = A::map( $parts, - fn ($part) => - in_array($part, ['?', ':', '?:', '??']) - ? $part - : Argument::factory($part) + fn ($part) => match ($part) { + '?', ':', '?:', '??' => $part, + default => Argument::factory($part) + } ); return new static(parts: $parts); @@ -53,7 +53,7 @@ class Expression /** * Splits a comparison string into an array * of expressions and operators - * @internal + * @unstable */ public static function parse(string $string): array { @@ -101,7 +101,9 @@ class Expression // if `a` isn't false, return `b`, otherwise `c` if ($part === '?') { if (($this->parts[$index + 2] ?? null) !== ':') { - throw new LogicException('Query: Incomplete ternary operator (missing matching `? :`)'); + throw new LogicException( + message: 'Query: Incomplete ternary operator (missing matching `? :`)' + ); } if ($base != false) { diff --git a/public/kirby/src/Query/Query.php b/public/kirby/src/Query/Query.php index 20cefa5..dc6fca8 100644 --- a/public/kirby/src/Query/Query.php +++ b/public/kirby/src/Query/Query.php @@ -80,7 +80,7 @@ class Query // merge data with default entries if (is_array($data) === true) { - $data = array_merge(static::$entries, $data); + $data = [...static::$entries, ...$data]; } // direct data array access via key diff --git a/public/kirby/src/Query/Segment.php b/public/kirby/src/Query/Segment.php index 89e7574..7c24aa9 100644 --- a/public/kirby/src/Query/Segment.php +++ b/public/kirby/src/Query/Segment.php @@ -28,7 +28,7 @@ class Segment /** * Throws an exception for an access to an invalid method - * @internal + * @unstable * * @param mixed $data Variable on which the access was tried * @param string $name Name of the method/property that was accessed @@ -44,7 +44,7 @@ class Segment $type = 'float'; } - $nonExisting = in_array($type, ['array', 'object']) ? 'non-existing ' : ''; + $nonExisting = in_array($type, ['array', 'object'], true) ? 'non-existing ' : ''; $error = 'Access to ' . $nonExisting . $label . ' "' . $name . '" on ' . $type; @@ -145,7 +145,9 @@ class Segment array_key_exists($this->method, $array) && $args !== [] ) { - throw new InvalidArgumentException('Cannot access array element "' . $this->method . '" with arguments'); + throw new InvalidArgumentException( + message: 'Cannot access array element "' . $this->method . '" with arguments' + ); } // last, the standard error for trying to access something diff --git a/public/kirby/src/Query/Segments.php b/public/kirby/src/Query/Segments.php index c4069a1..78e83a7 100644 --- a/public/kirby/src/Query/Segments.php +++ b/public/kirby/src/Query/Segments.php @@ -14,6 +14,8 @@ use Kirby\Toolkit\Collection; * @link https://getkirby.com * @copyright Bastian Allgeier * @license https://opensource.org/licenses/MIT + * + * @extends \Kirby\Toolkit\Collection<\Kirby\Query\Segment> */ class Segments extends Collection { @@ -37,7 +39,7 @@ class Segments extends Collection $segments, function ($segment) use (&$position) { // leave connectors as they are - if (in_array($segment, ['.', '?.']) === true) { + if (in_array($segment, ['.', '?.'], true) === true) { return $segment; } @@ -54,7 +56,7 @@ class Segments extends Collection /** * Splits the string of a segment chaing into an * array of segments as well as conenctors (`.` or `?.`) - * @internal + * @unstable */ public static function parse(string $string): array { diff --git a/public/kirby/src/Sane/DomHandler.php b/public/kirby/src/Sane/DomHandler.php index 2fe2e09..3ef7516 100644 --- a/public/kirby/src/Sane/DomHandler.php +++ b/public/kirby/src/Sane/DomHandler.php @@ -40,10 +40,8 @@ class DomHandler extends Handler /** * Allowed hostnames for HTTP(S) URLs - * - * @var array|true */ - public static array|bool $allowedDomains = true; + public static array|true $allowedDomains = true; /** * Whether URLs that begin with `/` should be allowed even if the @@ -71,8 +69,10 @@ class DomHandler extends Handler * * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed */ - public static function sanitize(string $string, bool $isExternal = false): string - { + public static function sanitize( + string $string, + bool $isExternal = false + ): string { $dom = static::parse($string); $dom->sanitize(static::options($isExternal)); return $dom->toString(); @@ -87,8 +87,10 @@ class DomHandler extends Handler * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation */ - public static function validate(string $string, bool $isExternal = false): void - { + public static function validate( + string $string, + bool $isExternal = false + ): void { $dom = static::parse($string); $errors = $dom->sanitize(static::options($isExternal)); @@ -104,8 +106,10 @@ class DomHandler extends Handler * * @return array Array with exception objects for each modification */ - public static function sanitizeAttr(DOMAttr $attr, array $options): array - { + public static function sanitizeAttr( + DOMAttr $attr, + array $options + ): array { // to be extended in child classes return []; } @@ -116,8 +120,10 @@ class DomHandler extends Handler * * @return array Array with exception objects for each modification */ - public static function sanitizeElement(DOMElement $element, array $options): array - { + public static function sanitizeElement( + DOMElement $element, + array $options + ): array { // to be extended in child classes return []; } @@ -126,8 +132,10 @@ class DomHandler extends Handler * Custom callback for additional doctype validation * @internal */ - public static function validateDoctype(DOMDocumentType $doctype, array $options): void - { + public static function validateDoctype( + DOMDocumentType $doctype, + array $options + ): void { // to be extended in child classes } @@ -145,9 +153,9 @@ class DomHandler extends Handler 'allowedDomains' => static::$allowedDomains, 'allowHostRelativeUrls' => static::$allowHostRelativeUrls, 'allowedPIs' => static::$allowedPIs, - 'attrCallback' => [static::class, 'sanitizeAttr'], - 'doctypeCallback' => [static::class, 'validateDoctype'], - 'elementCallback' => [static::class, 'sanitizeElement'], + 'attrCallback' => static::sanitizeAttr(...), + 'doctypeCallback' => static::validateDoctype(...), + 'elementCallback' => static::sanitizeElement(...), ]; // never allow host-relative URLs in external files as we diff --git a/public/kirby/src/Sane/Handler.php b/public/kirby/src/Sane/Handler.php index 7dfcd98..bd6b4e5 100644 --- a/public/kirby/src/Sane/Handler.php +++ b/public/kirby/src/Sane/Handler.php @@ -25,7 +25,10 @@ abstract class Handler * @param bool $isExternal Whether the string is from an external file * that may be accessed directly */ - abstract public static function sanitize(string $string, bool $isExternal = false): string; + abstract public static function sanitize( + string $string, + bool $isExternal = false + ): string; /** * Sanitizes the contents of a file by overwriting @@ -50,7 +53,10 @@ abstract class Handler * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation * @throws \Kirby\Exception\Exception On other errors */ - abstract public static function validate(string $string, bool $isExternal = false): void; + abstract public static function validate( + string $string, + bool $isExternal = false + ): void; /** * Validates the contents of a file @@ -76,7 +82,9 @@ abstract class Handler $contents = F::read($file); if ($contents === false) { - throw new Exception('The file "' . $file . '" does not exist'); + throw new Exception( + message: 'The file "' . $file . '" does not exist' + ); } return $contents; diff --git a/public/kirby/src/Sane/Html.php b/public/kirby/src/Sane/Html.php index 0766e36..0ad2e1f 100644 --- a/public/kirby/src/Sane/Html.php +++ b/public/kirby/src/Sane/Html.php @@ -113,7 +113,8 @@ class Html extends DomHandler */ protected static function options(bool $isExternal): array { - return array_merge(parent::options($isExternal), [ + return [ + ...parent::options($isExternal), 'allowedAttrPrefixes' => static::$allowedAttrPrefixes, 'allowedAttrs' => static::$allowedAttrs, 'allowedNamespaces' => [], @@ -121,6 +122,6 @@ class Html extends DomHandler 'allowedTags' => static::$allowedTags, 'disallowedTags' => static::$disallowedTags, 'urlAttrs' => static::$urlAttrs, - ]); + ]; } } diff --git a/public/kirby/src/Sane/Sane.php b/public/kirby/src/Sane/Sane.php index c079e2a..9d753ee 100644 --- a/public/kirby/src/Sane/Sane.php +++ b/public/kirby/src/Sane/Sane.php @@ -5,6 +5,7 @@ namespace Kirby\Sane; use Kirby\Exception\LogicException; use Kirby\Exception\NotFoundException; use Kirby\Filesystem\F; +use Kirby\Toolkit\A; /** * The `Sane` class validates that files @@ -71,7 +72,9 @@ class Sane return null; } - throw new NotFoundException('Missing handler for type: "' . $type . '"'); + throw new NotFoundException( + message: 'Missing handler for type: "' . $type . '"' + ); } /** @@ -81,8 +84,11 @@ class Sane * @param bool $isExternal Whether the string is from an external file * that may be accessed directly */ - public static function sanitize(string $string, string $type, bool $isExternal = false): string - { + public static function sanitize( + string $string, + string $type, + bool $isExternal = false + ): string { return static::handler($type)->sanitize($string, $isExternal); } @@ -123,10 +129,9 @@ class Sane default: // more than one matching handler; // sanitizing with all handlers will not leave much in the output - $handlerNames = array_map('get_class', $handlers); throw new LogicException( 'Cannot sanitize file as more than one handler applies: ' . - implode(', ', $handlerNames) + implode(', ', A::map($handlers, fn ($handler) => $handler::class)) ); } } @@ -194,12 +199,12 @@ class Sane foreach ($options as $option) { $handler = static::handler($option, $lazy); - $handlerClass = $handler ? get_class($handler) : null; + $handlerClass = $handler ? $handler::class : null; // ensure that each handler class is only returned once if ( $handler && - in_array($handlerClass, $handlerClasses) === false + in_array($handlerClass, $handlerClasses, true) === false ) { $handlers[] = $handler; $handlerClasses[] = $handlerClass; diff --git a/public/kirby/src/Sane/Svg.php b/public/kirby/src/Sane/Svg.php index 1a947ec..7d8d22d 100644 --- a/public/kirby/src/Sane/Svg.php +++ b/public/kirby/src/Sane/Svg.php @@ -264,10 +264,8 @@ class Svg extends Xml /** * Allowed hostnames for HTTP(S) URLs - * - * @var array|true */ - public static array|bool $allowedDomains = []; + public static array|true $allowedDomains = []; /** * Associative array of all allowed namespace URIs @@ -432,8 +430,10 @@ class Svg extends Xml * * @return array Array with exception objects for each modification */ - public static function sanitizeElement(DOMElement $element, array $options): array - { + public static function sanitizeElement( + DOMElement $element, + array $options + ): array { $errors = []; // check for URLs inside diff --git a/src/components/inspirations/Selector.vue b/src/components/inspirations/Selector.vue index 22a25be..84b564a 100644 --- a/src/components/inspirations/Selector.vue +++ b/src/components/inspirations/Selector.vue @@ -1,5 +1,4 @@