commit dc0ae26464ebe8be3b0f4f8d0583cbfe62330a33 Author: isUnknown Date: Mon Nov 24 14:01:48 2025 +0100 init with kirby, vue and pagedjs interactive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..298aad5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.claude \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ed16ede --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1470 @@ +{ + "name": "geoproject", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "geoproject", + "version": "0.0.0", + "dependencies": { + "pagedjs": "^0.4.3", + "vue": "^3.5.24" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/polyfill": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz", + "integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==", + "deprecated": "🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.", + "license": "MIT", + "dependencies": { + "core-js": "^2.6.5", + "regenerator-runtime": "^0.13.4" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", + "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz", + "integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.50" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/clear-cut": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz", + "integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/pagedjs": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/pagedjs/-/pagedjs-0.4.3.tgz", + "integrity": "sha512-YtAN9JAjsQw1142gxEjEAwXvOF5nYQuDwnQ67RW2HZDkMLI+b4RsBE37lULZa9gAr6kDAOGBOhXI4wGMoY3raw==", + "license": "MIT", + "dependencies": { + "@babel/polyfill": "^7.10.1", + "@babel/runtime": "^7.21.0", + "clear-cut": "^2.0.2", + "css-tree": "^1.1.3", + "event-emitter": "^0.3.5" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a0a97e --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "geoproject", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "pagedjs": "^0.4.3", + "vue": "^3.5.24" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "vite": "^7.2.4" + } +} diff --git a/public/.editorconfig b/public/.editorconfig new file mode 100644 index 0000000..31cb096 --- /dev/null +++ b/public/.editorconfig @@ -0,0 +1,21 @@ +[*.{css,scss,less,js,json,ts,sass,html,hbs,mustache,phtml,html.twig,md,yml}] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +[site/templates/**.php] +indent_size = 2 + +[site/snippets/**.php] +indent_size = 2 + +[package.json,.{babelrc,editorconfig,eslintrc,lintstagedrc,stylelintrc}] +indent_style = space +indent_size = 2 diff --git a/public/.gitignore b/public/.gitignore new file mode 100644 index 0000000..e371e8b --- /dev/null +++ b/public/.gitignore @@ -0,0 +1,50 @@ +# System files +# ------------ + +Icon +.DS_Store + +# Temporary files +# --------------- + +/media/* +!/media/index.html + +# Lock files +# --------------- + +.lock + +# Editors +# (sensitive workspace files) +# --------------------------- +*.sublime-workspace +/.vscode +/.idea + +# -------------SECURITY------------- +# NEVER publish these files via Git! +# -------------SECURITY------------- + +# Cache Files +# --------------- + +/site/cache/* +!/site/cache/index.html + +# Accounts +# --------------- + +/site/accounts/* +!/site/accounts/index.html + +# Sessions +# --------------- + +/site/sessions/* +!/site/sessions/index.html + +# License +# --------------- + +/site/config/.license diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..5fe5c71 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,67 @@ +# Kirby .htaccess +# revision 2023-07-22 + +# rewrite rules + + +# enable awesome urls. i.e.: +# http://yourdomain.com/about-us/team +RewriteEngine on + +# make sure to set the RewriteBase correctly +# if you are running the site in a subfolder; +# otherwise links or the entire site will break. +# +# If your homepage is http://yourdomain.com/mysite, +# set the RewriteBase to: +# +# RewriteBase /mysite + +# In some environments it's necessary to +# set the RewriteBase to: +# +# RewriteBase / + +# block files and folders beginning with a dot, such as .git +# except for the .well-known folder, which is used for Let's Encrypt and security.txt +RewriteRule (^|/)\.(?!well-known\/) index.php [L] + +# block all files in the content folder from being accessed directly +RewriteRule ^content/(.*) index.php [L] + +# block all files in the site folder from being accessed directly +RewriteRule ^site/(.*) index.php [L] + +# block direct access to Kirby and the Panel sources +RewriteRule ^kirby/(.*) index.php [L] + +# make site links work +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*) index.php [L] + + + +# pass the Authorization header to PHP +SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1 + +# compress text file responses + +AddOutputFilterByType DEFLATE text/plain +AddOutputFilterByType DEFLATE text/html +AddOutputFilterByType DEFLATE text/css +AddOutputFilterByType DEFLATE text/javascript +AddOutputFilterByType DEFLATE application/json +AddOutputFilterByType DEFLATE application/javascript +AddOutputFilterByType DEFLATE application/x-javascript + + +# set security headers in all responses + + +# serve files as plain text if the actual content type is not known +# (hardens against attacks from malicious file uploads) +Header set Content-Type "text/plain" "expr=-z %{CONTENT_TYPE}" +Header set X-Content-Type-Options "nosniff" + + diff --git a/public/README.md b/public/README.md new file mode 100644 index 0000000..7772e95 --- /dev/null +++ b/public/README.md @@ -0,0 +1,36 @@ + + +**Kirby: the CMS that adapts to any project, loved by developers and editors alike.** +The Plainkit is a minimal Kirby setup with the basics you need to start a project from scratch. It is the ideal choice if you are already familiar with Kirby and want to start step-by-step. + +You can learn more about Kirby at [getkirby.com](https://getkirby.com). + +### Try Kirby for free + +You can try Kirby and the Plainkit on your local machine or on a test server as long as you need to make sure it is the right tool for your next project. … and when you’re convinced, [buy your license](https://getkirby.com/buy). + +### Get going + +Read our guide on [how to get started with Kirby](https://getkirby.com/docs/guide/quickstart). + +You can [download the latest version](https://github.com/getkirby/plainkit/archive/main.zip) of the Plainkit. +If you are familiar with Git, you can clone Kirby's Plainkit repository from Github. + + git clone https://github.com/getkirby/plainkit.git + +## What's Kirby? + +- **[getkirby.com](https://getkirby.com)** – Get to know the CMS. +- **[Try it](https://getkirby.com/try)** – Take a test ride with our online demo. Or download one of our kits to get started. +- **[Documentation](https://getkirby.com/docs/guide)** – Read the official guide, reference and cookbook recipes. +- **[Issues](https://github.com/getkirby/kirby/issues)** – Report bugs and other problems. +- **[Feedback](https://feedback.getkirby.com)** – You have an idea for Kirby? Share it. +- **[Forum](https://forum.getkirby.com)** – Whenever you get stuck, don't hesitate to reach out for questions and support. +- **[Discord](https://chat.getkirby.com)** – Hang out and meet the community. +- **[Mastodon](https://mastodon.social/@getkirby)** – Spread the word. +- **[Bluesky](https://bsky.app/profile/getkirby.com)** – Spread the word. + +--- + +© 2009 Bastian Allgeier +[getkirby.com](https://getkirby.com) · [License agreement](https://getkirby.com/license) diff --git a/public/assets/src/_editor-ui.scss b/public/assets/src/_editor-ui.scss new file mode 100644 index 0000000..a5928c4 --- /dev/null +++ b/public/assets/src/_editor-ui.scss @@ -0,0 +1,16 @@ +#editor-ui { + position: fixed !important; + bottom: 1rem !important; + left: 1rem !important; + z-index: 9999 !important; + background: white; + padding: 0.5rem; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +#increase-margin { + padding: 0.5rem 1rem; + font-size: 1.5rem; + cursor: pointer; +} diff --git a/public/assets/style.css b/public/assets/style.css new file mode 100644 index 0000000..1fee007 --- /dev/null +++ b/public/assets/style.css @@ -0,0 +1,16 @@ +#editor-ui { + position: fixed !important; + bottom: 1rem !important; + left: 1rem !important; + z-index: 9999 !important; + background: white; + padding: 0.5rem; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +#increase-margin { + padding: 0.5rem 1rem; + font-size: 1.5rem; + cursor: pointer; +}/*# sourceMappingURL=style.css.map */ \ No newline at end of file diff --git a/public/assets/style.css.map b/public/assets/style.css.map new file mode 100644 index 0000000..ad0483b --- /dev/null +++ b/public/assets/style.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["src/_editor-ui.scss","style.css"],"names":[],"mappings":"AAAA;EACE,0BAAA;EACA,uBAAA;EACA,qBAAA;EACA,wBAAA;EACA,iBAAA;EACA,eAAA;EACA,kBAAA;EACA,wCAAA;ACCF;;ADEA;EACE,oBAAA;EACA,iBAAA;EACA,eAAA;ACCF","file":"style.css"} \ No newline at end of file diff --git a/public/assets/style.scss b/public/assets/style.scss new file mode 100644 index 0000000..0eca77e --- /dev/null +++ b/public/assets/style.scss @@ -0,0 +1 @@ +@import "src/_editor-ui.scss"; diff --git a/public/composer.json b/public/composer.json new file mode 100644 index 0000000..d3864ae --- /dev/null +++ b/public/composer.json @@ -0,0 +1,39 @@ +{ + "name": "getkirby/plainkit", + "description": "Kirby Plainkit", + "type": "project", + "keywords": [ + "kirby", + "cms", + "plainkit" + ], + "authors": [ + { + "name": "Bastian Allgeier", + "email": "bastian@getkirby.com", + "homepage": "https://getkirby.com" + } + ], + "homepage": "https://getkirby.com", + "support": { + "email": "support@getkirby.com", + "forum": "https://forum.getkirby.com", + "source": "https://github.com/getkirby/plainkit" + }, + "require": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", + "getkirby/cms": "^5.0" + }, + "config": { + "allow-plugins": { + "getkirby/composer-installer": true + }, + "optimize-autoloader": true + }, + "scripts": { + "start": [ + "Composer\\Config::disableProcessTimeout", + "@php -S localhost:8000 kirby/router.php" + ] + } +} diff --git a/public/composer.lock b/public/composer.lock new file mode 100644 index 0000000..5d9b2c4 --- /dev/null +++ b/public/composer.lock @@ -0,0 +1,1223 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "0b7fb803e22a45eb87e24172337208aa", + "packages": [ + { + "name": "christian-riesen/base32", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/ChristianRiesen/base32.git", + "reference": "2e82dab3baa008e24a505649b0d583c31d31e894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristianRiesen/base32/zipball/2e82dab3baa008e24a505649b0d583c31d31e894", + "reference": "2e82dab3baa008e24a505649b0d583c31d31e894", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.17", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.5.13 || ^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Base32\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Riesen", + "email": "chris.riesen@gmail.com", + "homepage": "http://christianriesen.com", + "role": "Developer" + } + ], + "description": "Base32 encoder/decoder according to RFC 4648", + "homepage": "https://github.com/ChristianRiesen/base32", + "keywords": [ + "base32", + "decode", + "encode", + "rfc4648" + ], + "support": { + "issues": "https://github.com/ChristianRiesen/base32/issues", + "source": "https://github.com/ChristianRiesen/base32/tree/1.6.0" + }, + "time": "2021-02-26T10:19:33+00:00" + }, + { + "name": "claviska/simpleimage", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/claviska/SimpleImage.git", + "reference": "ec6d5021e5a7153a2520d64c59b86b6f3c4157c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/claviska/SimpleImage/zipball/ec6d5021e5a7153a2520d64c59b86b6f3c4157c5", + "reference": "ec6d5021e5a7153a2520d64c59b86b6f3c4157c5", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "league/color-extractor": "0.4.*", + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.5", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "autoload": { + "psr-0": { + "claviska": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cory LaViska", + "homepage": "http://www.abeautifulsite.net/", + "role": "Developer" + } + ], + "description": "A PHP class that makes working with images as simple as possible.", + "support": { + "issues": "https://github.com/claviska/SimpleImage/issues", + "source": "https://github.com/claviska/SimpleImage/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/claviska", + "type": "github" + } + ], + "time": "2024-11-22T13:25:03+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "getkirby/cms", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/getkirby/kirby.git", + "reference": "2278dae6b41879bd1a8ac70ae62a6ea781fc629a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getkirby/kirby/zipball/2278dae6b41879bd1a8ac70ae62a6ea781fc629a", + "reference": "2278dae6b41879bd1a8ac70ae62a6ea781fc629a", + "shasum": "" + }, + "require": { + "christian-riesen/base32": "1.6.0", + "claviska/simpleimage": "4.2.1", + "composer/semver": "3.4.4", + "ext-ctype": "*", + "ext-curl": "*", + "ext-dom": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "filp/whoops": "2.18.4", + "getkirby/composer-installer": "^1.2.1", + "laminas/laminas-escaper": "2.18.0", + "michelf/php-smartypants": "1.8.1", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "phpmailer/phpmailer": "6.10.0", + "symfony/polyfill-intl-idn": "1.33.0", + "symfony/polyfill-mbstring": "1.33.0", + "symfony/yaml": "7.3.5" + }, + "replace": { + "symfony/polyfill-php72": "*" + }, + "suggest": { + "ext-PDO": "Support for using databases", + "ext-apcu": "Support for the Apcu cache driver", + "ext-exif": "Support for exif information from images", + "ext-fileinfo": "Improved mime type detection for files", + "ext-imagick": "Improved thumbnail generation", + "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" + }, + "type": "kirby-cms", + "extra": { + "unused": [ + "symfony/polyfill-intl-idn" + ] + }, + "autoload": { + "files": [ + "config/setup.php", + "config/helpers.php" + ], + "psr-4": { + "Kirby\\": "src/" + }, + "classmap": [ + "dependencies/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "proprietary" + ], + "authors": [ + { + "name": "Kirby Team", + "email": "support@getkirby.com", + "homepage": "https://getkirby.com" + } + ], + "description": "The Kirby core", + "homepage": "https://getkirby.com", + "keywords": [ + "cms", + "core", + "kirby" + ], + "support": { + "email": "support@getkirby.com", + "forum": "https://forum.getkirby.com", + "issues": "https://github.com/getkirby/kirby/issues", + "source": "https://github.com/getkirby/kirby" + }, + "funding": [ + { + "url": "https://getkirby.com/buy", + "type": "custom" + } + ], + "time": "2025-11-18T10:47:26+00:00" + }, + { + "name": "getkirby/composer-installer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/getkirby/composer-installer.git", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^1.8 || ^2.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Kirby\\ComposerInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "Kirby\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins", + "homepage": "https://getkirby.com", + "support": { + "issues": "https://github.com/getkirby/composer-installer/issues", + "source": "https://github.com/getkirby/composer-installer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://getkirby.com/buy", + "type": "custom" + } + ], + "time": "2020-12-28T12:54:39+00:00" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-14T18:31:13+00:00" + }, + { + "name": "league/color-extractor", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/color-extractor.git", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/21fcac6249c5ef7d00eb83e128743ee6678fe505", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "php": "^7.3 || ^8.0" + }, + "replace": { + "matthecat/colorextractor": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\ColorExtractor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "description": "Extract colors from an image as a human would do.", + "homepage": "https://github.com/thephpleague/color-extractor", + "keywords": [ + "color", + "extract", + "human", + "image", + "palette" + ], + "support": { + "issues": "https://github.com/thephpleague/color-extractor/issues", + "source": "https://github.com/thephpleague/color-extractor/tree/0.4.0" + }, + "time": "2022-09-24T15:57:16+00:00" + }, + { + "name": "michelf/php-smartypants", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/michelf/php-smartypants.git", + "reference": "47d17c90a4dfd0ccf1f87e25c65e6c8012415aad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/michelf/php-smartypants/zipball/47d17c90a4dfd0ccf1f87e25c65e6c8012415aad", + "reference": "47d17c90a4dfd0ccf1f87e25c65e6c8012415aad", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Michelf": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Michel Fortin", + "email": "michel.fortin@michelf.ca", + "homepage": "https://michelf.ca/", + "role": "Developer" + }, + { + "name": "John Gruber", + "homepage": "https://daringfireball.net/" + } + ], + "description": "PHP SmartyPants", + "homepage": "https://michelf.ca/projects/php-smartypants/", + "keywords": [ + "dashes", + "quotes", + "spaces", + "typographer", + "typography" + ], + "support": { + "issues": "https://github.com/michelf/php-smartypants/issues", + "source": "https://github.com/michelf/php-smartypants/tree/1.8.1" + }, + "time": "2016-12-13T01:01:17+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "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.10.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2025-04-24T15:19:31+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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-09-27T09:00:46+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/public/content/error/error.txt b/public/content/error/error.txt new file mode 100644 index 0000000..b588b2a --- /dev/null +++ b/public/content/error/error.txt @@ -0,0 +1 @@ +Title: Error \ No newline at end of file diff --git a/public/content/home/home.txt b/public/content/home/home.txt new file mode 100644 index 0000000..02896ec --- /dev/null +++ b/public/content/home/home.txt @@ -0,0 +1 @@ +Title: Home \ No newline at end of file diff --git a/public/content/site.txt b/public/content/site.txt new file mode 100644 index 0000000..b1f98d7 --- /dev/null +++ b/public/content/site.txt @@ -0,0 +1 @@ +Title: Site Title \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..87ed01d --- /dev/null +++ b/public/index.php @@ -0,0 +1,5 @@ +render(); diff --git a/public/kirby/.editorconfig b/public/kirby/.editorconfig new file mode 100644 index 0000000..c487405 --- /dev/null +++ b/public/kirby/.editorconfig @@ -0,0 +1,32 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# PHP PSR-12 Coding Standards +# https://www.php-fig.org/psr/psr-12/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = tab +indent_size = 2 +trim_trailing_whitespace = true + +[*.php] +indent_size = 4 +insert_final_newline = true + +[*.vue.php] +indent_size = 2 +insert_final_newline = false + +[views/**/*.php] +indent_size = 2 +insert_final_newline = false + +[*.yml] +indent_style = space + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/public/kirby/CONTRIBUTING.md b/public/kirby/CONTRIBUTING.md new file mode 100644 index 0000000..a4c299a --- /dev/null +++ b/public/kirby/CONTRIBUTING.md @@ -0,0 +1,101 @@ +# Contributing + +:+1::tada: First off, yes, you can contribute and thanks already for taking the time if you do! :tada::+1: + +## How we organize code + +To keep track of different states of our code (current release, bugfixes, features) we use branches: + +| Branch | Used for | PRs allowed? | +| --------------- | ------------------------------------------------------------------------ | --------------------------- | +| `main` | Latest released version | ❌ | +| `develop-patch` | Working branch for next patch release, e.g. `4.0.x` | ✅ | +| `develop-minor` | Working branch for next minor release, e.g. `4.x.0` | ✅ | +| `v5/develop` | Working branch for next major release, e.g. `5.0.0` | ✅ | +| `fix/*` | Temporary branches for single bugfix | - | +| `feature/*` | Temporary branches for single feature | - | +| `release/*` | Pre-releases in testing before they are merged into `main` when released | only during release testing | + +We will review all pull requests (PRs) to `develop-patch`, `develop-minor` and `v5/develop` and merge them if accepted, once an appropriate version is upcoming. Please understand that this might not be the immediate next release and might take some time. + +## How you can contribute + +### Report a bug + +When you find a bug, the first step to fixing it is to help us understand and reproduce the bug as best as possible. When you create a bug report, please include as many details as possible. Fill out [the template](https://github.com/getkirby/kirby/issues/new?template=bug_report.md) because the requested information helps us resolve issues so much faster. + +### Bug fixes + +For bug fixes, please create a new branch following the name scheme: `fix/issue_number-bug-x`, e.g. `fix/234-this-nasty-bug`. Limit bug fix PRs to a single bug. **Do not mix multiple bug fixes in a single PR.** This will make it easier for us to review the fix and merge it. + +- Always send bug fix PRs against the `develop-patch` branch––not `main`. +- Add a helpful description of what the PR does if it is not 100% self-explanatory. +- Every bug fix should include a [unit test](#tests) to avoid future regressions. Let us know if you need help with that. +- Make sure your code [style](#style) matches ours and includes [comments/in-code documentation](#documentation). +- Make sure your branch is up to date with the latest state on the `develop-patch` branch. [Rebase](https://help.github.com/articles/about-pull-request-merges/) changes before you send the PR. +- Please *don't* commit updated dist files in the `panel/dist` folder to avoid merge conflicts. We only build the dist files on release. Your branch should only contain changes to the source files. + +### Features + +For features create a new branch following the name scheme: `feature/issue_number-feature-x`, e.g. `feature/123-awesome-function`. Our [feedback platform](https://feedback.getkirby.com) can be a good source of highly requested features. Maybe your feature idea already exists and you can get valuable feedback from other Kirby users. Focus on a single feature per PR. Don't mix features! + +- Always send feature PRs against the `develop-minor` branch––not `main`. +- Add a helpful description of what the PR does. +- New features should include [unit tests](#tests). Let us know if you need help with that. +- Make your code [style](#style) matches ours and includes [comments/in-code documentation](#documentation). +- Make sure your branch is up to date with the latest state on the `develop-minor` branch. [Rebase](https://help.github.com/articles/about-pull-request-merges/) changes before you send the PR. +- Please *don't* commit updated dist files in the `panel/dist` folder to avoid merge conflicts. We only build the dist files on release. Your branch should only contain changes to the source files. + +We try to bundle features in our major releases, e.g. `5.0`. That is why we might only review and, if accepted, merge your PR once an appropriate release is upcoming. Please understand that we cannot merge all feature ideas or that it might take a while. Check out the [roadmap](https://roadmap.getkirby.com) to see upcoming releases. + +### Translations + +We are really happy about any help with translations. Please do not directly translate JSON files, though. We use a service called Transifex to handle [all translations](https://translation.getkirby.com/). Create an account there and send us a request to join our translator group. Additionally, also send an email to . Unfortunately, we don't get notified properly about new translator requests. + +## How we write code + +### Style + +#### Backend (PHP) + +We use [PHP CS Fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer) to ensure a consistent style for our PHP code. It is mainly based on [PSR-12](https://www.php-fig.org/psr/psr-12/). [Install PHP CS Fixer globally](https://github.com/FriendsOfPHP/PHP-CS-Fixer#globally-composer) via Composer and then run `composer fix` in the `kirby` folder to check for inconsistencies and fix them. Our automated PR checks will fail if there are code style issues with your code. + +#### Frontend/Panel (JavaScript, Vue) + +We use [Prettier](https://prettier.io) to ensure a consistent style for our JavaScript and Vue code. After running `npm install` in the `kirby/panel` folder, you can run `npm run format` to check for inconsistencies and fix them. We also use [ESLint](https://eslint.org) which you can use by running `npm run lint` and/or `npm run lint:fix`. + +### Documentation + +In-code documentation and comments help us understand each other's code - or our own code after some months. Especially when matters get more complicated, we try to add a lot of comments to explain what the code does or why we implemented it like this. Even better than good comments is good code that is easy to understand. + +#### Backend (PHP) + +We use PHP [DocBlocks](https://docs.phpdoc.org/guide/references/phpdoc/basic-syntax.html#what-is-a-docblock) for classes and methods. + +#### Frontend/Panel (JavaScript, Vue) + +We use [JSDoc](https://jsdoc.app) for documenting JavaScript code, especially for [Vue components](https://vue-styleguidist.github.io/docs/Documenting.html). + +#### Public documentation + +We also document Kirby on the Kirby website at . However we recommend to wait with writing public documentation until the feature PR is merged. If you don't know where the documentation for a feature best belongs, don't worry. We can take care of writing the docs. + +### Tests + +Unit and integration tests help us prevent regressions when we make changes to the code. Every bug fix should also add a unit test for the fixed bug to make sure we won't re-introduce the same problem later down the road. Every new feature should be accompanied by unit tests to protect it from breaking through future changes. + +#### Backend (PHP) + +We use [PHPUnit](https://phpunit.de) for unit test for our PHP code. You can find all existing tests in the [`kirby/tests` subfolders](https://github.com/getkirby/kirby/tree/main/tests). Take a look to see how we usually structure our tests. + +#### Frontend/Panel (JavaScript, Vue) + +The Panel doesn't have extensive test coverage yet. That's an area we are still trying to improve. + +We use [vitest](https://vitest.dev) for unit tests for JavaScript and Vue components - `.test.js` files next to the actual JavaScript/Vue file. + +## And last… + +Let us know [in the forum](https://forum.getkirby.com) if you have questions. + +**And once more: thank you!** :+1::tada: diff --git a/public/kirby/LICENSE.md b/public/kirby/LICENSE.md new file mode 100644 index 0000000..27b4e75 --- /dev/null +++ b/public/kirby/LICENSE.md @@ -0,0 +1,308 @@ +# Kirby License Agreement + +Published: March 18, 2025 +Source: https://getkirby.com/license/2025-03-18 + +## About this Agreement + +While Kirby's source code is publicly available, Kirby is **not free**. To use Kirby in production, you need to [purchase a license](https://getkirby.com/buy). + +This End User License Agreement (the **"Agreement"**) is fundamental to the relationship between you and us. Therefore we recommend to read this Agreement carefully before you download, install or use Kirby. + +If you do not agree to this Agreement, please do not download, install or use Kirby. Installation or use of Kirby signifies that you have read, understood, and agreed to be bound by this Agreement. + +## Summary + +This section summarizes the most important conditions of this Agreement to give you a quick overview: + +- With your purchase you obtain a license. A license allows you to use Kirby according to this Agreement. +- Each project (defined by its URL) needs its own license. You need to purchase the right license for your project and/or client. You can find our license variants on . +- In some explicitly listed cases, you can use Kirby without having to purchase a license. In these cases, this Agreement grants you the license directly. There are also cases where you can request a free or discounted license from us. +- Each license includes any Kirby version that gets released within three years from the date when you first activated your license. We also provide free security updates for older versions that may protect your project beyond three years. +- After those three years, you can continue to use Kirby for your project with any of these versions as long as you want. +- To use any newer version released after this time, you will need to upgrade your license. +- Upgrading your license extends the timeframe for an additional three years during which you can use new releases. You can perform the upgrade at any time. +- You have the right to transfer or reassign a license to another person or project if needed. +- There are some restrictions for use of Kirby that you can find below. + +For the full license details, please read the Agreement in full. Only the following sections are legally binding. + +## Definitions + +Before we get started with the conditions of the Agreement, let's define the terms that will be used throughout it: + +- When we refer to **"You"**, we mean the licensee. Before purchasing Kirby, that's the individual or company that has downloaded and/or installed Kirby for a Development Installation or Private Installation. When used for a Public Site, the licensee is the individual or company that has purchased the Kirby license or received a free license from Us on request. If you work on a client project and have purchased the Kirby license for your client, you (and _not_ the client) are the licensee. +- When we refer to **"We"**/**"Us"**/**"Our"**, we mean the licensor, the Content Folder GmbH & Co. KG. You can find Our company and contact information on Our [contact page](https://getkirby.com/contact). +- **"Client"** refers to the individual or company that is primarily responsible for and benefits from the Website, unless they are the licensee. You might create or work on the Website on behalf of the Client, either directly or through other intermediaries (e.g. as a freelancer for an agency that works on a client website). +- A **"Website"** is a single Kirby project that is defined by its domain name and root directory (e.g. `https://sub.example.com` or `https://example.com/example/`). Each (sub)domain and root directory is a separate Website, even if the projects are related in any way. Exception: If You use the cross-domain multi-language feature with the same `content` folder, these domains count as the same Website. + You may use Kirby as a headless backend or as a static site generator. In these cases the Website is defined by the domain and root directory of the user- or visitor-facing frontend(s). +- A **"Development Installation"** is a Website that is installed purely for the purposes of development and client preview. It must only be accessible by a restricted number of users (like on a personal computer, on a server in a network with restricted access or when protecting a staging website with a password that only a restricted number of users know). +- A **"Private Installation"** is a Website that is installed purely for personal use. It must only be accessible by You and Your family. +- A **"Public Site"** is a Website that is _neither_ a Development Installation nor a Private Installation. +- A **"Minor Release"** is a stable Kirby release which adds smaller new features, minor functionality enhancements or bug fixes. This class of release is identified by the change of the revision to the right of the first decimal point, e.g. 4.1 to 4.2, 4.X.1 to 4.X.2. +- A **"Major Release"** is a stable Kirby release which incorporates major new features or enhancements that increase or change the core functionality of Kirby to a larger extent. It may also deprecate existing parts of the Source Code or change them in a breaking way. This class of release is identified by the change of the revision to the left of the first decimal point, e.g. 4.X to 5.0. +- A **"Major Generation"** is defined as all releases that share the revision to the left of the first decimal point, e.g. 4.0.0, 4.0.X, 4.X.0 and 4.X.X. +- The **"Source Code"** is defined as the contents of all files that are provided with Kirby and that make Kirby work. This includes (but is not limited to) all PHP, JavaScript, JSON, HTML and CSS files as well as all related image and other media files. +- The **"MIT-licensed Source Code Parts"** are defined as the parts of the Source Code for which a file header or code comment inside the same source file explicitly states the applicability of the MIT license. +- The **"Activation Date"** determines the included updates. It is defined like this: + - For a newly purchased or granted license, it is the date when the license was first activated for use with a Public Site. + - When You upgrade an already activated license, it is the date on which the upgrade was performed in Our [license hub](https://hub.getkirby.com). If the license is still within the Included Updates Period, the Activation Date of the upgrade license will be set to the end of the Included Updates Period of the existing license. + - When You upgrade a license that had _not_ been activated before, the upgrade license adopts the unactivated state of the existing license. The Activation Date is set on first activation for use with a Public Site. +- The **"Included Updates Period"** is the time span of three (3) years after the Activation Date. +- Licensees (You), Clients and Websites are **"Qualified"** if they satisfy the purchase requirements from the ["Order Process" section](#order-process) of this Agreement. + +Every time you see one of these capitalized terms in the following text, it has the meaning that has been explained above. + +## Usage for a Public Site + +Installing Kirby on or using it for a Public Site requires a [paid license](https://getkirby.com/buy). Once a paid license is needed, the license must be immediately activated to the Public Site’s domain name and root directory via our [license hub](https://hub.getkirby.com) or the activation feature in the Kirby Panel. + +As Kirby is software and software is intangible, We don't sell it as such. Instead, this Agreement grants a license for each purchase to install and use a single instance of Kirby on a **specific Website**. Additional Kirby licenses must be purchased in order to install and use Kirby on **additional Websites**. + +The license is **non-exclusive** (meaning that You are not the only one to whom We will issue a license) and **generally non-transferable** (meaning that the one who purchases the license is the licensee). + +On request, We will **transfer** a license to anyone who would be allowed and Qualified to purchase the license by law and this Agreement. The new licensee will take over all rights and obligations of this Agreement from You at the moment We confirm the license transfer. + +We will also **reassign** a license to another Qualified Website domain and root directory, if You confirm that the previous Website is no longer in operation and will not be operated with the same license in the future. + +If the new licensee, Website or Client in a transfer or reassignment is not Qualified for the existing license, You or the new licensee need to **upgrade the license to the qualifying terms and conditions** before the transfer or reassignment can be performed. + +> [!NOTE] +> If you need to transfer your Kirby license to another individual or company (for example to your client or a new agency) or reassign it to a different project, please get in touch directly at . + +A license is valid for all Major Releases that We publish before the end of the Included Updates Period. It is also valid for all releases in those Major Generations independent of their release date. Whether a release is a Minor Release or Major Release is at Our sole discretion. + +The use of releases in Major Generations that We publish after the Included Updates Period requires a **paid license upgrade**. An upgrade license replaces the existing license. + +## Order Process + +Our order process is conducted by Our online reseller [Paddle.com](https://paddle.com). Paddle.com is the Merchant of Record for all Our orders. Paddle provides all customer service inquiries and handles returns. + +When purchasing a license, You are **responsible to choose the right license** based on You and the Website project. Different license variants can come with certain requirements towards You and/or the Website project. We publish all such requirements on in a way that makes them clearly visible before the purchase. With Your purchase, You confirm that You and the Website project qualify for the selected license variant. + +If the Website is created for a Client, You need to make sure that the **Client qualifies for the selected license**. + +If You purchase licenses **in advance**, You need to ensure to only use the license(s) for projects that satisfy the requirements for the selected license variant(s). + +We **reserve the right to verify** at any time after the purchase whether You, the Website and (if applicable) the Client are Qualified. Changes to the situation of You, the Website or the Client as well as changes to the published information on Our "Buy" page after the purchase or after the assignment to a Client do _not_ take effect on an existing license unless You upgrade the license or We transfer or reassign the license on Your request. + +## Free Licenses + +Kirby can be used **for free in the following cases**. + +> [!NOTE] +> Please note that the restrictions and all other clauses of this Agreement also apply to free licenses. You may especially _not_ alter or circumvent the licensing features. + +### Usage for a Development Installation + +We believe that it should be possible to test and evaluate software before having to purchase a license. Also, We understand that a web project first needs to be built in a protected environment before it can be published. + +Therefore, installing and using Kirby on a personal computer (like a desktop PC, notebook or tablet) or server for a Development Installation is **free** for as long as You need. If You have already purchased a license, You do *not* need to activate it to the development domain(s) of the project. + +> [!NOTE] +> The usage of Kirby in production (with the intention to handle production data or content) is _never_ considered a Development Installation, even in internal apps or systems. + +### Usage for a Private Installation + +You may also install and use Kirby for **free** in Private Installations as long as they are not accessible by anyone except You and Your family. + +> [!NOTE] +> Our [definition](#definitions) of a Private Installation allows the following use cases: +> +> - Private sites for personal use, for example: +> - Apps for You personally (like a personal diary) +> - Apps for You as a freelancer (like a bookkeeping, invoicing or project management app) +> - Apps for Your family (like a private photo gallery) +> - Experimental local Kirby setups for Your personal use (for example to try out Kirby features) +> +> However, the following use cases are _not_ covered and need a **[paid license](#usage-for-a-public-site)**: +> +> - Intranets for companies, authorities or organizations, no matter if on a local or public server +> - (Internal) apps for teams or entire companies, authorities or organizations +> - Websites that are accessible by the public, even for personal/non-commercial purposes +> - Use of Kirby as a local CMS for a static or headless site without a license for the frontend domain(s) + +### Free Licenses on Request + +We provide free or discounted licenses for specific purposes: + +- students, +- selected educational projects, social and environmental organizations, charities and non-profits with insufficient funding and +- demo sites showcasing a Kirby extension (plugin or theme). + +Unlike licenses for Development Installations or Private Installations, these free or discounted licenses are not granted by this Agreement directly. You need to request them from Us via email to . Any discounts or free licenses are at Our sole discretion. + +Should We grant a free or discounted license under the terms of this section, You will receive a license code for use with a Public Site. The [section "Usage for a Public Site"](#usage-for-a-public-site) as well as all other terms of this Agreement apply with the following modifications: + +- We will only transfer or reassign free or discounted licenses if the new licensee or project qualifies for the free or discounted license at Our sole discretion. +- You may upgrade free or discounted licenses to future Major Generations like paid licenses. We will grant free or discounted upgrades under the terms of this section. + +## Restrictions + +### Legal Restrictions + +You may only use Kirby in a manner that complies with any and all **applicable laws** in the jurisdictions in which You use Kirby. Please respect all applicable restrictions concerning **privacy and intellectual property rights**. + +### Making Copies + +You may make **copies of Kirby** in any machine readable form solely for the **following purposes**, provided that You reproduce Kirby in its original form and with all proprietary notices on the copy: + +- when deploying a Website to a server, +- when developing a Website on a personal computer or server, +- when working on code contributions to Kirby or +- as a backup. + +You may _not_ reproduce Kirby or its Source Code, in whole or in part, for **any other purpose**, except if granted below. + +### Modification of the Source Code + +You may **alter, modify or extend the Source Code** for Your own use or with the intention to contribute Your changes back to Kirby. You may also **commission a third party** to perform those modifications for You. + +However You may _not_: + +- **alter or circumvent the licensing features**, including (but not limited to) the license validation and payment prompts, +- **remove or alter any proprietary notices** on Kirby or +- **resell, redistribute or transfer** the modified or derivative version. + +> [!NOTE] +> Please note that We **can't provide technical support** for modified or derivative versions of the Source Code. + +### Your Relationship to Third Parties + +You are generally _not_ allowed to **sell, assign, license, disclose, distribute, or otherwise transfer or make available** Kirby or its Source Code, in whole or in part, in any form to any third parties. + +The following cases are exempted from this restriction: + +- Kirby licenses may be transferred to a new licensee by requesting the transfer from Us ([see above](#usage-for-a-public-site)). +- You may create Websites for third parties (e.g. as an agency or freelancer for a client). Together with this Website, You may bill Your client for the used Kirby license. You may also include the license price in a flat rate. Please note that the licensee in both of these cases is still You unless You request to transfer the license to Your client. If Your price exceeds the price You paid to Us, You need to give Your client the option to purchase the license directly from Us. +- You may make Kirby available to customers via a Software-as-a-Service (SaaS) offering, provided You ensure that each Website has a valid Kirby license purchased either by You or Your customer. If multiple customers share a Website, each customer needs at least one license. Your offering _must not_ appear to be provided or officially endorsed by Us. +- You may make a Kirby installation available to employees or partners of You or Your Website client. You may also disclose and distribute Kirby’s Source Code to Your client together with the source code of the Website You created for them. +- You may disclose the Source Code to individuals or companies that are involved in the development or operation of Your Website (e.g. agencies, design or development freelancers, hosting providers or administrators). +- You may disclose, distribute and make available extracted parts of the Source Code according to the conditions of the following section "Extraction of Source Code parts". + +> [!NOTE] +> E.g. the following cases are explicitly **_not_ allowed**: +> +> - Selling, licensing or distributing a new product based on Kirby that modifies or hides Kirby’s identity as a Content Management System (CMS) +> - Forking Kirby and selling the modified version ([see above](#modification-of-the-source-code)) +> - Buying licenses in bulk and reselling them in your own shop +> - Bundling or including Kirby’s full Source Code in the publication and/or distribution of a Website’s source code or a (free or paid) theme or plugin (please use Git submodules or Composer or provide a link to Our repository or website instead) + +### Extraction of Source Code parts + +We provide the Source Code as a complete unit for use according to this Agreement. + +Reuse of code parts in other programs or projects is _only_ permitted in the cases stated in the following sections. You may _not_ extract the Source Code or parts of the Source Code in any other way or for any other purpose. + +#### Extraction of MIT-licensed Source Code Parts + +Permission is hereby granted, free of charge, to any person obtaining a copy of MIT-licensed Source Code Parts, to deal in the MIT-licensed Source Code Parts without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the MIT-licensed Source Code Parts, and to permit persons to whom the MIT-licensed Source Code Parts is furnished to do so, subject to the following conditions: + +The copyright notice to Bastian Allgeier and this permission notice shall be included in all copies or substantial portions of the MIT-licensed Source Code Parts. + +THE MIT-LICENSED SOURCE CODE PARTS ARE PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE MIT-LICENSED SOURCE CODE PARTS OR THE USE OR OTHER DEALINGS IN THE MIT-LICENSED SOURCE CODE PARTS. + +#### Extraction for Extensions + +You may extract, use, disclose and distribute copies or adapted copies of individual parts of the Source Code (such as component templates), including those not licensed under the terms of the MIT license but excluding those covered by third-party licenses, if both of the following conditions are met: + +- You use the code parts in or disclose/distribute them as part of an extension (plugin or theme) that You solely intend to be used with Kirby. +- Every copied or adapted code part carries a clear note that references these conditions as well as Our copyright and this Agreement. You may use the following example as a template: + ``` + /** + * The following code was copied or adapted from the source code of + * Kirby CMS and may not be used outside of licensed Kirby projects. + * + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ + ``` + +### Disallowed Uses + +The following uses of Kirby are _not_ covered by this Agreement and will result in the termination of the license: + +- Direct or indirect use of Kirby in **critical infrastructure** (e.g. water and energy services, public health, financial services, public security services) or **high-risk environments** (e.g. handling of harmful or dangerous materials). The use in Websites without connection to core processes is allowed. +- Use of Kirby for Websites that contain **misinformation, hate speech or discriminating content** based on age, gender, gender identity, race, sexuality, religion, nationality, serious illnesses or disabilities, no matter who authored this content. Misinformation is defined as content that is false or misleading and may lead to significant risk of physical or societal harm. +- Use of Kirby by **companies or individuals who**: + - lobby for, promote, derive a majority of income from or are significantly invested in: + - the production of tobacco or weapons, + - any prison or jail operated for profit, + - any action or facility that supports or contributes to: + - gambling, adversely addictive behaviours or + - deforestation. + - lobby against, or derive a majority of income from actions that discourage or frustrate: + - peace, + - access to the rights set out in the Universal Declaration of Human Rights and the Convention on the Rights of the Child, + - peaceful assembly and association (including worker associations), + - a safe environment or action to curtail the use of fossil fuels or to prevent climate change or + - democratic processes. + +## Technical Support + +Technical support is **provided as described on Our website** at . **No representations or guarantees** are made regarding the response time in which support questions are answered, however We will do Our best to respond quickly. + +For each Major Generation, We aim to provide **security support for three (3) years** after the Major Release. Security support means that We will provide free security updates for the supported releases, which will include fixes for security vulnerabilities according to the following rules: + - We published a security advisory on within the respective security support period. We will publish vulnerabilities on this page as soon as they are known to Us and an official fix for any supported release is available. + - The latest release of the supported Major Generation is affected by the vulnerability. + +With each vulnerability, We aim to publish the security advisory and security updates for all supported Major Generations at the same time. + +> [!NOTE] +> You can find up-to-date information on our currently supported versions in our [public security policy](https://getkirby.com/security). + +We reserve the right to **limit technical support for free licenses**. + +## Refund Policy + +We offer a **14-day**, money back refund policy if Kirby didn't work out for Your project. + +> [!NOTE] +> If you need a refund, please get in touch directly at . + +## No Warranty + +KIRBY IS OFFERED ON AN **"AS-IS" BASIS** AND **NO WARRANTY**, EITHER EXPRESSED OR IMPLIED, IS GIVEN. WE EXPRESSLY DISCLAIM ALL WARRANTIES OF ANY KIND, WHETHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. YOU ASSUME ALL RISK ASSOCIATED WITH THE QUALITY, PERFORMANCE, INSTALLATION AND USE OF KIRBY INCLUDING, BUT NOT LIMITED TO, THE RISKS OF PROGRAM ERRORS, DAMAGE TO EQUIPMENT, LOSS OF DATA OR SOFTWARE PROGRAMS, OR UNAVAILABILITY OR INTERRUPTION OF OPERATIONS. **YOU ARE SOLELY RESPONSIBLE** FOR DETERMINING THE APPROPRIATENESS OF USE OF KIRBY AND ASSUME ALL RISKS ASSOCIATED WITH ITS USE. THIS PARAGRAPH ALSO APPLIES TO YOU IF YOU ARE NOT THE LICENSEE (E.G. IF YOU USE KIRBY WHILE SOMEONE ELSE IS THE LICENSEE). + +## Term, Termination and Modification + +You may **indefinitely use** all Kirby versions that are covered by Your license under this Agreement until either party **terminates this Agreement** as described in this section. + +**You** may terminate the Agreement at any time. + +**We** may only terminate the Agreement if You or any individual or company who works with Kirby or uses it on Your behalf has violated or failed to comply with terms of this Agreement. If Your compliance with the Agreement can be restored by fixing the violation or non-compliance, We will first contact You with information on the specific term that was violated or not complied with and will allow reasonable time of at least 14 days before We will decide on a license termination. Should We be in the position to terminate a license according to this paragraph, other or all licenses granted to You may be terminated for the same reason(s) at the same time or at any later date. + +Termination takes effect upon notice to the other party in textual form (via email or letter). Upon termination, the specified **licenses granted to You will terminate**, and You will **immediately uninstall and cease all use** of Kirby. If not all licenses are terminated, You may continue to use Kirby for the Websites with active licenses. The sections entitled "No Warranty", "Indemnification" and "Limitation of Liability" will **survive any termination** of this Agreement. + +We may **modify Kirby and this Agreement** with notice to You either via email or by publishing content on the Kirby website at https://getkirby.com, including but not limited to changing the functionality or appearance of Kirby. Any such modification will **become binding on You** unless You terminate this Agreement. Changes to this Agreement that constrain Your rights to a great extent will only become effective with Your approval in textual or electronic form. + +## Indemnification + +By accepting the Agreement, you **agree to indemnify and otherwise hold harmless** Us as well as Our officers, employees, agents, subsidiaries, affiliates and other partners from any direct, indirect, incidental, special, consequential or exemplary damages arising out of, relating to, or resulting from your use of Kirby or any other matter relating to Kirby. This paragraph also applies to you if you are not the licensee (e.g. if you use Kirby while someone else is the licensee). + +## Limitation of Liability + +YOU EXPRESSLY UNDERSTAND AND AGREE THAT **WE SHALL NOT BE LIABLE** FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR EXEMPLARY DAMAGES, INCLUDING BUT NOT LIMITED TO, DAMAGES FOR LOSS OF PROFITS, GOODWILL, USE, DATA OR OTHER INTANGIBLE LOSSES (EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES). SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF THE LIMITATION OR EXCLUSION OF LIABILITY FOR INCIDENTAL OR CONSEQUENTIAL DAMAGES. ACCORDINGLY, **SOME OF THE ABOVE LIMITATIONS MAY NOT APPLY TO YOU**. **IN NO EVENT WILL OUR TOTAL CUMULATIVE DAMAGES EXCEED** THE FEES YOU PAID TO US UNDER THIS AGREEMENT IN THE MOST RECENT TWELVE-MONTH PERIOD. THIS PARAGRAPH ALSO APPLIES TO YOU IF YOU ARE NOT THE LICENSEE (E.G. IF YOU USE KIRBY WHILE SOMEONE ELSE IS THE LICENSEE). + +## All Rights Reserved + +Bastian Allgeier **owns all rights**, title and interest to Kirby (including all intellectual property rights) and **reserves all rights to Kirby** that are not expressly granted in this Agreement. + +In the event that Kirby will no longer be actively maintained, Bastian Allgeier will provide the Source Code under the terms of a free and open source software (FOSS) license as far as legally and contractually possible. + +## Applicable Law & Place of Jurisdiction + +1. For all disputes arising out of or in connection with this Agreement, the courts competent for Neckargemünd, Germany, shall have exclusive jurisdiction. However, We shall have the choice to file lawsuits against You before the courts competent for Your place of business. +2. If You reside in Germany, para. 1 shall only apply if You are a merchant, a legal entity under public law or a special fund under public law. +3. If You don't reside in Germany, but in a different member state of the European Union, para. 1 shall only apply if You are not a consumer under Art. 17 of the regulation (EU) No. 1215/2012. In that case, You shall be entitled to file actions against Us either at Our place of business or at the courts competent at the place where You usually reside. We, on the other hand, are only entitled to bring proceedings against You in the courts of the Member State in which You are domiciled. +4. If You neither reside in Germany nor in a member state of the EU, the applicability of para. 1 remains unaffected. + +## Severability Clause + +Should any provision of this Agreement be or become invalid, void or unenforceable, in whole or in part, at present or in the future, this shall not affect the validity of the remaining provisions of this Agreement. The same shall apply if a gap requiring supplementation arises after conclusion of this Agreement. The parties shall replace the invalid, void or unenforceable provision or gap requiring filling by a valid provision which in its legal or economic content takes account of the invalid, void provision and the overall content of the agreement. § Section 139 of the German Civil Code (partial invalidity) is expressly waived. + +## Questions? + +Due to Kirby's flexibility, you may have special use cases or requirements that don't fit this Agreement. + +If that's the case or if you have any questions, feel free to [get in touch](mailto:support@getkirby.com). We are happy to think outside the box and find custom license solutions for your creative application of Kirby. diff --git a/public/kirby/README.md b/public/kirby/README.md new file mode 100644 index 0000000..284fd6d --- /dev/null +++ b/public/kirby/README.md @@ -0,0 +1,49 @@ +[](https://getkirby.com) + +[![Release](https://img.shields.io/github/v/release/getkirby/kirby)](https://github.com/getkirby/kirby/releases/latest) +[![CI Status](https://img.shields.io/github/actions/workflow/status/getkirby/kirby/ci.yml?branch=main&label=CI)](https://github.com/getkirby/kirby/actions?query=workflow%3ACI+branch%3Amain) +[![Coverage Status](https://img.shields.io/codecov/c/gh/getkirby/kirby?token=ROZ2RVA0OF)](https://codecov.io/gh/getkirby/kirby) +[![Downloads](https://img.shields.io/packagist/dt/getkirby/cms?color=red)](https://github.com/getkirby/kirby/releases/latest) + +**Kirby: the CMS that adapts to any project, loved by developers and editors alike.** +With Kirby, you build your own ideal interface. Combine forms, galleries, articles, spreadsheets and more into an amazing editing experience. You can learn more about Kirby at [getkirby.com](https://getkirby.com). + +This is Kirby's core application folder. Get started with one of the following repositories instead: + +- [Starterkit](https://github.com/getkirby/starterkit) +- [Plainkit](https://github.com/getkirby/plainkit) + + + +### Try Kirby for free + +Kirby is not free software. However, you can try Kirby and the Starterkit on your local machine or on a test server as long as you need to make sure it is the right tool for your next project. … and when you’re convinced, [buy your license](https://getkirby.com/buy). + +### Contribute + +**Found a bug?** +Please post all bugs as individual reports in our [issue tracker](https://github.com/getkirby/kirby/issues). + +**Suggest a feature** +If you have ideas for a feature or enhancement for Kirby, please use our [feedback platform](https://feedback.getkirby.com). + +**Translations, bug fixes, code contributions ...** +Read about how to contribute to the development in our [contributing guide](/CONTRIBUTING.md). + +## What's Kirby? + +- **[getkirby.com](https://getkirby.com)** – Get to know the CMS. +- **[Try it](https://getkirby.com/try)** – Take a test ride with our online demo. Or download one of our kits to get started. +- **[Documentation](https://getkirby.com/docs/guide)** – Read the official guide, reference and cookbook recipes. +- **[Issues](https://github.com/getkirby/kirby/issues)** – Report bugs and other problems. +- **[Feedback](https://feedback.getkirby.com)** – You have an idea for Kirby? Share it. +- **[Forum](https://forum.getkirby.com)** – Whenever you get stuck, don't hesitate to reach out for questions and support. +- **[Discord](https://chat.getkirby.com)** – Hang out and meet the community. +- **[YouTube](https://youtube.com/kirbyCasts)** - Watch the latest video tutorials visually with Bastian. +- **[Mastodon](https://mastodon.social/@getkirby)** – Spread the word. +- **[Bluesky](https://bsky.app/profile/getkirby.com)** – Tell a friend. + +--- + +© 2009 Bastian Allgeier +[getkirby.com](https://getkirby.com) · [License agreement](https://getkirby.com/license) diff --git a/public/kirby/SECURITY.md b/public/kirby/SECURITY.md new file mode 100644 index 0000000..ffc6ad0 --- /dev/null +++ b/public/kirby/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported versions and past security incidents + +You can find up-to-date information on the security status of each version on . + +## Security of your Kirby site + +We have a detailed [security guide](https://getkirby.com/docs/guide/security) with information on how to keep your Kirby installation secure. + +## Reporting a vulnerability + +If you have spotted a vulnerability in Kirby's core or the Panel, please make sure to let us know immediately. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. + +You can always contact us directly at ****. +If you want to encrypt your message, our GPG key is [6E6B 057A F491 FFAD 363F 6F49 9101 10FA A459 E120](https://getkirby.com/pgp.asc). + +You can also use the [security advisory form on GitHub](https://github.com/getkirby/kirby/security/advisories/new) to securely and privately report a vulnerability to us. + +We will send you a response as soon as possible and will keep you informed on our progress towards a fix and announcement. + +> [!IMPORTANT] +> Please do not write to us publicly, e.g. in the forum, on Discord or in a GitHub issue. A public report can give attackers valuable time to exploit the issue before it is fixed. +> +> By letting us know directly and coordinating the disclosure with us, you can help to protect other Kirby users from such attacks. +> +> Also please do *not* request a CVE ID from organizations like MITRE. The responsible CVE Numbering Authority (CNA) for Kirby is GitHub. We can and will request a CVE ID for each confirmed vulnerability and will provide it to you in advance of the coordinated release. diff --git a/public/kirby/assets/whoops.css b/public/kirby/assets/whoops.css new file mode 100644 index 0000000..bd35464 --- /dev/null +++ b/public/kirby/assets/whoops.css @@ -0,0 +1,83 @@ +body { + background: #efefef; + font: normal normal 400 12px/1.5 -apple-system, BlinkMacSystemFont, Segoe UI, + Roboto, Helvetica, Arial, sans-serif; +} + +.left-panel { + background: transparent; +} + +header { + background-color: #313740; +} + +.exc-title-primary { + color: hsl(0, 71%, 55%); +} + +.frame.active { + color: hsl(0, 71%, 55%); + box-shadow: inset -5px 0 0 0 #d16464; +} + +.frame:not(.active):hover { + background: rgba(203, 215, 229, 0.5); +} + +.rightButton { + color: #999; + box-shadow: inset 0 0 0 1px #777; + border-radius: 0; +} + +.rightButton:hover { + box-shadow: inset 0 0 0 1px #555; + color: #777; +} + +.details-heading { + color: #7e9abf; + font-weight: 500; +} + +.frame-code { + background: #000; +} + +pre.code-block, +code.code-block, +.frame-args.code-block, +.frame-args.code-block samp { + background: #16171a; +} + +.linenums li.current { + background: transparent; +} + +.linenums li.current.active { + background: rgba(209, 100, 100, 0.3); +} + +pre .atv, +code .atv, +pre .str, +code .str { + color: #a7bd68; +} + +pre .tag, +code .tag { + color: #d16464; +} + +pre .kwd, +code .kwd { + color: #8abeb7; +} + +pre .atn, +code .atn { + color: #de935f; +} diff --git a/public/kirby/bootstrap.php b/public/kirby/bootstrap.php new file mode 100644 index 0000000..70f27ce --- /dev/null +++ b/public/kirby/bootstrap.php @@ -0,0 +1,35 @@ +=') === false || + version_compare(PHP_VERSION, '8.5.0', '<') === false +) { + die(include __DIR__ . '/views/php.php'); +} + +if (is_file($autoloader = dirname(__DIR__) . '/vendor/autoload.php')) { + /** + * Always prefer a site-wide Composer autoloader + * if it exists, it means that the user has probably + * installed additional packages + * + * @psalm-suppress MissingFile + */ + include $autoloader; +} elseif (is_file($autoloader = __DIR__ . '/vendor/autoload.php')) { + /** + * Fall back to the local autoloader if that exists + * + * @psalm-suppress MissingFile + */ + include $autoloader; +} +/** + * 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 new file mode 100644 index 0000000..5d325ac --- /dev/null +++ b/public/kirby/cacert.pem @@ -0,0 +1,3601 @@ +## +## Bundle of CA Root Certificates +## +## Certificate data from Mozilla as of: Tue Nov 4 04:12:02 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://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 +## an Apache+mod_ssl webserver for SSL client authentication. +## Just configure this file as the SSLCACertificateFile. +## +## Conversion done with mk-ca-bundle.pl version 1.29. +## SHA256: 039132bff5179ce57cec5803ba59fe37abe6d0297aeb538c5af27847f0702517 +## + + +Entrust Root Certification Authority +==================================== +-----BEGIN CERTIFICATE----- +MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV +BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw +b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG +A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0 +MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu +MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu +Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v +dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz +A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww +Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68 +j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN +rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1 +MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH +hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA +A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM +Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa +v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS +W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0 +tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8 +-----END CERTIFICATE----- + +QuoVadis Root CA 2 +================== +-----BEGIN CERTIFICATE----- +MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT +EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMjAeFw0wNjExMjQx +ODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCaGMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6 +XJxgFyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55JWpzmM+Yk +lvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bBrrcCaoF6qUWD4gXmuVbB +lDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp+ARz8un+XJiM9XOva7R+zdRcAitMOeGy +lZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt +66/3FsvbzSUr5R/7mp/iUcw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1Jdxn +wQ5hYIizPtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og/zOh +D7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UHoycR7hYQe7xFSkyy +BNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuIyV77zGHcizN300QyNQliBJIWENie +J0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1Ud +DgQWBBQahGK8SEwzJQTU7tD2A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGU +a6FJpEcwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT +ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2fBluornFdLwUv +Z+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzng/iN/Ae42l9NLmeyhP3ZRPx3 +UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2BlfF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodm +VjB3pjd4M1IQWK4/YY7yarHvGH5KWWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK ++JDSV6IZUaUtl0HaB0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrW +IozchLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPRTUIZ3Ph1 +WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWDmbA4CD/pXvk1B+TJYm5X +f6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0ZohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II +4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8 +VCLAAVBpQ570su9t+Oza8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u +-----END CERTIFICATE----- + +QuoVadis Root CA 3 +================== +-----BEGIN CERTIFICATE----- +MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT +EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMzAeFw0wNjExMjQx +OTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM +aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDMV0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNgg +DhoB4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUrH556VOij +KTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd8lyyBTNvijbO0BNO/79K +DDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9CabwvvWhDFlaJKjdhkf2mrk7AyxRllDdLkgbv +BNDInIjbC3uBr7E9KsRlOni27tyAsdLTmZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwp +p5ijJUMv7/FfJuGITfhebtfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8 +nT8KKdjcT5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDtWAEX +MJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZc6tsgLjoC2SToJyM +Gf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A4iLItLRkT9a6fUg+qGkM17uGcclz +uD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYDVR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHT +BgkrBgEEAb5YAAMwgcUwgZMGCCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmlj +YXRlIGNvbnN0aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0 +aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVudC4wLQYIKwYB +BQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2NwczALBgNVHQ8EBAMCAQYwHQYD +VR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4GA1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4 +ywLQoUmkRzBFMQswCQYDVQQGEwJCTTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UE +AxMSUXVvVmFkaXMgUm9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZV +qyM07ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSemd1o417+s +hvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd+LJ2w/w4E6oM3kJpK27z +POuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2 +Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadNt54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp +8kokUvd0/bpO5qgdAm6xDYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBC +bjPsMZ57k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6szHXu +g/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0jWy10QJLZYxkNc91p +vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr +qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto= +-----END CERTIFICATE----- + +DigiCert Assured ID Root CA +=========================== +-----BEGIN CERTIFICATE----- +MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw +IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzEx +MTEwMDAwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0Ew +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7cJpSIqvTO +9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYPmDI2dsze3Tyoou9q+yHy +UmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW +/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpy +oeb6pNnVFzF1roV9Iq4/AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whf +GHdPAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRF +66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzANBgkq +hkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRCdWKuh+vy1dneVrOfzM4UKLkNl2Bc +EkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTffwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38Fn +SbNd67IJKusm7Xi+fT8r87cmNW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i +8b5QZ7dsvfPxH2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe ++o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== +-----END CERTIFICATE----- + +DigiCert Global Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw +HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAw +MDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 +dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsBCSDMAZOn +TjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97nh6Vfe63SKMI2tavegw5 +BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt43C/dxC//AH2hdmoRBBYMql1GNXRor5H +4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y +7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQAB +o2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm +8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEF +BQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmr +EbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIt +tep3Sp+dWOIrWcBAI+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886 +UAb3LujEV0lsYSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk +CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= +-----END CERTIFICATE----- + +DigiCert High Assurance EV Root CA +================================== +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSsw +KQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAw +MFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ +MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFu +Y2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0t +Mqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMS +OO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3 +MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQ +NAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUe +h10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSY +JhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQ +V8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFp +myPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkK +mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe +vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K +-----END CERTIFICATE----- + +SwissSign Gold CA - G2 +====================== +-----BEGIN CERTIFICATE----- +MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNIMRUw +EwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2lnbiBHb2xkIENBIC0gRzIwHhcN +MDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBFMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dp +c3NTaWduIEFHMR8wHQYDVQQDExZTd2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUq +t2/876LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+bbqBHH5C +jCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c6bM8K8vzARO/Ws/BtQpg +vd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqEemA8atufK+ze3gE/bk3lUIbLtK/tREDF +ylqM2tIrfKjuvqblCqoOpd8FUrdVxyJdMmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvR +AiTysybUa9oEVeXBCsdtMDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuend +jIj3o02yMszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69yFGkO +peUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPiaG59je883WX0XaxR +7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxMgI93e2CaHt+28kgeDrpOVG2Y4OGi +GqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw +AwEB/zAdBgNVHQ4EFgQUWyV7lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64 +OfPAeGZe6Drn8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov +L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe645R88a7A3hfm +5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczOUYrHUDFu4Up+GC9pWbY9ZIEr +44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOf +Mke6UiI0HTJ6CVanfCU2qT1L2sCCbwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6m +Gu6uLftIdxf+u+yvGPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxp +mo/a77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCChdiDyyJk +vC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid392qgQmwLOM7XdVAyksLf +KzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEppLd6leNcG2mqeSz53OiATIgHQv2ieY2Br +NU0LbbqhPcCT4H8js1WtciVORvnSFu+wZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6Lqj +viOvrv1vA+ACOzB2+httQc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ +-----END CERTIFICATE----- + +SecureTrust CA +============== +-----BEGIN CERTIFICATE----- +MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQG +EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xFzAVBgNVBAMTDlNlY3VyZVRy +dXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIzMTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAe +BgNVBAoTF1NlY3VyZVRydXN0IENvcnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQX +OZEzZum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO0gMdA+9t +DWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIaowW8xQmxSPmjL8xk037uH +GFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b +01k/unK8RCSc43Oz969XL0Imnal0ugBS8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmH +ursCAwEAAaOBnTCBmjATBgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCegJYYj +aHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ +KoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt36Z3q059c4EVlew3KW+JwULKUBRSu +SceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHf +mbx8IVQr5Fiiu1cprp6poxkmD5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZ +nMUFdAvnZyPSCPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR +3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= +-----END CERTIFICATE----- + +Secure Global CA +================ +-----BEGIN CERTIFICATE----- +MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQG +EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBH +bG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkxMjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEg +MB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwg +Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jx +YDiJiQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa/FHtaMbQ +bqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJjnIFHovdRIWCQtBJwB1g +8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnIHmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYV +HDGA76oYa8J719rO+TMg1fW9ajMtgQT7sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi +0XPnj3pDAgMBAAGjgZ0wgZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCswKaAn +oCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsGAQQBgjcVAQQDAgEA +MA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0LURYD7xh8yOOvaliTFGCRsoTciE6+ +OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXOH0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cn +CDpOGR86p1hcF895P4vkp9MmI50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/5 +3CYNv6ZHdAbYiNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc +f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW +-----END CERTIFICATE----- + +COMODO Certification Authority +============================== +-----BEGIN CERTIFICATE----- +MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCBgTELMAkGA1UE +BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG +A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNVBAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1 +dGhvcml0eTAeFw0wNjEyMDEwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEb +MBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFD +T01PRE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3UcEbVASY06m/weaKXTuH ++7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI2GqGd0S7WWaXUF601CxwRM/aN5VCaTww +xHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV +4EajcNxo2f8ESIl33rXp+2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA +1KGzqSX+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5OnKVI +rLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW/zAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6gPKA6hjhodHRwOi8vY3JsLmNvbW9k +b2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOC +AQEAPpiem/Yb6dc5t3iuHXIYSdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CP +OGEIqB6BCsAvIC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/ +RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4zJVSk/BwJVmc +IGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5ddBA6+C4OmF4O5MBKgxTMVBbkN ++8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IBZQ== +-----END CERTIFICATE----- + +COMODO ECC Certification Authority +================================== +-----BEGIN CERTIFICATE----- +MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTELMAkGA1UEBhMC +R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE +ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwHhcNMDgwMzA2MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0Ix +GzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR +Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRo +b3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSRFtSrYpn1PlILBs5BAH+X +4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0JcfRK9ChQtP6IHG4/bC8vCVlbpVsLM5ni +wz2J+Wos77LTBumjQjBAMB0GA1UdDgQWBBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8E +BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VG +FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA +U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= +-----END CERTIFICATE----- + +Certigna +======== +-----BEGIN CERTIFICATE----- +MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNVBAYTAkZSMRIw +EAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4XDTA3MDYyOTE1MTMwNVoXDTI3 +MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwI +Q2VydGlnbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7q +XOEm7RFHYeGifBZ4QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyH +GxnygQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbwzBfsV1/p +ogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q130yGLMLLGq/jj8UEYkg +DncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKf +Irjxwo1p3Po6WAbfAgMBAAGjgbwwgbkwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQ +tCRZvgHyUtVF9lo53BEwZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJ +BgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzjAQ/J +SP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG9w0BAQUFAAOCAQEA +hQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8hbV6lUmPOEvjvKtpv6zf+EwLHyzs+ +ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFncfca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1klu +PBS1xp81HlDQwY9qcEQCYsuuHWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY +1gkIl2PlwS6wt0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw +WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== +-----END CERTIFICATE----- + +ePKI Root Certification Authority +================================= +-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBeMQswCQYDVQQG +EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xKjAoBgNVBAsMIWVQS0kg +Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMx +MjdaMF4xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEq +MCgGA1UECwwhZVBLSSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAHSyZbCUNs +IZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAhijHyl3SJCRImHJ7K2RKi +lTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3XDZoTM1PRYfl61dd4s5oz9wCGzh1NlDiv +qOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX +12ruOzjjK9SXDrkb5wdJfzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0O +WQqraffAsgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uUWH1+ +ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLSnT0IFaUQAS2zMnao +lQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pHdmX2Os+PYhcZewoozRrSgx4hxyy/ +vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJipNiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXi +Zo1jDiVN1Rmy5nk3pyKdVDECAwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/Qkqi +MAwGA1UdEwQFMAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH +ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGBuvl2ICO1J2B0 +1GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6YlPwZpVnPDimZI+ymBV3QGypzq +KOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkPJXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdV +xrsStZf0X4OFunHB2WyBEXYKCrC/gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEP +NXubrjlpC2JgQCA2j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+r +GNm65ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUBo2M3IUxE +xJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS/jQ6fbjpKdx2qcgw+BRx +gMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2zGp1iro2C6pSe3VkQw63d4k3jMdXH7Ojy +sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD +BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw= +-----END CERTIFICATE----- + +certSIGN ROOT CA +================ +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYTAlJPMREwDwYD +VQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTAeFw0wNjA3MDQxNzIwMDRa +Fw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UE +CxMQY2VydFNJR04gUk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7I +JUqOtdu0KBuqV5Do0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHH +rfAQUySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5dRdY4zTW2 +ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQOA7+j0xbm0bqQfWwCHTD +0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwvJoIQ4uNllAoEwF73XVv4EOLQunpL+943 +AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B +Af8EBAMCAcYwHQYDVR0OBBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IB +AQA+0hyJLjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecYMnQ8 +SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ44gx+FkagQnIl6Z0 +x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6IJd1hJyMctTEHBDa0GpC9oHRxUIlt +vBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNwi/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7Nz +TogVZ96edhBiIL5VaZVDADlN9u6wWk5JRFRYX0KD +-----END CERTIFICATE----- + +NetLock Arany (Class Gold) Főtanúsítvány +======================================== +-----BEGIN CERTIFICATE----- +MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQGEwJIVTERMA8G +A1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3MDUGA1UECwwuVGFuw7pzw610 +dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBB +cmFueSAoQ2xhc3MgR29sZCkgRsWRdGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgx +MjA2MTUwODIxWjCBpzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxO +ZXRMb2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlmaWNhdGlv +biBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNzIEdvbGQpIEbFkXRhbsO6 +c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCRec75LbRTDofTjl5Bu +0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrTlF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw +/HpYzY6b7cNGbIRwXdrzAZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAk +H3B5r9s5VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRGILdw +fzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2BJtr+UBdADTHLpl1 +neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2MU9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwW +qZw8UQCgwBEIBaeZ5m8BiFRhbvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTta +YtOUZcTh5m2C+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC +bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2FuLjbvrW5Kfna +NwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2XjG4Kvte9nHfRCaexOYNkbQu +dZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= +-----END CERTIFICATE----- + +Microsec e-Szigno Root CA 2009 +============================== +-----BEGIN CERTIFICATE----- +MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYDVQQGEwJIVTER +MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jv +c2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o +dTAeFw0wOTA2MTYxMTMwMThaFw0yOTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UE +BwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUt +U3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvPkd6mJviZpWNwrZuuyjNA +fW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tccbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG +0IMZfcChEhyVbUr02MelTTMuhTlAdX4UfIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKA +pxn1ntxVUwOXewdI/5n7N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm +1HxdrtbCxkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1+rUC +AwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTLD8bf +QkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAbBgNVHREE +FDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqGSIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0o +lZMEyL/azXm4Q5DwpL7v8u8hmLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfX +I/OMn74dseGkddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 +tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c2Pm2G2JwCz02 +yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5tHMN1Rq41Bab2XD0h7lbwyYIi +LXpUq3DDfSJlgnCW +-----END CERTIFICATE----- + +GlobalSign Root CA - R3 +======================= +-----BEGIN CERTIFICATE----- +MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xv +YmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh +bFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT +aWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln +bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWt +iHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ +0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3 +rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjl +OCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2 +xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7 +lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8 +EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1E +bddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18 +YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7r +kpeDMdmztcpHWD9f +-----END CERTIFICATE----- + +Izenpe.com +========== +-----BEGIN CERTIFICATE----- +MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4MQswCQYDVQQG +EwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wHhcNMDcxMjEz +MTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMu +QS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ +03rKDx6sp4boFmVqscIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAK +ClaOxdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6HLmYRY2xU ++zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFXuaOKmMPsOzTFlUFpfnXC +PCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQDyCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxT +OTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbK +F7jJeodWLBoBHmy+E60QrLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK +0GqfvEyNBjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8Lhij+ +0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIBQFqNeb+Lz0vPqhbB +leStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+HMh3/1uaD7euBUbl8agW7EekFwID +AQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2luZm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+ +SVpFTlBFIFMuQS4gLSBDSUYgQTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBG +NjIgUzgxQzBBBgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx +MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O +BBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUAA4ICAQB4pgwWSp9MiDrAyw6l +Fn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWblaQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbga +kEyrkgPH7UIBzg/YsfqikuFgba56awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8q +hT/AQKM6WfxZSzwoJNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Cs +g1lwLDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCTVyvehQP5 +aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGkLhObNA5me0mrZJfQRsN5 +nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJbUjWumDqtujWTI6cfSN01RpiyEGjkpTHC +ClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZo +Q0iy2+tzJOeRf1SktoA+naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1Z +WrOZyGlsQyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== +-----END CERTIFICATE----- + +Go Daddy Root Certificate Authority - G2 +======================================== +-----BEGIN CERTIFICATE----- +MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMu +MTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 +MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 +b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8G +A1UEAxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq +9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD ++qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutd +fMh8+7ArU6SSYmlRJQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMl +NAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNC +MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqahQcQZyi27/a9 +BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096ac +vNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r +5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYV +N8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO +LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1 +-----END CERTIFICATE----- + +Starfield Root Certificate Authority - G2 +========================================= +-----BEGIN CERTIFICATE----- +MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s +b2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0 +eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAw +DgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQg +VGVjaG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBB +dXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3twQP89o/8ArFv +W59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdhl+72WoTs +bhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNk +N3mSwOxGXn/hbVNMYq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7Nf +ZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbU +JtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfol +TwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx +4mcujJUDJi5DnUox9g61DLu34jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUw +F5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K +pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZ +c2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 +-----END CERTIFICATE----- + +Starfield Services Root Certificate Authority - G2 +================================================== +-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgT +B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s +b2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRl +IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNV +BAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxT +dGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2VydmljZXMg +Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20pOsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2 +h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm28xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4Pa +hHQUw2eeBGg6345AWh1KTs9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLP +LJGmpufehRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk6mFB +rMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMA0GCSqG +SIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMIbw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPP +E95Dz+I0swSdHynVv/heyNXBve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTy +xQGjhdByPq1zqwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd +iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza +YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6 +-----END CERTIFICATE----- + +AffirmTrust Commercial +====================== +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UEBhMCVVMxFDAS +BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMB4XDTEw +MDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly +bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6Eqdb +DuKPHx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yrba0F8PrV +C8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPALMeIrJmqbTFeurCA+ukV6 +BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1yHp52UKqK39c/s4mT6NmgTWvRLpUHhww +MmWd5jyTXlBOeuM61G7MGvv50jeuJCqrVwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNV +HQ4EFgQUnZPGU4teyq8/nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYGXUPG +hi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNjvbz4YYCanrHOQnDi +qX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivtZ8SOyUOyXGsViQK8YvxO8rUzqrJv +0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9gN53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0kh +sUlHRUe072o0EclNmsxZt9YCnlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= +-----END CERTIFICATE----- + +AffirmTrust Networking +====================== +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCVVMxFDAS +BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMB4XDTEw +MDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly +bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SE +Hi3yYJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbuakCNrmreI +dIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRLQESxG9fhwoXA3hA/Pe24 +/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gb +h+0t+nvujArjqWaJGctB+d1ENmHP4ndGyH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNV +HQ4EFgQUBx/S55zawm6iQLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfOtDIu +UFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzuQY0x2+c06lkh1QF6 +12S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZLgo/bNjR9eUJtGxUAArgFU2HdW23 +WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4uolu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9 +/ZFvgrG+CJPbFEfxojfHRZ48x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s= +-----END CERTIFICATE----- + +AffirmTrust Premium +=================== +-----BEGIN CERTIFICATE----- +MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UEBhMCVVMxFDAS +BgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMB4XDTEwMDEy +OTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRy +dXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEAxBLfqV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtn +BKAQJG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ+jjeRFcV +5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrSs8PhaJyJ+HoAVt70VZVs ++7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmd +GPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d770O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5R +p9EixAqnOEhss/n/fauGV+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NI +S+LI+H+SqHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S5u04 +6uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4IaC1nEWTJ3s7xgaVY5 +/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TXOwF0lkLgAOIua+rF7nKsu7/+6qqo ++Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYEFJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB +/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByv +MiPIs0laUZx2KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg +Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B8OWycvpEgjNC +6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQMKSOyARiqcTtNd56l+0OOF6S +L5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK ++4w1IX2COPKpVJEZNZOUbWo6xbLQu4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmV +BtWVyuEklut89pMFu+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFg +IxpHYoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8GKa1qF60 +g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaORtGdFNrHF+QFlozEJLUb +zxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6eKeC2uAloGRwYQw== +-----END CERTIFICATE----- + +AffirmTrust Premium ECC +======================= +-----BEGIN CERTIFICATE----- +MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMCVVMxFDASBgNV +BAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQcmVtaXVtIEVDQzAeFw0xMDAx +MjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1U +cnVzdDEgMB4GA1UEAwwXQWZmaXJtVHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAAQNMF4bFZ0D0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQ +N8O9ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0GA1UdDgQW +BBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAK +BggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/VsaobgxCd05DhT1wV/GzTjxi+zygk8N53X +57hG8f2h4nECMEJZh0PUUd+60wkyWs6Iflc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKM +eQ== +-----END CERTIFICATE----- + +Certum Trusted Network CA +========================= +-----BEGIN CERTIFICATE----- +MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK +ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIy +MTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBU +ZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +MSIwIAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rHUV+rpDKmYYe2bg+G0jAC +l/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jUI4ZZJ88J +J7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4 +fOQtf/WsX+sWn7Et0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0 +cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8BAf8EBAMCAQYw +DQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCj +jSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1 +mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5aj +Zt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI +03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= +-----END CERTIFICATE----- + +TWCA Root Certification Authority +================================= +-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJ +VEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMzWhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQG +EwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NB +IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFEAcK0HMMx +QhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HHK3XLfJ+utdGdIzdjp9xC +oi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeXRfwZVzsrb+RH9JlF/h3x+JejiB03HFyP +4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/zrX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1r +y+UPizgN7gr8/g+YnzAx3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB +BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkqhkiG +9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeCMErJk/9q56YAf4lC +mtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdlsXebQ79NqZp4VKIV66IIArB6nCWlW +QtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62Dlhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVY +T0bf+215WfKEIlKuD8z7fDvnaspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocny +Yh0igzyXxfkZYiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw== +-----END CERTIFICATE----- + +Security Communication RootCA2 +============================== +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc +U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMeU2VjdXJpdHkgQ29tbXVuaWNh +dGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoXDTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMC +SlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3Vy +aXR5IENvbW11bmljYXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ANAVOVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGrzbl+dp++ ++T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVMVAX3NuRFg3sUZdbcDE3R +3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQhNBqyjoGADdH5H5XTz+L62e4iKrFvlNV +spHEfbmwhRkGeC7bYRr6hfVKkaHnFtWOojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1K +EOtOghY6rCcMU/Gt1SSwawNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8 +QIH4D5csOPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB +CwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpFcoJxDjrSzG+ntKEj +u/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXcokgfGT+Ok+vx+hfuzU7jBBJV1uXk +3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6q +tnRGEmyR7jTV7JqR50S+kDFy1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29 +mvVXIwAHIRc/SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 +-----END CERTIFICATE----- + +Actalis Authentication Root CA +============================== +-----BEGIN CERTIFICATE----- +MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCSVQxDjAM +BgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UE +AwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDky +MjExMjIwMlowazELMAkGA1UEBhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlz +IFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 +IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNvUTufClrJ +wkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX4ay8IMKx4INRimlNAJZa +by/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9KK3giq0itFZljoZUj5NDKd45RnijMCO6 +zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1f +YVEiVRvjRuPjPdA1YprbrxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2 +oxgkg4YQ51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2Fbe8l +EfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxeKF+w6D9Fz8+vm2/7 +hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4Fv6MGn8i1zeQf1xcGDXqVdFUNaBr8 +EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbnfpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5 +jF66CyCU3nuDuP/jVo23Eek7jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLY +iDrIn3hm7YnzezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt +ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQALe3KHwGCmSUyI +WOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70jsNjLiNmsGe+b7bAEzlgqqI0 +JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDzWochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKx +K3JCaKygvU5a2hi/a5iB0P2avl4VSM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+ +Xlff1ANATIGk0k9jpwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC +4yyXX04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+OkfcvHlXHo +2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7RK4X9p2jIugErsWx0Hbhz +lefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btUZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXem +OR/qnuOf0GZvBeyqdn6/axag67XH/JJULysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9 +vwGYT7JZVEc+NHt4bVaTLnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== +-----END CERTIFICATE----- + +Buypass Class 2 Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU +QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4X +DTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 +eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1 +g1Lr6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn +9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC911K2GScuVr1QGbNgGE41b +/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFOBz3cFIqU +CqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeff +awrbD02TTqigzXsu8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgI +zRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhn +Bkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/TizPJhk9H9Z2vX +Uq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHs +M+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF +AAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s +A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EI +osHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48S +aq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYd +DnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWD +LfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0 +oyLQI+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGC +wWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koS +CTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv4x3kqdbQCtCev9eBCfHJxyYN +rJgWVqA= +-----END CERTIFICATE----- + +Buypass Class 3 Root CA +======================= +-----BEGIN CERTIFICATE----- +MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU +QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMyBSb290IENBMB4X +DTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFowTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 +eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRH +sJ8YZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3EN3coTRiR +5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9tznDDgFHmV0ST9tD+leh +7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX0DJq1l1sDPGzbjniazEuOQAnFN44wOwZ +ZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH +2xc519woe2v1n/MuwU8XKhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV +/afmiSTYzIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvSO1UQ +RwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D34xFMFbG02SrZvPA +Xpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgPK9Dx2hzLabjKSWJtyNBjYt1gD1iq +j6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFEe4zf/lb+74suwvTg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF +AAOCAgEAACAjQTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV +cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXSIGrs/CIBKM+G +uIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2HJLw5QY33KbmkJs4j1xrG0aG +Q0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsaO5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8 +ZORK15FTAaggiG6cX0S5y2CBNOxv033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2 +KSb12tjE8nVhz36udmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz +6MkEkbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg413OEMXbug +UZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvDu79leNKGef9JOxqDDPDe +eOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq4/g7u9xN12TyUb7mqqta6THuBrxzvxNi +Cp/HuZc= +-----END CERTIFICATE----- + +T-TeleSec GlobalRoot Class 3 +============================ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM +IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU +cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgx +MDAxMTAyOTU2WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz +dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD +ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN8ELg63iIVl6bmlQdTQyK +9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/RLyTPWGrTs0NvvAgJ1gORH8EGoel15YU +NpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZF +iP0Zf3WHHx+xGwpzJFu5ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W +0eDrXltMEnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1A/d2O2GCahKqGFPr +AyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOyWL6ukK2YJ5f+AbGwUgC4TeQbIXQb +fsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzT +ucpH9sry9uetuUg/vBa3wW306gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7h +P0HHRwA11fXT91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml +e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4pTpPDpFQUWw== +-----END CERTIFICATE----- + +D-TRUST Root Class 3 CA 2 2009 +============================== +-----BEGIN CERTIFICATE----- +MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTAe +Fw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NThaME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxE +LVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIw +DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOAD +ER03UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42tSHKXzlA +BF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9RySPocq60vFYJfxLLHLGv +KZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsMlFqVlNpQmvH/pStmMaTJOKDfHR+4CS7z +p+hnUquVH+BGPtikw8paxTGA6Eian5Rp/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUC +AwEAAaOCARowggEWMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ +4PGEMA4GA1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVjdG9y +eS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUyMENBJTIwMiUyMDIw +MDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRlcmV2b2NhdGlvbmxpc3QwQ6BBoD+G +PWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAw +OS5jcmwwDQYJKoZIhvcNAQELBQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm +2H6NMLVwMeniacfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 +o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4KzCUqNQT4YJEV +dT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8PIWmawomDeCTmGCufsYkl4ph +X5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3YJohw1+qRzT65ysCQblrGXnRl11z+o+I= +-----END CERTIFICATE----- + +D-TRUST Root Class 3 CA 2 EV 2009 +================================= +-----BEGIN CERTIFICATE----- +MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw +OTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUwNDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK +DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw +OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfS +egpnljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM03TP1YtHh +zRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6ZqQTMFexgaDbtCHu39b+T +7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lRp75mpoo6Kr3HGrHhFPC+Oh25z1uxav60 +sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure35 +11H3a6UCAwEAAaOCASQwggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyv +cop9NteaHNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFwOi8v +ZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xhc3MlMjAzJTIwQ0El +MjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRp +b25saXN0MEagRKBChkBodHRwOi8vd3d3LmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xh +c3NfM19jYV8yX2V2XzIwMDkuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+ +PPoeUSbrh/Yp3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 +nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNFCSuGdXzfX2lX +ANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7naxpeG0ILD5EJt/rDiZE4OJudA +NCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqXKVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVv +w9y4AyHqnxbxLFS1 +-----END CERTIFICATE----- + +CA Disig Root R2 +================ +-----BEGIN CERTIFICATE----- +MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNVBAYTAlNLMRMw +EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMuMRkwFwYDVQQDExBDQSBEaXNp +ZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQyMDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sx +EzARBgNVBAcTCkJyYXRpc2xhdmExEzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERp +c2lnIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbC +w3OeNcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNHPWSb6Wia +xswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3Ix2ymrdMxp7zo5eFm1tL7 +A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbeQTg06ov80egEFGEtQX6sx3dOy1FU+16S +GBsEWmjGycT6txOgmLcRK7fWV8x8nhfRyyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqV +g8NTEQxzHQuyRpDRQjrOQG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa +5Beny912H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJQfYE +koopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUDi/ZnWejBBhG93c+A +Ak9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORsnLMOPReisjQS1n6yqEm70XooQL6i +Fh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNV +HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5u +Qu0wDQYJKoZIhvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM +tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqfGopTpti72TVV +sRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkblvdhuDvEK7Z4bLQjb/D907Je +dR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka+elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W8 +1k/BfDxujRNt+3vrMNDcTa/F1balTFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjx +mHHEt38OFdAlab0inSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01 +utI3gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18DrG5gPcFw0 +sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3OszMOl6W8KjptlwlCFtaOg +UxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8xL4ysEr3vQCj8KWefshNPZiTEUxnpHikV +7+ZtsH8tZ/3zbBt1RqPlShfppNcL +-----END CERTIFICATE----- + +ACCVRAIZ1 +========= +-----BEGIN CERTIFICATE----- +MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UEAwwJQUNDVlJB +SVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQswCQYDVQQGEwJFUzAeFw0xMTA1 +MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQBgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwH +UEtJQUNDVjENMAsGA1UECgwEQUNDVjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQCbqau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gM +jmoYHtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWoG2ioPej0 +RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpAlHPrzg5XPAOBOp0KoVdD +aaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhrIA8wKFSVf+DuzgpmndFALW4ir50awQUZ +0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDG +WuzndN9wrqODJerWx5eHk6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs7 +8yM2x/474KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMOm3WR +5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpacXpkatcnYGMN285J +9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPluUsXQA+xtrn13k/c4LOsOxFwYIRK +Q26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYIKwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRw +Oi8vd3d3LmFjY3YuZXMvZmlsZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEu +Y3J0MB8GCCsGAQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 +VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeTVfZW6oHlNsyM +Hj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIGCCsGAQUFBwICMIIBFB6CARAA +QQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUAcgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBh +AO0AegAgAGQAZQAgAGwAYQAgAEEAQwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUA +YwBuAG8AbABvAGcA7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBj +AHQAcgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAAQwBQAFMA +IABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUAczAwBggrBgEFBQcCARYk +aHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2MuaHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0 +dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRtaW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2 +MV9kZXIuY3JsMA4GA1UdDwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZI +hvcNAQEFBQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdpD70E +R9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gUJyCpZET/LtZ1qmxN +YEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+mAM/EKXMRNt6GGT6d7hmKG9Ww7Y49 +nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepDvV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJ +TS+xJlsndQAJxGJ3KQhfnlmstn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3 +sCPdK6jT2iWH7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h +I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szAh1xA2syVP1Xg +Nce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xFd3+YJ5oyXSrjhO7FmGYvliAd +3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2HpPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3p +EfbRD0tVNEYqi4Y7 +-----END CERTIFICATE----- + +TWCA Global Root CA +=================== +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcxEjAQBgNVBAoT +CVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMTVFdDQSBHbG9iYWwgUm9vdCBD +QTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQK +EwlUQUlXQU4tQ0ExEDAOBgNVBAsTB1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3Qg +Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2C +nJfF10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz0ALfUPZV +r2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfChMBwqoJimFb3u/Rk28OKR +Q4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbHzIh1HrtsBv+baz4X7GGqcXzGHaL3SekV +tTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1W +KKD+u4ZqyPpcC1jcxkt2yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99 +sy2sbZCilaLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYPoA/p +yJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQABDzfuBSO6N+pjWxn +kjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcEqYSjMq+u7msXi7Kx/mzhkIyIqJdI +zshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6g +cFGn90xHNcgL1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn +LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WFH6vPNOw/KP4M +8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNoRI2T9GRwoD2dKAXDOXC4Ynsg +/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlg +lPx4mI88k1HtQJAH32RjJMtOcQWh15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryP +A9gK8kxkRr05YuWW6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3m +i4TWnsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5jwa19hAM8 +EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3 +zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0= +-----END CERTIFICATE----- + +TeliaSonera Root CA v1 +====================== +-----BEGIN CERTIFICATE----- +MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAwNzEUMBIGA1UE +CgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJvb3QgQ0EgdjEwHhcNMDcxMDE4 +MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYDVQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwW +VGVsaWFTb25lcmEgUm9vdCBDQSB2MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+ +6yfwIaPzaSZVfp3FVRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA +3GV17CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+XZ75Ljo1k +B1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+/jXh7VB7qTCNGdMJjmhn +Xb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxH +oLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkmdtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3 +F0fUTPHSiXk+TT2YqGHeOh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJ +oWjiUIMusDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4pgd7 +gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fsslESl1MpWtTwEhDc +TwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQarMCpgKIv7NHfirZ1fpoeDVNAgMB +AAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qW +DNXr+nuqF+gTEjANBgkqhkiG9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNm +zqjMDfz1mgbldxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx +0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1TjTQpgcmLNkQfW +pb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBedY2gea+zDTYa4EzAvXUYNR0PV +G6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpc +c41teyWRyu5FrgZLAMzTsVlQ2jqIOylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOT +JsjrDNYmiLbAJM+7vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2 +qReWt88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcnHL/EVlP6 +Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVxSK236thZiNSQvxaz2ems +WWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= +-----END CERTIFICATE----- + +T-TeleSec GlobalRoot Class 2 +============================ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM +IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU +cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgx +MDAxMTA0MDE0WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz +dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD +ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUdAqSzm1nzHoqvNK38DcLZ +SBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiCFoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/F +vudocP05l03Sx5iRUKrERLMjfTlH6VJi1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx970 +2cu+fjOlbpSD8DT6IavqjnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGV +WOHAD3bZwI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGjQjBA +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/WSA2AHmgoCJrjNXy +YdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhyNsZt+U2e+iKo4YFWz827n+qrkRk4 +r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPACuvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNf +vNoBYimipidx5joifsFvHZVwIEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR +3p1m0IvVVGb6g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN +9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlPBSeOE6Fuwg== +-----END CERTIFICATE----- + +Atos TrustedRoot 2011 +===================== +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UEAwwVQXRvcyBU +cnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0xMTA3MDcxNDU4 +MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMMFUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsG +A1UECgwEQXRvczELMAkGA1UEBhMCREUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV +hTuXbyo7LjvPpvMpNb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr +54rMVD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+SZFhyBH+ +DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ4J7sVaE3IqKHBAUsR320 +HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0Lcp2AMBYHlT8oDv3FdU9T1nSatCQujgKR +z3bFmx5VdJx4IbHwLfELn8LVlhgf8FQieowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7R +l+lwrrw7GWzbITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZ +bNshMBgGA1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8jvZfza1zv7v1Apt+h +k6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kPDpFrdRbhIfzYJsdHt6bPWHJxfrrh +TZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pcmaHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a9 +61qn8FYiqTxlVMYVqL2Gns2Dlmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G +3mB/ufNPRJLvKrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed +-----END CERTIFICATE----- + +QuoVadis Root CA 1 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakE +PBtVwedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWerNrwU8lm +PNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF34168Xfuw6cwI2H44g4hWf6 +Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh4Pw5qlPafX7PGglTvF0FBM+hSo+LdoIN +ofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXpUhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/l +g6AnhF4EwfWQvTA9xO+oabw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV +7qJZjqlc3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/GKubX +9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSthfbZxbGL0eUQMk1f +iyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KOTk0k+17kBL5yG6YnLUlamXrXXAkg +t3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOtzCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZI +hvcNAQELBQADggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC +MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2cDMT/uFPpiN3 +GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUNqXsCHKnQO18LwIE6PWThv6ct +Tr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP ++V04ikkwj+3x6xn0dxoxGE1nVGwvb2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh +3jRJjehZrJ3ydlo28hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fa +wx/kNSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNjZgKAvQU6 +O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhpq1467HxpvMc7hU6eFbm0 +FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFtnh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOV +hMJKzRwuJIczYOXD +-----END CERTIFICATE----- + +QuoVadis Root CA 2 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFh +ZiFfqq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMWn4rjyduY +NM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ymc5GQYaYDFCDy54ejiK2t +oIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+o +MiwMzAkd056OXbxMmO7FGmh77FOm6RQ1o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+l +V0POKa2Mq1W/xPtbAd0jIaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZo +L1NesNKqIcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz8eQQ +sSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43ehvNURG3YBZwjgQQvD +6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l7ZizlWNof/k19N+IxWA1ksB8aRxh +lRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALGcC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZI +hvcNAQELBQADggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 +AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RCroijQ1h5fq7K +pVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0GaW/ZZGYjeVYg3UQt4XAoeo0L9 +x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4nlv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgz +dWqTHBLmYF5vHX/JHyPLhGGfHoJE+V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6X +U/IyAgkwo1jwDQHVcsaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+Nw +mNtddbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNgKCLjsZWD +zYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeMHVOyToV7BjjHLPj4sHKN +JeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4WSr2Rz0ZiC3oheGe7IUIarFsNMkd7Egr +O3jtZsSOeWmD3n+M +-----END CERTIFICATE----- + +QuoVadis Root CA 3 G3 +===================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQELBQAwSDELMAkG +A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv +b3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJN +MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMg +RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286 +IxSR/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNuFoM7pmRL +Mon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXRU7Ox7sWTaYI+FrUoRqHe +6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+cra1AdHkrAj80//ogaX3T7mH1urPnMNA3 +I4ZyYUUpSFlob3emLoG+B01vr87ERRORFHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3U +VDmrJqMz6nWB2i3ND0/kA9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f7 +5li59wzweyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634RylsSqi +Md5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBpVzgeAVuNVejH38DM +dyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0QA4XN8f+MFrXBsj6IbGB/kE+V9/Yt +rQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZI +hvcNAQELBQADggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px +KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnIFUBhynLWcKzS +t/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5WvvoxXqA/4Ti2Tk08HS6IT7SdEQ +TXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFgu/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9Du +DcpmvJRPpq3t/O5jrFc/ZSXPsoaP0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGib +Ih6BJpsQBJFxwAYf3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmD +hPbl8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+DhcI00iX +0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HNPlopNLk9hM6xZdRZkZFW +dSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ywaZWWDYWGWVjUTR939+J399roD1B0y2 +PpxxVJkES/1Y+Zj0 +-----END CERTIFICATE----- + +DigiCert Assured ID Root G2 +=========================== +-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw +IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgw +MTE1MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSAn61UQbVH +35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4HteccbiJVMWWXvdMX0h5i89vq +bFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9HpEgjAALAcKxHad3A2m67OeYfcgnDmCXRw +VWmvo2ifv922ebPynXApVfSr/5Vh88lAbx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OP +YLfykqGxvYmJHzDNw6YuYjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+Rn +lTGNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTO +w0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPIQW5pJ6d1Ee88hjZv +0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I0jJmwYrA8y8678Dj1JGG0VDjA9tz +d29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4GnilmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAW +hsI6yLETcDbYz+70CjTVW0z9B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0M +jomZmWzwPDCvON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo +IhNzbM8m9Yop5w== +-----END CERTIFICATE----- + +DigiCert Assured ID Root G3 +=========================== +-----BEGIN CERTIFICATE----- +MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYD +VQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 +MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQ +BgcqhkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJfZn4f5dwb +RXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17QRSAPWXYQ1qAk8C3eNvJs +KTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgF +UaFNN6KDec6NHSrkhDAKBggqhkjOPQQDAwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5Fy +YZ5eEJJZVrmDxxDnOOlYJjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy +1vUhZscv6pZjamVFkpUBtA== +-----END CERTIFICATE----- + +DigiCert Global Root G2 +======================= +-----BEGIN CERTIFICATE----- +MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw +HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUx +MjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 +dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJ +kTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO +3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hdG82MHauV +BJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyM +UNGPHgm+F6HmIcr9g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQAB +o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu +5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9PMWLSn/pvtsr +F9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0U +WTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBH +QRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/ +iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl +MrY= +-----END CERTIFICATE----- + +DigiCert Global Root G3 +======================= +-----BEGIN CERTIFICATE----- +MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQswCQYDVQQGEwJV +UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYD +VQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAw +MDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5k +aWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FGfp4tn+6O +YwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPOZ9wj/wMco+I+o0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNp +Yim8S8YwCgYIKoZIzj0EAwMDaAAwZQIxAK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y +3maTD/HMsQmP3Wyr+mt/oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34 +VOKa5Vt8sycX +-----END CERTIFICATE----- + +DigiCert Trusted Root G4 +======================== +-----BEGIN CERTIFICATE----- +MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG +EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEw +HwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 +MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0G +CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEp +pz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9o +k3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7Fsa +vOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY +QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6 +MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtm +mnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7 +f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFH +dL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8 +oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud +DwEB/wQEAwIBhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD +ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY +ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdNOj6PWTkiU0Tr +yF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy +7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iah +ixTXTBmyUEFxPT9NcCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN +5r5N0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb +/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP0oUA51Aa +5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tK +G48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP +82Z+ +-----END CERTIFICATE----- + +COMODO RSA Certification Authority +================================== +-----BEGIN CERTIFICATE----- +MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCBhTELMAkGA1UE +BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG +A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwHhcNMTAwMTE5MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMC +R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE +ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR6FSS0gpWsawNJN3Fz0Rn +dJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8Xpz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZ +FGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+ +5eNu/Nio5JIk2kNrYrhV/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pG +x8cgoLEfZd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z+pUX +2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7wqP/0uK3pN/u6uPQL +OvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZahSL0896+1DSJMwBGB7FY79tOi4lu3 +sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVICu9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+C +GCe01a60y1Dma/RMhnEw6abfFobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5 +WdYgGq/yapiqcrxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E +FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvlwFTPoCWOAvn9sKIN9SCYPBMt +rFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+ +nq6PK7o9mfjYcwlYRm6mnPTXJ9OV2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSg +tZx8jb8uk2IntznaFxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwW +sRqZCuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiKboHGhfKp +pC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmckejkk9u+UJueBPSZI9FoJA +zMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yLS0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHq +ZJx64SIDqZxubw5lT2yHh17zbqD5daWbQOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk52 +7RH89elWsn2/x20Kk4yl0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7I +LaZRfyHBNVOFBkpdn627G190 +-----END CERTIFICATE----- + +USERTrust RSA Certification Authority +===================================== +-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK +ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UE +BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK +ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh +dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCAEmUXNg7D2wiz +0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2j +Y0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFn +RghRy4YUVD+8M/5+bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O ++T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq +/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Plfg6lZrEpfDKE +Y1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJM +lXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8 +yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+ +eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd +BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeW +FPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ +7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQ +Eg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM +8WcRiQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGi +FSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdi +yA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9c +J2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw +sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gx +Q+6IHdfGjjxDah2nGN59PRbxYvnKkKj9 +-----END CERTIFICATE----- + +USERTrust ECC Certification Authority +===================================== +-----BEGIN CERTIFICATE----- +MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU +aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqfloI+d61SRvU8Za2EurxtW2 +0eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinngo4N+LZfQYcTxmdwlkWOrfzCjtHDix6Ez +nPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNV +HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBB +HU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu +9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= +-----END CERTIFICATE----- + +GlobalSign ECC Root CA - R5 +=========================== +-----BEGIN CERTIFICATE----- +MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb +R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD +EwpHbG9iYWxTaWduMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6 +SFkc8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8kehOvRnkmS +h5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYIKoZIzj0EAwMDaAAwZQIxAOVpEslu28Yx +uglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7 +yFz9SO8NdCKoCOJuxUnOxwy8p2Fp8fc74SrL+SvzZpA3 +-----END CERTIFICATE----- + +IdenTrust Commercial Root CA 1 +============================== +-----BEGIN CERTIFICATE----- +MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBKMQswCQYDVQQG +EwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBS +b290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQwMTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzES +MBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENB +IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ld +hNlT3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU+ehcCuz/ +mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gpS0l4PJNgiCL8mdo2yMKi +1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1bVoE/c40yiTcdCMbXTMTEl3EASX2MN0C +XZ/g1Ue9tOsbobtJSdifWwLziuQkkORiT0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl +3ZBWzvurpWCdxJ35UrCLvYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzy +NeVJSQjKVsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZKdHzV +WYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHTc+XvvqDtMwt0viAg +xGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hvl7yTmvmcEpB4eoCHFddydJxVdHix +uuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5NiGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZI +hvcNAQELBQADggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH +6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwtLRvM7Kqas6pg +ghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93nAbowacYXVKV7cndJZ5t+qnt +ozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3+wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmV +YjzlVYA211QC//G5Xc7UI2/YRYRKW2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUX +feu+h1sXIFRRk0pTAwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/ro +kTLql1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG4iZZRHUe +2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZmUlO+KWA2yUPHGNiiskz +Z2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7R +cGzM7vRX+Bi6hG6H +-----END CERTIFICATE----- + +IdenTrust Public Sector Root CA 1 +================================= +-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQG +EwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3Rv +ciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcNMzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJV +UzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBS +b290IENBIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTy +P4o7ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGyRBb06tD6 +Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlSbdsHyo+1W/CD80/HLaXI +rcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF/YTLNiCBWS2ab21ISGHKTN9T0a9SvESf +qy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoS +mJxZZoY+rfGwyj4GD3vwEUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFn +ol57plzy9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9VGxyh +LrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ2fjXctscvG29ZV/v +iDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsVWaFHVCkugyhfHMKiq3IXAAaOReyL +4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gDW/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8B +Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMw +DQYJKoZIhvcNAQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj +t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHVDRDtfULAj+7A +mgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9TaDKQGXSc3z1i9kKlT/YPyNt +GtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8GlwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFt +m6/n6J91eEyrRjuazr8FGF1NFTwWmhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMx +NRF4eKLg6TCMf4DfWN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4 +Mhn5+bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJtshquDDI +ajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhAGaQdp/lLQzfcaFpPz+vC +ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ +3Wl9af0AVqW3rLatt8o+Ae+c +-----END CERTIFICATE----- + +Entrust Root Certification Authority - G2 +========================================= +-----BEGIN CERTIFICATE----- +MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMCVVMxFjAUBgNV +BAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVy +bXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ug +b25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIw +HhcNMDkwNzA3MTcyNTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoT +DUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMx +OTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25s +eTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP +/vaCeb9zYQYKpSfYs1/TRU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXz +HHfV1IWNcCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hWwcKU +s/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1U1+cPvQXLOZprE4y +TGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0jaWvYkxN4FisZDQSA/i2jZRjJKRx +AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ6 +0B7vfec7aVHUbI2fkBJmqzANBgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5Z +iXMRrEPR9RP/jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ +Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v1fN2D807iDgi +nWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4RnAuknZoh8/CbCzB428Hch0P+ +vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmHVHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xO +e4pIb4tF9g== +-----END CERTIFICATE----- + +Entrust Root Certification Authority - EC1 +========================================== +-----BEGIN CERTIFICATE----- +MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkGA1UEBhMCVVMx +FjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVn +YWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXpl +ZCB1c2Ugb25seTEzMDEGA1UEAxMqRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 +IC0gRUMxMB4XDTEyMTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYw +FAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2Fs +LXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQg +dXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt +IEVDMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHy +AsWfoPZb1YsGGYZPUxBtByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef +9eNi1KlHBz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVCR98crlOZF7ZvHH3h +vxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nXhTcGtXsI/esni0qU+eH6p44mCOh8 +kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G +-----END CERTIFICATE----- + +CFCA EV ROOT +============ +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIEGErM1jANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJDTjEwMC4GA1UE +CgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQDDAxDRkNB +IEVWIFJPT1QwHhcNMTIwODA4MDMwNzAxWhcNMjkxMjMxMDMwNzAxWjBWMQswCQYDVQQGEwJDTjEw +MC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQD +DAxDRkNBIEVWIFJPT1QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDXXWvNED8fBVnV +BU03sQ7smCuOFR36k0sXgiFxEFLXUWRwFsJVaU2OFW2fvwwbwuCjZ9YMrM8irq93VCpLTIpTUnrD +7i7es3ElweldPe6hL6P3KjzJIx1qqx2hp/Hz7KDVRM8Vz3IvHWOX6Jn5/ZOkVIBMUtRSqy5J35DN +uF++P96hyk0g1CXohClTt7GIH//62pCfCqktQT+x8Rgp7hZZLDRJGqgG16iI0gNyejLi6mhNbiyW +ZXvKWfry4t3uMCz7zEasxGPrb382KzRzEpR/38wmnvFyXVBlWY9ps4deMm/DGIq1lY+wejfeWkU7 +xzbh72fROdOXW3NiGUgthxwG+3SYIElz8AXSG7Ggo7cbcNOIabla1jj0Ytwli3i/+Oh+uFzJlU9f +py25IGvPa931DfSCt/SyZi4QKPaXWnuWFo8BGS1sbn85WAZkgwGDg8NNkt0yxoekN+kWzqotaK8K +gWU6cMGbrU1tVMoqLUuFG7OA5nBFDWteNfB/O7ic5ARwiRIlk9oKmSJgamNgTnYGmE69g60dWIol +hdLHZR4tjsbftsbhf4oEIRUpdPA+nJCdDC7xij5aqgwJHsfVPKPtl8MeNPo4+QgO48BdK4PRVmrJ +tqhUUy54Mmc9gn900PvhtgVguXDbjgv5E1hvcWAQUhC5wUEJ73IfZzF4/5YFjQIDAQABo2MwYTAf +BgNVHSMEGDAWgBTj/i39KNALtbq2osS/BqoFjJP7LzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB +/wQEAwIBBjAdBgNVHQ4EFgQU4/4t/SjQC7W6tqLEvwaqBYyT+y8wDQYJKoZIhvcNAQELBQADggIB +ACXGumvrh8vegjmWPfBEp2uEcwPenStPuiB/vHiyz5ewG5zz13ku9Ui20vsXiObTej/tUxPQ4i9q +ecsAIyjmHjdXNYmEwnZPNDatZ8POQQaIxffu2Bq41gt/UP+TqhdLjOztUmCypAbqTuv0axn96/Ua +4CUqmtzHQTb3yHQFhDmVOdYLO6Qn+gjYXB74BGBSESgoA//vU2YApUo0FmZ8/Qmkrp5nGm9BC2sG +E5uPhnEFtC+NiWYzKXZUmhH4J/qyP5Hgzg0b8zAarb8iXRvTvyUFTeGSGn+ZnzxEk8rUQElsgIfX +BDrDMlI1Dlb4pd19xIsNER9Tyx6yF7Zod1rg1MvIB671Oi6ON7fQAUtDKXeMOZePglr4UeWJoBjn +aH9dCi77o0cOPaYjesYBx4/IXr9tgFa+iiS6M+qf4TIRnvHST4D2G0CvOJ4RUHlzEhLN5mydLIhy +PDCBBpEi6lmt2hkuIsKNuYyH4Ga8cyNfIWRjgEj1oDwYPZTISEEdQLpe/v5WOaHIz16eGWRGENoX +kbcFgKyLmZJ956LYBws2J+dIeWCKw9cTXPhyQN9Ky8+ZAAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3C +ekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su +-----END CERTIFICATE----- + +OISTE WISeKey Global Root GB CA +=============================== +-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBtMQswCQYDVQQG +EwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl +ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAw +MzJaFw0zOTEyMDExNTEwMzFaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYD +VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEds +b2JhbCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3HEokKtaX +scriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGxWuR51jIjK+FTzJlFXHtP +rby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk +9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNku7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4o +Qnc/nSMbsrY9gBQHTC5P99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvg +GUpuuy9rM2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI +hvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrghcViXfa43FK8+5/ea4n32cZiZBKpD +dHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0 +VQreUGdNZtGn//3ZwLWoo4rOZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEui +HZeeevJuQHHfaPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic +Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= +-----END CERTIFICATE----- + +SZAFIR ROOT CA2 +=============== +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQELBQAwUTELMAkG +A1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6ZW5pb3dhIFMuQS4xGDAWBgNV +BAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkwNzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJ +BgNVBAYTAlBMMSgwJgYDVQQKDB9LcmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYD +VQQDDA9TWkFGSVIgUk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5Q +qEvNQLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT3PSQ1hNK +DJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw3gAeqDRHu5rr/gsUvTaE +2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr63fE9biCloBK0TXC5ztdyO4mTp4CEHCdJ +ckm1/zuVnsHMyAHs6A6KCpbns6aH5db5BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwi +ieDhZNRnvDF5YTy7ykHNXGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsFAAOC +AQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw8PRBEew/R40/cof5 +O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOGnXkZ7/e7DDWQw4rtTw/1zBLZpD67 +oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCPoky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul +4+vJhaAlIDf7js4MNIThPIGyd05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6 ++/NNIxuZMzSgLvWpCz/UXeHPhJ/iGcJfitYgHuNztw== +-----END CERTIFICATE----- + +Certum Trusted Network CA 2 +=========================== +-----BEGIN CERTIFICATE----- +MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCBgDELMAkGA1UE +BhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1 +bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29y +ayBDQSAyMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQ +TDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl +cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENB +IDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWADGSdhhuWZGc/IjoedQF9 +7/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+o +CgCXhVqqndwpyeI1B+twTUrWwbNWuKFBOJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40b +Rr5HMNUuctHFY9rnY3lEfktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2p +uTRZCr+ESv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1mo130 +GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02isx7QBlrd9pPPV3WZ +9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOWOZV7bIBaTxNyxtd9KXpEulKkKtVB +Rgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgezTv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pye +hizKV/Ma5ciSixqClnrDvFASadgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vM +BhBgu4M1t15n3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZI +hvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQF/xlhMcQSZDe28cmk4gmb3DW +Al45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTfCVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuA +L55MYIR4PSFk1vtBHxgP58l1cb29XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMo +clm2q8KMZiYcdywmdjWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tM +pkT/WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jbAoJnwTnb +w3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksqP/ujmv5zMnHCnsZy4Ypo +J/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Kob7a6bINDd82Kkhehnlt4Fj1F4jNy3eFm +ypnTycUm/Q1oBEauttmbjL4ZvrHG8hnjXALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLX +is7VmFxWlgPF7ncGNf/P5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7 +zAYspsbiDrW5viSP +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions RootCA 2015 +======================================================= +-----BEGIN CERTIFICATE----- +MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcT +BkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0 +aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl +YXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAx +MTIxWjCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMg +QWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNV +BAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIw +MTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDC+Kk/G4n8PDwEXT2QNrCROnk8Zlrv +bTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+eh +iGsxr/CL0BgzuNtFajT0AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+ +6PAQZe104S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06CojXd +FPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV9Cz82XBST3i4vTwr +i5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrDgfgXy5I2XdGj2HUb4Ysn6npIQf1F +GQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2 +fu/Z8VFRfS0myGlZYeCsargqNhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9mu +iNX6hME6wGkoLfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc +Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVdctA4GGqd83EkVAswDQYJKoZI +hvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0IXtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+ +D1hYc2Ryx+hFjtyp8iY/xnmMsVMIM4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrM +d/K4kPFox/la/vot9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+y +d+2VZ5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/eaj8GsGsVn +82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnhX9izjFk0WaSrT2y7Hxjb +davYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQl033DlZdwJVqwjbDG2jJ9SrcR5q+ss7F +Jej6A7na+RZukYT1HCjI/CbM1xyQVqdfbzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVt +J94Cj8rDtSvK6evIIVM4pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGa +JI7ZjnHKe7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0vm9q +p/UsQu0yrbYhnr68 +-----END CERTIFICATE----- + +Hellenic Academic and Research Institutions ECC RootCA 2015 +=========================================================== +-----BEGIN CERTIFICATE----- +MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0 +aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u +cyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj +aCBJbnN0aXR1dGlvbnMgRUNDIFJvb3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEw +MzcxMlowgaoxCzAJBgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmlj +IEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5MUQwQgYD +VQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIEVDQyBSb290 +Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKgQehLgoRc4vgxEZmGZE4JJS+dQS8KrjVP +dJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJajq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoK +Vlp8aQuqgAkkbH7BRqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O +BBYEFLQiC4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaeplSTA +GiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7SofTUwJCA3sS61kFyjn +dc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR +-----END CERTIFICATE----- + +ISRG Root X1 +============ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UE +BhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQD +EwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQG +EwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMT +DElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54r +Vygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj1 +3Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8K +b4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCN +Aymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ +4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf +1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFu +hjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQH +usEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/r +OPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4G +A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY +9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV +0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt +hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJw +TdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nx +e5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZA +JzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahD +YVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9n +JEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJ +m+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- + +AC RAIZ FNMT-RCM +================ +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsxCzAJBgNVBAYT +AkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTAeFw0wODEw +MjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJD +TTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBALpxgHpMhm5/yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcf +qQgfBBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAzWHFctPVr +btQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxFtBDXaEAUwED653cXeuYL +j2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z374jNUUeAlz+taibmSXaXvMiwzn15Cou +08YfxGyqxRxqAQVKL9LFwag0Jl1mpdICIfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mw +WsXmo8RZZUc1g16p6DULmbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnT +tOmlcYF7wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peSMKGJ +47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2ZSysV4999AeU14EC +ll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMetUqIJ5G+GR4of6ygnXYMgrwTJbFaa +i0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FPd9xf3E6Jobd2Sn9R2gzL+HYJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1o +dHRwOi8vd3d3LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD +nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1RXxlDPiyN8+s +D8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYMLVN0V2Ue1bLdI4E7pWYjJ2cJ +j+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrT +Qfv6MooqtyuGC2mDOL7Nii4LcK2NJpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW ++YJF1DngoABd15jmfZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7 +Ixjp6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp1txyM/1d +8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B9kiABdcPUXmsEKvU7ANm +5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wokRqEIr9baRRmW1FMdW4R58MD3R++Lj8UG +rp1MYp3/RgT408m2ECVAdf4WqslKYIYvuu8wd+RU4riEmViAqhOLUTpPSPaLtrM= +-----END CERTIFICATE----- + +Amazon Root CA 1 +================ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1 +MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv +bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgH +FzZM9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQ +gLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6VOujw5H5SNz/0egwLX0t +dHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7LWIbxcce +VOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3 +DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PM +CCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gDS63pYaACbvXy +8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa +2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2 +xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE----- + +Amazon Root CA 2 +================ +-----BEGIN CERTIFICATE----- +MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwFADA5MQswCQYD +VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAyMB4XDTE1 +MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv +bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAK2Wny2cSkxKgXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4 +kHbZW0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg1dKmSYXp +N+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K8nu+NQWpEjTj82R0Yiw9 +AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvd +fLC6HM783k81ds8P+HgfajZRRidhW+mez/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAEx +kv8LV/SasrlX6avvDXbR8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSS +btqDT6ZjmUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz7Mt0 +Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6+XUyo05f7O0oYtlN +c/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI0u1ufm8/0i2BWSlmy5A5lREedCf+ +3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSw +DPBMMPQFWAJI/TPlUq9LhONmUjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oA +A7CXDpO8Wqj2LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY ++gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kSk5Nrp+gvU5LE +YFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl7uxMMne0nxrpS10gxdr9HIcW +xkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygmbtmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQ +gj9sAq+uEjonljYE1x2igGOpm/HlurR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbW +aQbLU8uz/mtBzUF+fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoV +Yh63n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE76KlXIx3 +KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H9jVlpNMKVv/1F2Rs76gi +JUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT4PsJYGw= +-----END CERTIFICATE----- + +Amazon Root CA 3 +================ +-----BEGIN CERTIFICATE----- +MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5MQswCQYDVQQG +EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAzMB4XDTE1MDUy +NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ +MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZB +f8ANm+gBG1bG8lKlui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjr +Zt6jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSrttvXBp43 +rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkrBqWTrBqYaGFy+uGh0Psc +eGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteMYyRIHN8wfdVoOw== +-----END CERTIFICATE----- + +Amazon Root CA 4 +================ +-----BEGIN CERTIFICATE----- +MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5MQswCQYDVQQG +EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSA0MB4XDTE1MDUy +NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ +MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN +/sGKe0uoe0ZLY7Bi9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri +83BkM6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WBMAoGCCqGSM49BAMDA2gA +MGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlwCkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1 +AE47xDqUEpHJWEadIRNyp4iciuRMStuW1KyLa2tJElMzrdfkviT8tQp21KW8EA== +-----END CERTIFICATE----- + +TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1 +============================================= +-----BEGIN CERTIFICATE----- +MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIxGDAWBgNVBAcT +D0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxpbXNlbCB2ZSBUZWtub2xvamlr +IEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0wKwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24g +TWVya2V6aSAtIEthbXUgU00xNjA0BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRp +ZmlrYXNpIC0gU3VydW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYD +VQQGEwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXllIEJpbGlt +c2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklUQUsxLTArBgNVBAsTJEth +bXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBTTTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11 +IFNNIFNTTCBLb2sgU2VydGlmaWthc2kgLSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAr3UwM6q7a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y8 +6Ij5iySrLqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INrN3wc +wv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2XYacQuFWQfw4tJzh0 +3+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/iSIzL+aFCr2lqBs23tPcLG07xxO9 +WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4fAJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQU +ZT/HiobGPN08VFw1+DrtUgxHV8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJ +KoZIhvcNAQELBQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh +AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPfIPP54+M638yc +lNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4lzwDGrpDxpa5RXI4s6ehlj2R +e37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0j +q5Rm+K37DwhuJi1/FwcJsoz7UMCflo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= +-----END CERTIFICATE----- + +GDCA TrustAUTH R5 ROOT +====================== +-----BEGIN CERTIFICATE----- +MIIFiDCCA3CgAwIBAgIIfQmX/vBH6nowDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCQ04xMjAw +BgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZIENPLixMVEQuMR8wHQYDVQQD +DBZHRENBIFRydXN0QVVUSCBSNSBST09UMB4XDTE0MTEyNjA1MTMxNVoXDTQwMTIzMTE1NTk1OVow +YjELMAkGA1UEBhMCQ04xMjAwBgNVBAoMKUdVQU5HIERPTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZ +IENPLixMVEQuMR8wHQYDVQQDDBZHRENBIFRydXN0QVVUSCBSNSBST09UMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEA2aMW8Mh0dHeb7zMNOwZ+Vfy1YI92hhJCfVZmPoiC7XJjDp6L3TQs +AlFRwxn9WVSEyfFrs0yw6ehGXTjGoqcuEVe6ghWinI9tsJlKCvLriXBjTnnEt1u9ol2x8kECK62p +OqPseQrsXzrj/e+APK00mxqriCZ7VqKChh/rNYmDf1+uKU49tm7srsHwJ5uu4/Ts765/94Y9cnrr +pftZTqfrlYwiOXnhLQiPzLyRuEH3FMEjqcOtmkVEs7LXLM3GKeJQEK5cy4KOFxg2fZfmiJqwTTQJ +9Cy5WmYqsBebnh52nUpmMUHfP/vFBu8btn4aRjb3ZGM74zkYI+dndRTVdVeSN72+ahsmUPI2JgaQ +xXABZG12ZuGR224HwGGALrIuL4xwp9E7PLOR5G62xDtw8mySlwnNR30YwPO7ng/Wi64HtloPzgsM +R6flPri9fcebNaBhlzpBdRfMK5Z3KpIhHtmVdiBnaM8Nvd/WHwlqmuLMc3GkL30SgLdTMEZeS1SZ +D2fJpcjyIMGC7J0R38IC+xo70e0gmu9lZJIQDSri3nDxGGeCjGHeuLzRL5z7D9Ar7Rt2ueQ5Vfj4 +oR24qoAATILnsn8JuLwwoC8N9VKejveSswoAHQBUlwbgsQfZxw9cZX08bVlX5O2ljelAU58VS6Bx +9hoh49pwBiFYFIeFd3mqgnkCAwEAAaNCMEAwHQYDVR0OBBYEFOLJQJ9NzuiaoXzPDj9lxSmIahlR +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQDRSVfg +p8xoWLoBDysZzY2wYUWsEe1jUGn4H3++Fo/9nesLqjJHdtJnJO29fDMylyrHBYZmDRd9FBUb1Ov9 +H5r2XpdptxolpAqzkT9fNqyL7FeoPueBihhXOYV0GkLH6VsTX4/5COmSdI31R9KrO9b7eGZONn35 +6ZLpBN79SWP8bfsUcZNnL0dKt7n/HipzcEYwv1ryL3ml4Y0M2fmyYzeMN2WFcGpcWwlyua1jPLHd ++PwyvzeG5LuOmCd+uh8W4XAR8gPfJWIyJyYYMoSf/wA6E7qaTfRPuBRwIrHKK5DOKcFw9C+df/KQ +HtZa37dG/OaG+svgIHZ6uqbL9XzeYqWxi+7egmaKTjowHz+Ay60nugxe19CxVsp3cbK1daFQqUBD +F8Io2c9Si1vIY9RCPqAzekYu9wogRlR+ak8x8YF+QnQ4ZXMn7sZ8uI7XpTrXmKGcjBBV09tL7ECQ +8s1uV9JiDnxXk7Gnbc2dg7sq5+W2O3FYrf3RRbxake5TFW/TRQl1brqQXR4EzzffHqhmsYzmIGrv +/EhOdJhCrylvLmrH+33RZjEizIYAfmaDDEL0vTSSwxrqT8p+ck0LcIymSLumoRT2+1hEmRSuqguT +aaApJUqlyyvdimYHFngVV3Eb7PVHhPOeMTd61X8kreS8/f3MboPoDKi3QWwH3b08hpcv0g== +-----END CERTIFICATE----- + +SSL.com Root Certification Authority RSA +======================================== +-----BEGIN CERTIFICATE----- +MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAM +BgNVBAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24x +MTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBSU0EwHhcNMTYw +MjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NM +LmNvbSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQAD +ggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/C +Fp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrzQdjt0Oj8 +P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/ge +oeOy3ZExqysdBP+lSgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkp +k8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9AhnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3Z +fBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyAKoFBbZQ+yODJ +gUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2 +UzU1/Dfkw/ZdFr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi8 +1xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRnhE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4s +bE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV7jiAJQ2mWTAPBgNV +HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUr +dIWQPXhq9LmLpZc7tRiRux6n+UBbkflVma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUf +ijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAjRfSda/wZY52jvATGGAsl +u1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjq +erQ0cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxj +MxW3IwiPxg+NQVrdjsW5j+VFP3jbutIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJ +vTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJBjSWFupwWRoyeXkLtoh/D1JI +Pb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y +wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NI +WuuA8ShYIc2wBlX7Jz9TkHCpBB5XJ7k= +-----END CERTIFICATE----- + +SSL.com Root Certification Authority ECC +======================================== +-----BEGIN CERTIFICATE----- +MIICjTCCAhSgAwIBAgIIdebfy8FoW6gwCgYIKoZIzj0EAwIwfDELMAkGA1UEBhMCVVMxDjAMBgNV +BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAv +BgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYwMjEy +MTgxNDAzWhcNNDEwMjEyMTgxNDAzWjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMxEDAO +BgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNv +bSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuBBAAiA2IA +BEVuqVDEpiM2nl8ojRfLliJkP9x6jh3MCLOicSS6jkm5BBtHllirLZXI7Z4INcgn64mMU1jrYor+ +8FsPazFSY0E7ic3s7LaNGdM0B9y7xgZ/wkWV7Mt/qCPgCemB+vNH06NjMGEwHQYDVR0OBBYEFILR +hXMw5zUE044CkvvlpNHEIejNMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUgtGFczDnNQTT +jgKS++Wk0cQh6M0wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2cAMGQCMG/n61kRpGDPYbCW +e+0F+S8Tkdzt5fxQaxFGRrMcIQBiu77D5+jNB5n5DQtdcj7EqgIwH7y6C+IwJPt8bYBVCpk+gA0z +5Wajs6O7pdWLjwkspl1+4vAHCGht0nxpbl/f5Wpl +-----END CERTIFICATE----- + +SSL.com EV Root Certification Authority RSA R2 +============================================== +-----BEGIN CERTIFICATE----- +MIIF6zCCA9OgAwIBAgIIVrYpzTS8ePYwDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNVBAYTAlVTMQ4w +DAYDVQQIDAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9u +MTcwNQYDVQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIy +MB4XDTE3MDUzMTE4MTQzN1oXDTQyMDUzMDE4MTQzN1owgYIxCzAJBgNVBAYTAlVTMQ4wDAYDVQQI +DAVUZXhhczEQMA4GA1UEBwwHSG91c3RvbjEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMTcwNQYD +VQQDDC5TU0wuY29tIEVWIFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgUlNBIFIyMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjzZlQOHWTcDXtOlG2mvqM0fNTPl9fb69LT3w23jh +hqXZuglXaO1XPqDQCEGD5yhBJB/jchXQARr7XnAjssufOePPxU7Gkm0mxnu7s9onnQqG6YE3Bf7w +cXHswxzpY6IXFJ3vG2fThVUCAtZJycxa4bH3bzKfydQ7iEGonL3Lq9ttewkfokxykNorCPzPPFTO +Zw+oz12WGQvE43LrrdF9HSfvkusQv1vrO6/PgN3B0pYEW3p+pKk8OHakYo6gOV7qd89dAFmPZiw+ +B6KjBSYRaZfqhbcPlgtLyEDhULouisv3D5oi53+aNxPN8k0TayHRwMwi8qFG9kRpnMphNQcAb9Zh +CBHqurj26bNg5U257J8UZslXWNvNh2n4ioYSA0e/ZhN2rHd9NCSFg83XqpyQGp8hLH94t2S42Oim +9HizVcuE0jLEeK6jj2HdzghTreyI/BXkmg3mnxp3zkyPuBQVPWKchjgGAGYS5Fl2WlPAApiiECto +RHuOec4zSnaqW4EWG7WK2NAAe15itAnWhmMOpgWVSbooi4iTsjQc2KRVbrcc0N6ZVTsj9CLg+Slm +JuwgUHfbSguPvuUCYHBBXtSuUDkiFCbLsjtzdFVHB3mBOagwE0TlBIqulhMlQg+5U8Sb/M3kHN48 ++qvWBkofZ6aYMBzdLNvcGJVXZsb/XItW9XcCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNV +HSMEGDAWgBT5YLvU49U09rj1BoAlp3PbRmmonjAdBgNVHQ4EFgQU+WC71OPVNPa49QaAJadz20Zp +qJ4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBWs47LCp1Jjr+kxJG7ZhcFUZh1 +++VQLHqe8RT6q9OKPv+RKY9ji9i0qVQBDb6Thi/5Sm3HXvVX+cpVHBK+Rw82xd9qt9t1wkclf7nx +Y/hoLVUE0fKNsKTPvDxeH3jnpaAgcLAExbf3cqfeIg29MyVGjGSSJuM+LmOW2puMPfgYCdcDzH2G +guDKBAdRUNf/ktUM79qGn5nX67evaOI5JpS6aLe/g9Pqemc9YmeuJeVy6OLk7K4S9ksrPJ/psEDz +OFSz/bdoyNrGj1E8svuR3Bznm53htw1yj+KkxKl4+esUrMZDBcJlOSgYAsOCsp0FvmXtll9ldDz7 +CTUue5wT/RsPXcdtgTpWD8w74a8CLyKsRspGPKAcTNZEtF4uXBVmCeEmKf7GUmG6sXP/wwyc5Wxq +lD8UykAWlYTzWamsX0xhk23RO8yilQwipmdnRC652dKKQbNmC1r7fSOl8hqw/96bg5Qu0T/fkreR +rwU7ZcegbLHNYhLDkBvjJc40vG93drEQw/cFGsDWr3RiSBd3kmmQYRzelYB0VI8YHMPzA9C/pEN1 +hlMYegouCRw2n5H9gooiS9EOUCXdywMMF8mDAAhONU2Ki+3wApRmLER/y5UnlhetCTCstnEXbosX +9hwJ1C07mKVx01QT2WDz9UtmT/rx7iASjbSsV7FFY6GsdqnC+w== +-----END CERTIFICATE----- + +SSL.com EV Root Certification Authority ECC +=========================================== +-----BEGIN CERTIFICATE----- +MIIClDCCAhqgAwIBAgIILCmcWxbtBZUwCgYIKoZIzj0EAwIwfzELMAkGA1UEBhMCVVMxDjAMBgNV +BAgMBVRleGFzMRAwDgYDVQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xNDAy +BgNVBAMMK1NTTC5jb20gRVYgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSBFQ0MwHhcNMTYw +MjEyMTgxNTIzWhcNNDEwMjEyMTgxNTIzWjB/MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4YXMx +EDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjE0MDIGA1UEAwwrU1NM +LmNvbSBFViBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IEVDQzB2MBAGByqGSM49AgEGBSuB +BAAiA2IABKoSR5CYG/vvw0AHgyBO8TCCogbR8pKGYfL2IWjKAMTH6kMAVIbc/R/fALhBYlzccBYy +3h+Z1MzFB8gIH2EWB1E9fVwHU+M1OIzfzZ/ZLg1KthkuWnBaBu2+8KGwytAJKaNjMGEwHQYDVR0O +BBYEFFvKXuXe0oGqzagtZFG22XKbl+ZPMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUW8pe +5d7SgarNqC1kUbbZcpuX5k8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA2gAMGUCMQCK5kCJ +N+vp1RPZytRrJPOwPYdGWBrssd9v+1a6cGvHOMzosYxPD/fxZ3YOg9AeUY8CMD32IygmTMZgh5Mm +m7I1HrrW9zzRHM76JTymGoEVW/MSD2zuZYrJh6j5B+BimoxcSg== +-----END CERTIFICATE----- + +GlobalSign Root CA - R6 +======================= +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIORea7A4Mzw4VlSOb/RVEwDQYJKoZIhvcNAQEMBQAwTDEgMB4GA1UECxMX +R2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wHhcNMTQxMjEwMDAwMDAwWhcNMzQxMjEwMDAwMDAwWjBMMSAwHgYDVQQLExdHbG9i +YWxTaWduIFJvb3QgQ0EgLSBSNjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFs +U2lnbjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJUH6HPKZvnsFMp7PPcNCPG0RQss +grRIxutbPK6DuEGSMxSkb3/pKszGsIhrxbaJ0cay/xTOURQh7ErdG1rG1ofuTToVBu1kZguSgMpE +3nOUTvOniX9PeGMIyBJQbUJmL025eShNUhqKGoC3GYEOfsSKvGRMIRxDaNc9PIrFsmbVkJq3MQbF +vuJtMgamHvm566qjuL++gmNQ0PAYid/kD3n16qIfKtJwLnvnvJO7bVPiSHyMEAc4/2ayd2F+4OqM +PKq0pPbzlUoSB239jLKJz9CgYXfIWHSw1CM69106yqLbnQneXUQtkPGBzVeS+n68UARjNN9rkxi+ +azayOeSsJDa38O+2HBNXk7besvjihbdzorg1qkXy4J02oW9UivFyVm4uiMVRQkQVlO6jxTiWm05O +WgtH8wY2SXcwvHE35absIQh1/OZhFj931dmRl4QKbNQCTXTAFO39OfuD8l4UoQSwC+n+7o/hbguy +CLNhZglqsQY6ZZZZwPA1/cnaKI0aEYdwgQqomnUdnjqGBQCe24DWJfncBZ4nWUx2OVvq+aWh2IMP +0f/fMBH5hc8zSPXKbWQULHpYT9NLCEnFlWQaYw55PfWzjMpYrZxCRXluDocZXFSxZba/jJvcE+kN +b7gu3GduyYsRtYQUigAZcIN5kZeR1BonvzceMgfYFGM8KEyvAgMBAAGjYzBhMA4GA1UdDwEB/wQE +AwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSubAWjkxPioufi1xzWx/B/yGdToDAfBgNV +HSMEGDAWgBSubAWjkxPioufi1xzWx/B/yGdToDANBgkqhkiG9w0BAQwFAAOCAgEAgyXt6NH9lVLN +nsAEoJFp5lzQhN7craJP6Ed41mWYqVuoPId8AorRbrcWc+ZfwFSY1XS+wc3iEZGtIxg93eFyRJa0 +lV7Ae46ZeBZDE1ZXs6KzO7V33EByrKPrmzU+sQghoefEQzd5Mr6155wsTLxDKZmOMNOsIeDjHfrY +BzN2VAAiKrlNIC5waNrlU/yDXNOd8v9EDERm8tLjvUYAGm0CuiVdjaExUd1URhxN25mW7xocBFym +Fe944Hn+Xds+qkxV/ZoVqW/hpvvfcDDpw+5CRu3CkwWJ+n1jez/QcYF8AOiYrg54NMMl+68KnyBr +3TsTjxKM4kEaSHpzoHdpx7Zcf4LIHv5YGygrqGytXm3ABdJ7t+uA/iU3/gKbaKxCXcPu9czc8FB1 +0jZpnOZ7BN9uBmm23goJSFmH63sUYHpkqmlD75HHTOwY3WzvUy2MmeFe8nI+z1TIvWfspA9MRf/T +uTAjB0yPEL+GltmZWrSZVxykzLsViVO6LAUP5MSeGbEYNNVMnbrt9x+vJJUEeKgDu+6B5dpffItK +oZB0JaezPkvILFa9x8jvOOJckvB595yEunQtYQEgfn7R8k8HWV+LLUNS60YMlOH1Zkd5d9VUWx+t +JDfLRVpOoERIyNiwmcUVhAn21klJwGW45hpxbqCo8YLoRT5s1gLXCmeDBVrJpBA= +-----END CERTIFICATE----- + +OISTE WISeKey Global Root GC CA +=============================== +-----BEGIN CERTIFICATE----- +MIICaTCCAe+gAwIBAgIQISpWDK7aDKtARb8roi066jAKBggqhkjOPQQDAzBtMQswCQYDVQQGEwJD +SDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNlZDEo +MCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQyBDQTAeFw0xNzA1MDkwOTQ4MzRa +Fw00MjA1MDkwOTU4MzNaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYDVQQL +ExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEdsb2Jh +bCBSb290IEdDIENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAETOlQwMYPchi82PG6s4nieUqjFqdr +VCTbUf/q9Akkwwsin8tqJ4KBDdLArzHkdIJuyiXZjHWd8dvQmqJLIX4Wp2OQ0jnUsYd4XxiWD1Ab +NTcPasbc2RNNpI6QN+a9WzGRo1QwUjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUSIcUrOPDnpBgOtfKie7TrYy0UGYwEAYJKwYBBAGCNxUBBAMCAQAwCgYIKoZIzj0E +AwMDaAAwZQIwJsdpW9zV57LnyAyMjMPdeYwbY9XJUpROTYJKcx6ygISpJcBMWm1JKWB4E+J+SOtk +AjEA2zQgMgj/mkkCtojeFK9dbJlxjRo/i9fgojaGHAeCOnZT/cKi7e97sIBPWA9LUzm9 +-----END CERTIFICATE----- + +UCA Global G2 Root +================== +-----BEGIN CERTIFICATE----- +MIIFRjCCAy6gAwIBAgIQXd+x2lqj7V2+WmUgZQOQ7zANBgkqhkiG9w0BAQsFADA9MQswCQYDVQQG +EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxGzAZBgNVBAMMElVDQSBHbG9iYWwgRzIgUm9vdDAeFw0x +NjAzMTEwMDAwMDBaFw00MDEyMzEwMDAwMDBaMD0xCzAJBgNVBAYTAkNOMREwDwYDVQQKDAhVbmlU +cnVzdDEbMBkGA1UEAwwSVUNBIEdsb2JhbCBHMiBSb290MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A +MIICCgKCAgEAxeYrb3zvJgUno4Ek2m/LAfmZmqkywiKHYUGRO8vDaBsGxUypK8FnFyIdK+35KYmT +oni9kmugow2ifsqTs6bRjDXVdfkX9s9FxeV67HeToI8jrg4aA3++1NDtLnurRiNb/yzmVHqUwCoV +8MmNsHo7JOHXaOIxPAYzRrZUEaalLyJUKlgNAQLx+hVRZ2zA+te2G3/RVogvGjqNO7uCEeBHANBS +h6v7hn4PJGtAnTRnvI3HLYZveT6OqTwXS3+wmeOwcWDcC/Vkw85DvG1xudLeJ1uK6NjGruFZfc8o +LTW4lVYa8bJYS7cSN8h8s+1LgOGN+jIjtm+3SJUIsUROhYw6AlQgL9+/V087OpAh18EmNVQg7Mc/ +R+zvWr9LesGtOxdQXGLYD0tK3Cv6brxzks3sx1DoQZbXqX5t2Okdj4q1uViSukqSKwxW/YDrCPBe +KW4bHAyvj5OJrdu9o54hyokZ7N+1wxrrFv54NkzWbtA+FxyQF2smuvt6L78RHBgOLXMDj6DlNaBa +4kx1HXHhOThTeEDMg5PXCp6dW4+K5OXgSORIskfNTip1KnvyIvbJvgmRlld6iIis7nCs+dwp4wwc +OxJORNanTrAmyPPZGpeRaOrvjUYG0lZFWJo8DA+DuAUlwznPO6Q0ibd5Ei9Hxeepl2n8pndntd97 +8XplFeRhVmUCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFIHEjMz15DD/pQwIX4wVZyF0Ad/fMA0GCSqGSIb3DQEBCwUAA4ICAQATZSL1jiutROTL/7lo +5sOASD0Ee/ojL3rtNtqyzm325p7lX1iPyzcyochltq44PTUbPrw7tgTQvPlJ9Zv3hcU2tsu8+Mg5 +1eRfB70VVJd0ysrtT7q6ZHafgbiERUlMjW+i67HM0cOU2kTC5uLqGOiiHycFutfl1qnN3e92mI0A +Ds0b+gO3joBYDic/UvuUospeZcnWhNq5NXHzJsBPd+aBJ9J3O5oUb3n09tDh05S60FdRvScFDcH9 +yBIw7m+NESsIndTUv4BFFJqIRNow6rSn4+7vW4LVPtateJLbXDzz2K36uGt/xDYotgIVilQsnLAX +c47QN6MUPJiVAAwpBVueSUmxX8fjy88nZY41F7dXyDDZQVu5FLbowg+UMaeUmMxq67XhJ/UQqAHo +jhJi6IjMtX9Gl8CbEGY4GjZGXyJoPd/JxhMnq1MGrKI8hgZlb7F+sSlEmqO6SWkoaY/X5V+tBIZk +bxqgDMUIYs6Ao9Dz7GjevjPHF1t/gMRMTLGmhIrDO7gJzRSBuhjjVFc2/tsvfEehOjPI+Vg7RE+x +ygKJBJYoaMVLuCaJu9YzL1DV/pqJuhgyklTGW+Cd+V7lDSKb9triyCGyYiGqhkCyLmTTX8jjfhFn +RR8F/uOi77Oos/N9j/gMHyIfLXC0uAE0djAA5SN4p1bXUB+K+wb1whnw0A== +-----END CERTIFICATE----- + +UCA Extended Validation Root +============================ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIQT9Irj/VkyDOeTzRYZiNwYDANBgkqhkiG9w0BAQsFADBHMQswCQYDVQQG +EwJDTjERMA8GA1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9u +IFJvb3QwHhcNMTUwMzEzMDAwMDAwWhcNMzgxMjMxMDAwMDAwWjBHMQswCQYDVQQGEwJDTjERMA8G +A1UECgwIVW5pVHJ1c3QxJTAjBgNVBAMMHFVDQSBFeHRlbmRlZCBWYWxpZGF0aW9uIFJvb3QwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpCQcoEwKwmeBkqh5DFnpzsZGgdT6o+uM4AHrs +iWogD4vFsJszA1qGxliG1cGFu0/GnEBNyr7uaZa4rYEwmnySBesFK5pI0Lh2PpbIILvSsPGP2KxF +Rv+qZ2C0d35qHzwaUnoEPQc8hQ2E0B92CvdqFN9y4zR8V05WAT558aopO2z6+I9tTcg1367r3CTu +eUWnhbYFiN6IXSV8l2RnCdm/WhUFhvMJHuxYMjMR83dksHYf5BA1FxvyDrFspCqjc/wJHx4yGVMR +59mzLC52LqGj3n5qiAno8geK+LLNEOfic0CTuwjRP+H8C5SzJe98ptfRr5//lpr1kXuYC3fUfugH +0mK1lTnj8/FtDw5lhIpjVMWAtuCeS31HJqcBCF3RiJ7XwzJE+oJKCmhUfzhTA8ykADNkUVkLo4KR +el7sFsLzKuZi2irbWWIQJUoqgQtHB0MGcIfS+pMRKXpITeuUx3BNr2fVUbGAIAEBtHoIppB/TuDv +B0GHr2qlXov7z1CymlSvw4m6WC31MJixNnI5fkkE/SmnTHnkBVfblLkWU41Gsx2VYVdWf6/wFlth +WG82UBEL2KwrlRYaDh8IzTY0ZRBiZtWAXxQgXy0MoHgKaNYs1+lvK9JKBZP8nm9rZ/+I8U6laUpS +NwXqxhaN0sSZ0YIrO7o1dfdRUVjzyAfd5LQDfwIDAQABo0IwQDAdBgNVHQ4EFgQU2XQ65DA9DfcS +3H5aBZ8eNJr34RQwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQEL +BQADggIBADaNl8xCFWQpN5smLNb7rhVpLGsaGvdftvkHTFnq88nIua7Mui563MD1sC3AO6+fcAUR +ap8lTwEpcOPlDOHqWnzcSbvBHiqB9RZLcpHIojG5qtr8nR/zXUACE/xOHAbKsxSQVBcZEhrxH9cM +aVr2cXj0lH2RC47skFSOvG+hTKv8dGT9cZr4QQehzZHkPJrgmzI5c6sq1WnIeJEmMX3ixzDx/BR4 +dxIOE/TdFpS/S2d7cFOFyrC78zhNLJA5wA3CXWvp4uXViI3WLL+rG761KIcSF3Ru/H38j9CHJrAb ++7lsq+KePRXBOy5nAliRn+/4Qh8st2j1da3Ptfb/EX3C8CSlrdP6oDyp+l3cpaDvRKS+1ujl5BOW +F3sGPjLtx7dCvHaj2GU4Kzg1USEODm8uNBNA4StnDG1KQTAYI1oyVZnJF+A83vbsea0rWBmirSwi +GpWOvpaQXUJXxPkUAzUrHC1RVwinOt4/5Mi0A3PCwSaAuwtCH60NryZy2sy+s6ODWA2CxR9GUeOc +GMyNm43sSet1UNWMKFnKdDTajAshqx7qG+XH/RU+wBeq+yNuJkbL+vmxcmtpzyKEC2IPrNkZAJSi +djzULZrtBJ4tBmIQN1IchXIbJ+XMxjHsN+xjWZsLHXbMfjKaiJUINlK73nZfdklJrX+9ZSCyycEr +dhh2n1ax +-----END CERTIFICATE----- + +Certigna Root CA +================ +-----BEGIN CERTIFICATE----- +MIIGWzCCBEOgAwIBAgIRAMrpG4nxVQMNo+ZBbcTjpuEwDQYJKoZIhvcNAQELBQAwWjELMAkGA1UE +BhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczEcMBoGA1UECwwTMDAwMiA0ODE0NjMwODEwMDAzNjEZ +MBcGA1UEAwwQQ2VydGlnbmEgUm9vdCBDQTAeFw0xMzEwMDEwODMyMjdaFw0zMzEwMDEwODMyMjda +MFoxCzAJBgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxHDAaBgNVBAsMEzAwMDIgNDgxNDYz +MDgxMDAwMzYxGTAXBgNVBAMMEENlcnRpZ25hIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDNGDllGlmx6mQWDoyUJJV8g9PFOSbcDO8WV43X2KyjQn+Cyu3NW9sOty3tRQgX +stmzy9YXUnIo245Onoq2C/mehJpNdt4iKVzSs9IGPjA5qXSjklYcoW9MCiBtnyN6tMbaLOQdLNyz +KNAT8kxOAkmhVECe5uUFoC2EyP+YbNDrihqECB63aCPuI9Vwzm1RaRDuoXrC0SIxwoKF0vJVdlB8 +JXrJhFwLrN1CTivngqIkicuQstDuI7pmTLtipPlTWmR7fJj6o0ieD5Wupxj0auwuA0Wv8HT4Ks16 +XdG+RCYyKfHx9WzMfgIhC59vpD++nVPiz32pLHxYGpfhPTc3GGYo0kDFUYqMwy3OU4gkWGQwFsWq +4NYKpkDfePb1BHxpE4S80dGnBs8B92jAqFe7OmGtBIyT46388NtEbVncSVmurJqZNjBBe3YzIoej +wpKGbvlw7q6Hh5UbxHq9MfPU0uWZ/75I7HX1eBYdpnDBfzwboZL7z8g81sWTCo/1VTp2lc5ZmIoJ +lXcymoO6LAQ6l73UL77XbJuiyn1tJslV1c/DeVIICZkHJC1kJWumIWmbat10TWuXekG9qxf5kBdI +jzb5LdXF2+6qhUVB+s06RbFo5jZMm5BX7CO5hwjCxAnxl4YqKE3idMDaxIzb3+KhF1nOJFl0Mdp/ +/TBt2dzhauH8XwIDAQABo4IBGjCCARYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +HQYDVR0OBBYEFBiHVuBud+4kNTxOc5of1uHieX4rMB8GA1UdIwQYMBaAFBiHVuBud+4kNTxOc5of +1uHieX4rMEQGA1UdIAQ9MDswOQYEVR0gADAxMC8GCCsGAQUFBwIBFiNodHRwczovL3d3d3cuY2Vy +dGlnbmEuZnIvYXV0b3JpdGVzLzBtBgNVHR8EZjBkMC+gLaArhilodHRwOi8vY3JsLmNlcnRpZ25h +LmZyL2NlcnRpZ25hcm9vdGNhLmNybDAxoC+gLYYraHR0cDovL2NybC5kaGlteW90aXMuY29tL2Nl +cnRpZ25hcm9vdGNhLmNybDANBgkqhkiG9w0BAQsFAAOCAgEAlLieT/DjlQgi581oQfccVdV8AOIt +OoldaDgvUSILSo3L6btdPrtcPbEo/uRTVRPPoZAbAh1fZkYJMyjhDSSXcNMQH+pkV5a7XdrnxIxP +TGRGHVyH41neQtGbqH6mid2PHMkwgu07nM3A6RngatgCdTer9zQoKJHyBApPNeNgJgH60BGM+RFq +7q89w1DTj18zeTyGqHNFkIwgtnJzFyO+B2XleJINugHA64wcZr+shncBlA2c5uk5jR+mUYyZDDl3 +4bSb+hxnV29qao6pK0xXeXpXIs/NX2NGjVxZOob4Mkdio2cNGJHc+6Zr9UhhcyNZjgKnvETq9Emd +8VRY+WCv2hikLyhF3HqgiIZd8zvn/yk1gPxkQ5Tm4xxvvq0OKmOZK8l+hfZx6AYDlf7ej0gcWtSS +6Cvu5zHbugRqh5jnxV/vfaci9wHYTfmJ0A6aBVmknpjZbyvKcL5kwlWj9Omvw5Ip3IgWJJk8jSaY +tlu3zM63Nwf9JtmYhST/WSMDmu2dnajkXjjO11INb9I/bbEFa0nOipFGc/T2L/Coc3cOZayhjWZS +aX5LaAzHHjcng6WMxwLkFM1JAbBzs/3GkDpv0mztO+7skb6iQ12LAEpmJURw3kAP+HwV96LOPNde +E4yBFxgX0b3xdxA61GU5wSesVywlVP+i2k+KYTlerj1KjL0= +-----END CERTIFICATE----- + +emSign Root CA - G1 +=================== +-----BEGIN CERTIFICATE----- +MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYDVQQGEwJJTjET +MBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBHMTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgx +ODMwMDBaMGcxCzAJBgNVBAYTAklOMRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVk +aHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQzf2N4aLTN +LnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO8oG0x5ZOrRkVUkr+PHB1 +cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aqd7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHW +DV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhMtTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ +6DqS0hdW5TUaQBw+jSztOd9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrH +hQIDAQABo0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQDAgEG +MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31xPaOfG1vR2vjTnGs2 +vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjMwiI/aTvFthUvozXGaCocV685743Q +NcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6dGNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q ++Mri/Tm3R7nrft8EI6/6nAYH6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeih +U80Bv2noWgbyRQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx +iN66zB+Afko= +-----END CERTIFICATE----- + +emSign ECC Root CA - G3 +======================= +-----BEGIN CERTIFICATE----- +MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQGEwJJTjETMBEG +A1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg +MB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4 +MTgzMDAwWjBrMQswCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11 +ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g +RzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0WXTsuwYc +58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xySfvalY8L1X44uT6EYGQIr +MgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuBzhccLikenEhjQjAOBgNVHQ8BAf8EBAMC +AQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+D +CBeQyh+KTOgNG3qxrdWBCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7 +jHvrZQnD+JbNR6iC8hZVdyR+EhCVBCyj +-----END CERTIFICATE----- + +emSign Root CA - C1 +=================== +-----BEGIN CERTIFICATE----- +MIIDczCCAlugAwIBAgILAK7PALrEzzL4Q7IwDQYJKoZIhvcNAQELBQAwVjELMAkGA1UEBhMCVVMx +EzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQDExNlbVNp +Z24gUm9vdCBDQSAtIEMxMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowVjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMRwwGgYDVQQD +ExNlbVNpZ24gUm9vdCBDQSAtIEMxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+up +ufGZBczYKCFK83M0UYRWEPWgTywS4/oTmifQz/l5GnRfHXk5/Fv4cI7gklL35CX5VIPZHdPIWoU/ +Xse2B+4+wM6ar6xWQio5JXDWv7V7Nq2s9nPczdcdioOl+yuQFTdrHCZH3DspVpNqs8FqOp099cGX +OFgFixwR4+S0uF2FHYP+eF8LRWgYSKVGczQ7/g/IdrvHGPMF0Ybzhe3nudkyrVWIzqa2kbBPrH4V +I5b2P/AgNBbeCsbEBEV5f6f9vtKppa+cxSMq9zwhbL2vj07FOrLzNBL834AaSaTUqZX3noleooms +lMuoaJuvimUnzYnu3Yy1aylwQ6BpC+S5DwIDAQABo0IwQDAdBgNVHQ4EFgQU/qHgcB4qAzlSWkK+ +XJGFehiqTbUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD +ggEBAMJKVvoVIXsoounlHfv4LcQ5lkFMOycsxGwYFYDGrK9HWS8mC+M2sO87/kOXSTKZEhVb3xEp +/6tT+LvBeA+snFOvV71ojD1pM/CjoCNjO2RnIkSt1XHLVip4kqNPEjE2NuLe/gDEo2APJ62gsIq1 +NnpSob0n9CAnYuhNlCQT5AoE6TyrLshDCUrGYQTlSTR+08TI9Q/Aqum6VF7zYytPT1DU/rl7mYw9 +wC68AivTxEDkigcxHpvOJpkT+xHqmiIMERnHXhuBUDDIlhJu58tBf5E7oke3VIAb3ADMmpDqw8NQ +BmIMMMAVSKeoWXzhriKi4gp6D/piq1JM4fHfyr6DDUI= +-----END CERTIFICATE----- + +emSign ECC Root CA - C3 +======================= +-----BEGIN CERTIFICATE----- +MIICKzCCAbGgAwIBAgIKe3G2gla4EnycqDAKBggqhkjOPQQDAzBaMQswCQYDVQQGEwJVUzETMBEG +A1UECxMKZW1TaWduIFBLSTEUMBIGA1UEChMLZU11ZGhyYSBJbmMxIDAeBgNVBAMTF2VtU2lnbiBF +Q0MgUm9vdCBDQSAtIEMzMB4XDTE4MDIxODE4MzAwMFoXDTQzMDIxODE4MzAwMFowWjELMAkGA1UE +BhMCVVMxEzARBgNVBAsTCmVtU2lnbiBQS0kxFDASBgNVBAoTC2VNdWRocmEgSW5jMSAwHgYDVQQD +ExdlbVNpZ24gRUNDIFJvb3QgQ0EgLSBDMzB2MBAGByqGSM49AgEGBSuBBAAiA2IABP2lYa57JhAd +6bciMK4G9IGzsUJxlTm801Ljr6/58pc1kjZGDoeVjbk5Wum739D+yAdBPLtVb4OjavtisIGJAnB9 +SMVK4+kiVCJNk7tCDK93nCOmfddhEc5lx/h//vXyqaNCMEAwHQYDVR0OBBYEFPtaSNCAIEDyqOkA +B2kZd6fmw/TPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gA +MGUCMQC02C8Cif22TGK6Q04ThHK1rt0c3ta13FaPWEBaLd4gTCKDypOofu4SQMfWh0/434UCMBwU +ZOR8loMRnLDRWmFLpg9J0wD8ofzkpf9/rdcw0Md3f76BB1UwUCAU9Vc4CqgxUQ== +-----END CERTIFICATE----- + +Hongkong Post Root CA 3 +======================= +-----BEGIN CERTIFICATE----- +MIIFzzCCA7egAwIBAgIUCBZfikyl7ADJk0DfxMauI7gcWqQwDQYJKoZIhvcNAQELBQAwbzELMAkG +A1UEBhMCSEsxEjAQBgNVBAgTCUhvbmcgS29uZzESMBAGA1UEBxMJSG9uZyBLb25nMRYwFAYDVQQK +Ew1Ib25na29uZyBQb3N0MSAwHgYDVQQDExdIb25na29uZyBQb3N0IFJvb3QgQ0EgMzAeFw0xNzA2 +MDMwMjI5NDZaFw00MjA2MDMwMjI5NDZaMG8xCzAJBgNVBAYTAkhLMRIwEAYDVQQIEwlIb25nIEtv +bmcxEjAQBgNVBAcTCUhvbmcgS29uZzEWMBQGA1UEChMNSG9uZ2tvbmcgUG9zdDEgMB4GA1UEAxMX +SG9uZ2tvbmcgUG9zdCBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz +iNfqzg8gTr7m1gNt7ln8wlffKWihgw4+aMdoWJwcYEuJQwy51BWy7sFOdem1p+/l6TWZ5Mwc50tf +jTMwIDNT2aa71T4Tjukfh0mtUC1Qyhi+AViiE3CWu4mIVoBc+L0sPOFMV4i707mV78vH9toxdCim +5lSJ9UExyuUmGs2C4HDaOym71QP1mbpV9WTRYA6ziUm4ii8F0oRFKHyPaFASePwLtVPLwpgchKOe +sL4jpNrcyCse2m5FHomY2vkALgbpDDtw1VAliJnLzXNg99X/NWfFobxeq81KuEXryGgeDQ0URhLj +0mRiikKYvLTGCAj4/ahMZJx2Ab0vqWwzD9g/KLg8aQFChn5pwckGyuV6RmXpwtZQQS4/t+TtbNe/ +JgERohYpSms0BpDsE9K2+2p20jzt8NYt3eEV7KObLyzJPivkaTv/ciWxNoZbx39ri1UbSsUgYT2u +y1DhCDq+sI9jQVMwCFk8mB13umOResoQUGC/8Ne8lYePl8X+l2oBlKN8W4UdKjk60FSh0Tlxnf0h ++bV78OLgAo9uliQlLKAeLKjEiafv7ZkGL7YKTE/bosw3Gq9HhS2KX8Q0NEwA/RiTZxPRN+ZItIsG +xVd7GYYKecsAyVKvQv83j+GjHno9UKtjBucVtT+2RTeUN7F+8kjDf8V1/peNRY8apxpyKBpADwID +AQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQXnc0e +i9Y5K3DTXNSguB+wAPzFYTAdBgNVHQ4EFgQUF53NHovWOStw01zUoLgfsAD8xWEwDQYJKoZIhvcN +AQELBQADggIBAFbVe27mIgHSQpsY1Q7XZiNc4/6gx5LS6ZStS6LG7BJ8dNVI0lkUmcDrudHr9Egw +W62nV3OZqdPlt9EuWSRY3GguLmLYauRwCy0gUCCkMpXRAJi70/33MvJJrsZ64Ee+bs7Lo3I6LWld +y8joRTnU+kLBEUx3XZL7av9YROXrgZ6voJmtvqkBZss4HTzfQx/0TW60uhdG/H39h4F5ag0zD/ov ++BS5gLNdTaqX4fnkGMX41TiMJjz98iji7lpJiCzfeT2OnpA8vUFKOt1b9pq0zj8lMH8yfaIDlNDc +eqFS3m6TjRgm/VWsvY+b0s+v54Ysyx8Jb6NvqYTUc79NoXQbTiNg8swOqn+knEwlqLJmOzj/2ZQw +9nKEvmhVEA/GcywWaZMH/rFF7buiVWqw2rVKAiUnhde3t4ZEFolsgCs+l6mc1X5VTMbeRRAc6uk7 +nwNT7u56AQIWeNTowr5GdogTPyK7SBIdUgC0An4hGh6cJfTzPV4e0hz5sy229zdcxsshTrD3mUcY +hcErulWuBurQB7Lcq9CClnXO0lD+mefPL5/ndtFhKvshuzHQqp9HpLIiyhY6UFfEW0NnxWViA0kB +60PZ2Pierc+xYw5F9KBaLJstxabArahH9CdMOA0uG0k7UvToiIMrVCjU8jVStDKDYmlkDJGcn5fq +dBb9HxEGmpv0 +-----END CERTIFICATE----- + +Microsoft ECC Root Certificate Authority 2017 +============================================= +-----BEGIN CERTIFICATE----- +MIICWTCCAd+gAwIBAgIQZvI9r4fei7FK6gxXMQHC7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV +UzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgRUND +IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjMwNjQ1WhcNNDIwNzE4 +MjMxNjA0WjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYw +NAYDVQQDEy1NaWNyb3NvZnQgRUNDIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwdjAQ +BgcqhkjOPQIBBgUrgQQAIgNiAATUvD0CQnVBEyPNgASGAlEvaqiBYgtlzPbKnR5vSmZRogPZnZH6 +thaxjG7efM3beaYvzrvOcS/lpaso7GMEZpn4+vKTEAXhgShC48Zo9OYbhGBKia/teQ87zvH2RPUB +eMCjVDBSMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTIy5lycFIM ++Oa+sgRXKSrPQhDtNTAQBgkrBgEEAYI3FQEEAwIBADAKBggqhkjOPQQDAwNoADBlAjBY8k3qDPlf +Xu5gKcs68tvWMoQZP3zVL8KxzJOuULsJMsbG7X7JNpQS5GiFBqIb0C8CMQCZ6Ra0DvpWSNSkMBaR +eNtUjGUBiudQZsIxtzm6uBoiB078a1QWIP8rtedMDE2mT3M= +-----END CERTIFICATE----- + +Microsoft RSA Root Certificate Authority 2017 +============================================= +-----BEGIN CERTIFICATE----- +MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQG +EwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQg +UlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwHhcNMTkxMjE4MjI1MTIyWhcNNDIw +NzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +MTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw +ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml +7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7e +S0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk3bPZ7Pw7 +1VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+ +dkC0zVJhUXAoP8XFWvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49F +yGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fznhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRS +MLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IYZVcSn2i51BVr +lMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ +0tHXUeOrO8ZV/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJ +ClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tNsiMwEAYJKwYBBAGC +NxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og +6Lq46BrsTaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80 +dK2Jp86519efhGSSvpWhrQlTM93uCupKUY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk ++ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2qdgXKxdJ5XbLwVaZOjex +/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDy +AmH3pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nW +ZjEgQRCHLQzWwa80mMpkg/sTV9HB8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE +7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy4ZQ5bb0Sr74MTnB8G2+NszKT +c0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D +5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E +-----END CERTIFICATE----- + +e-Szigno Root CA 2017 +===================== +-----BEGIN CERTIFICATE----- +MIICQDCCAeWgAwIBAgIMAVRI7yH9l1kN9QQKMAoGCCqGSM49BAMCMHExCzAJBgNVBAYTAkhVMREw +DwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UECgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUt +MjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3ppZ25vIFJvb3QgQ0EgMjAxNzAeFw0xNzA4MjIxMjA3MDZa +Fw00MjA4MjIxMjA3MDZaMHExCzAJBgNVBAYTAkhVMREwDwYDVQQHDAhCdWRhcGVzdDEWMBQGA1UE +CgwNTWljcm9zZWMgTHRkLjEXMBUGA1UEYQwOVkFUSFUtMjM1ODQ0OTcxHjAcBgNVBAMMFWUtU3pp +Z25vIFJvb3QgQ0EgMjAxNzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJbcPYrYsHtvxie+RJCx +s1YVe45DJH0ahFnuY2iyxl6H0BVIHqiQrb1TotreOpCmYF9oMrWGQd+HWyx7xf58etqjYzBhMA8G +A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSHERUI0arBeAyxr87GyZDv +vzAEwDAfBgNVHSMEGDAWgBSHERUI0arBeAyxr87GyZDvvzAEwDAKBggqhkjOPQQDAgNJADBGAiEA +tVfd14pVCzbhhkT61NlojbjcI4qKDdQvfepz7L9NbKgCIQDLpbQS+ue16M9+k/zzNY9vTlp8tLxO +svxyqltZ+efcMQ== +-----END CERTIFICATE----- + +certSIGN Root CA G2 +=================== +-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIJEQA0tk7GNi02MA0GCSqGSIb3DQEBCwUAMEExCzAJBgNVBAYTAlJPMRQw +EgYDVQQKEwtDRVJUU0lHTiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjAeFw0xNzAy +MDYwOTI3MzVaFw00MjAyMDYwOTI3MzVaMEExCzAJBgNVBAYTAlJPMRQwEgYDVQQKEwtDRVJUU0lH +TiBTQTEcMBoGA1UECxMTY2VydFNJR04gUk9PVCBDQSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBAMDFdRmRfUR0dIf+DjuW3NgBFszuY5HnC2/OOwppGnzC46+CjobXXo9X69MhWf05 +N0IwvlDqtg+piNguLWkh59E3GE59kdUWX2tbAMI5Qw02hVK5U2UPHULlj88F0+7cDBrZuIt4Imfk +abBoxTzkbFpG583H+u/E7Eu9aqSs/cwoUe+StCmrqzWaTOTECMYmzPhpn+Sc8CnTXPnGFiWeI8Mg +wT0PPzhAsP6CRDiqWhqKa2NYOLQV07YRaXseVO6MGiKscpc/I1mbySKEwQdPzH/iV8oScLumZfNp +dWO9lfsbl83kqK/20U6o2YpxJM02PbyWxPFsqa7lzw1uKA2wDrXKUXt4FMMgL3/7FFXhEZn91Qqh +ngLjYl/rNUssuHLoPj1PrCy7Lobio3aP5ZMqz6WryFyNSwb/EkaseMsUBzXgqd+L6a8VTxaJW732 +jcZZroiFDsGJ6x9nxUWO/203Nit4ZoORUSs9/1F3dmKh7Gc+PoGD4FapUB8fepmrY7+EF3fxDTvf +95xhszWYijqy7DwaNz9+j5LP2RIUZNoQAhVB/0/E6xyjyfqZ90bp4RjZsbgyLcsUDFDYg2WD7rlc +z8sFWkz6GZdr1l0T08JcVLwyc6B49fFtHsufpaafItzRUZ6CeWRgKRM+o/1Pcmqr4tTluCRVLERL +iohEnMqE0yo7AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1Ud +DgQWBBSCIS1mxteg4BXrzkwJd8RgnlRuAzANBgkqhkiG9w0BAQsFAAOCAgEAYN4auOfyYILVAzOB +ywaK8SJJ6ejqkX/GM15oGQOGO0MBzwdw5AgeZYWR5hEit/UCI46uuR59H35s5r0l1ZUa8gWmr4UC +b6741jH/JclKyMeKqdmfS0mbEVeZkkMR3rYzpMzXjWR91M08KCy0mpbqTfXERMQlqiCA2ClV9+BB +/AYm/7k29UMUA2Z44RGx2iBfRgB4ACGlHgAoYXhvqAEBj500mv/0OJD7uNGzcgbJceaBxXntC6Z5 +8hMLnPddDnskk7RI24Zf3lCGeOdA5jGokHZwYa+cNywRtYK3qq4kNFtyDGkNzVmf9nGvnAvRCjj5 +BiKDUyUM/FHE5r7iOZULJK2v0ZXkltd0ZGtxTgI8qoXzIKNDOXZbbFD+mpwUHmUUihW9o4JFWklW +atKcsWMy5WHgUyIOpwpJ6st+H6jiYoD2EEVSmAYY3qXNL3+q1Ok+CHLsIwMCPKaq2LxndD0UF/tU +Sxfj03k9bWtJySgOLnRQvwzZRjoQhsmnP+mg7H/rpXdYaXHmgwo38oZJar55CJD2AhZkPuXaTH4M +NMn5X7azKFGnpyuqSfqNZSlO42sTp5SjLVFteAxEy9/eCG/Oo2Sr05WE1LlSVHJ7liXMvGnjSG4N +0MedJ5qq+BOS3R7fY581qRY27Iy4g/Q9iY/NtBde17MXQRBdJ3NghVdJIgc= +-----END CERTIFICATE----- + +Trustwave Global Certification Authority +======================================== +-----BEGIN CERTIFICATE----- +MIIF2jCCA8KgAwIBAgIMBfcOhtpJ80Y1LrqyMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJV +UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2 +ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTAeFw0xNzA4MjMxOTM0MTJaFw00MjA4MjMxOTM0MTJaMIGIMQswCQYDVQQGEwJV +UzERMA8GA1UECAwISWxsaW5vaXMxEDAOBgNVBAcMB0NoaWNhZ28xITAfBgNVBAoMGFRydXN0d2F2 +ZSBIb2xkaW5ncywgSW5jLjExMC8GA1UEAwwoVHJ1c3R3YXZlIEdsb2JhbCBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALldUShLPDeS0YLOvR29 +zd24q88KPuFd5dyqCblXAj7mY2Hf8g+CY66j96xz0XznswuvCAAJWX/NKSqIk4cXGIDtiLK0thAf +LdZfVaITXdHG6wZWiYj+rDKd/VzDBcdu7oaJuogDnXIhhpCujwOl3J+IKMujkkkP7NAP4m1ET4Bq +stTnoApTAbqOl5F2brz81Ws25kCI1nsvXwXoLG0R8+eyvpJETNKXpP7ScoFDB5zpET71ixpZfR9o +WN0EACyW80OzfpgZdNmcc9kYvkHHNHnZ9GLCQ7mzJ7Aiy/k9UscwR7PJPrhq4ufogXBeQotPJqX+ +OsIgbrv4Fo7NDKm0G2x2EOFYeUY+VM6AqFcJNykbmROPDMjWLBz7BegIlT1lRtzuzWniTY+HKE40 +Cz7PFNm73bZQmq131BnW2hqIyE4bJ3XYsgjxroMwuREOzYfwhI0Vcnyh78zyiGG69Gm7DIwLdVcE +uE4qFC49DxweMqZiNu5m4iK4BUBjECLzMx10coos9TkpoNPnG4CELcU9402x/RpvumUHO1jsQkUm ++9jaJXLE9gCxInm943xZYkqcBW89zubWR2OZxiRvchLIrH+QtAuRcOi35hYQcRfO3gZPSEF9NUqj +ifLJS3tBEW1ntwiYTOURGa5CgNz7kAXU+FDKvuStx8KU1xad5hePrzb7AgMBAAGjQjBAMA8GA1Ud +EwEB/wQFMAMBAf8wHQYDVR0OBBYEFJngGWcNYtt2s9o9uFvo/ULSMQ6HMA4GA1UdDwEB/wQEAwIB +BjANBgkqhkiG9w0BAQsFAAOCAgEAmHNw4rDT7TnsTGDZqRKGFx6W0OhUKDtkLSGm+J1WE2pIPU/H +PinbbViDVD2HfSMF1OQc3Og4ZYbFdada2zUFvXfeuyk3QAUHw5RSn8pk3fEbK9xGChACMf1KaA0H +ZJDmHvUqoai7PF35owgLEQzxPy0QlG/+4jSHg9bP5Rs1bdID4bANqKCqRieCNqcVtgimQlRXtpla +4gt5kNdXElE1GYhBaCXUNxeEFfsBctyV3lImIJgm4nb1J2/6ADtKYdkNy1GTKv0WBpanI5ojSP5R +vbbEsLFUzt5sQa0WZ37b/TjNuThOssFgy50X31ieemKyJo90lZvkWx3SD92YHJtZuSPTMaCm/zjd +zyBP6VhWOmfD0faZmZ26NraAL4hHT4a/RDqA5Dccprrql5gR0IRiR2Qequ5AvzSxnI9O4fKSTx+O +856X3vOmeWqJcU9LJxdI/uz0UA9PSX3MReO9ekDFQdxhVicGaeVyQYHTtgGJoC86cnn+OjC/QezH +Yj6RS8fZMXZC+fc8Y+wmjHMMfRod6qh8h6jCJ3zhM0EPz8/8AKAigJ5Kp28AsEFFtyLKaEjFQqKu +3R3y4G5OBVixwJAWKqQ9EEC+j2Jjg6mcgn0tAumDMHzLJ8n9HmYAsC7TIS+OMxZsmO0QqAfWzJPP +29FpHOTKyeC2nOnOcXHebD8WpHk= +-----END CERTIFICATE----- + +Trustwave Global ECC P256 Certification Authority +================================================= +-----BEGIN CERTIFICATE----- +MIICYDCCAgegAwIBAgIMDWpfCD8oXD5Rld9dMAoGCCqGSM49BAMCMIGRMQswCQYDVQQGEwJVUzER +MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI +b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1NiBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM1MTBaFw00MjA4MjMxOTM1MTBaMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy +dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDI1 +NiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABH77bOYj +43MyCMpg5lOcunSNGLB4kFKA3TjASh3RqMyTpJcGOMoNFWLGjgEqZZ2q3zSRLoHB5DOSMcT9CTqm +P62jQzBBMA8GA1UdEwEB/wQFMAMBAf8wDwYDVR0PAQH/BAUDAwcGADAdBgNVHQ4EFgQUo0EGrJBt +0UrrdaVKEJmzsaGLSvcwCgYIKoZIzj0EAwIDRwAwRAIgB+ZU2g6gWrKuEZ+Hxbb/ad4lvvigtwjz +RM4q3wghDDcCIC0mA6AFvWvR9lz4ZcyGbbOcNEhjhAnFjXca4syc4XR7 +-----END CERTIFICATE----- + +Trustwave Global ECC P384 Certification Authority +================================================= +-----BEGIN CERTIFICATE----- +MIICnTCCAiSgAwIBAgIMCL2Fl2yZJ6SAaEc7MAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzER +MA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRydXN0d2F2ZSBI +b2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4NCBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MjMxOTM2NDNaFw00MjA4MjMxOTM2NDNaMIGRMQswCQYD +VQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNVBAcTB0NoaWNhZ28xITAfBgNVBAoTGFRy +dXN0d2F2ZSBIb2xkaW5ncywgSW5jLjE6MDgGA1UEAxMxVHJ1c3R3YXZlIEdsb2JhbCBFQ0MgUDM4 +NCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGvaDXU1CDFH +Ba5FmVXxERMuSvgQMSOjfoPTfygIOiYaOs+Xgh+AtycJj9GOMMQKmw6sWASr9zZ9lCOkmwqKi6vr +/TklZvFe/oyujUF5nQlgziip04pt89ZF1PKYhDhloKNDMEEwDwYDVR0TAQH/BAUwAwEB/zAPBgNV +HQ8BAf8EBQMDBwYAMB0GA1UdDgQWBBRVqYSJ0sEyvRjLbKYHTsjnnb6CkDAKBggqhkjOPQQDAwNn +ADBkAjA3AZKXRRJ+oPM+rRk6ct30UJMDEr5E0k9BpIycnR+j9sKS50gU/k6bpZFXrsY3crsCMGcl +CrEMXu6pY5Jv5ZAL/mYiykf9ijH3g/56vxC+GCsej/YpHpRZ744hN8tRmKVuSw== +-----END CERTIFICATE----- + +NAVER Global Root Certification Authority +========================================= +-----BEGIN CERTIFICATE----- +MIIFojCCA4qgAwIBAgIUAZQwHqIL3fXFMyqxQ0Rx+NZQTQ0wDQYJKoZIhvcNAQEMBQAwaTELMAkG +A1UEBhMCS1IxJjAkBgNVBAoMHU5BVkVSIEJVU0lORVNTIFBMQVRGT1JNIENvcnAuMTIwMAYDVQQD +DClOQVZFUiBHbG9iYWwgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0xNzA4MTgwODU4 +NDJaFw0zNzA4MTgyMzU5NTlaMGkxCzAJBgNVBAYTAktSMSYwJAYDVQQKDB1OQVZFUiBCVVNJTkVT +UyBQTEFURk9STSBDb3JwLjEyMDAGA1UEAwwpTkFWRVIgR2xvYmFsIFJvb3QgQ2VydGlmaWNhdGlv +biBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC21PGTXLVAiQqrDZBb +UGOukJR0F0Vy1ntlWilLp1agS7gvQnXp2XskWjFlqxcX0TM62RHcQDaH38dq6SZeWYp34+hInDEW ++j6RscrJo+KfziFTowI2MMtSAuXaMl3Dxeb57hHHi8lEHoSTGEq0n+USZGnQJoViAbbJAh2+g1G7 +XNr4rRVqmfeSVPc0W+m/6imBEtRTkZazkVrd/pBzKPswRrXKCAfHcXLJZtM0l/aM9BhK4dA9WkW2 +aacp+yPOiNgSnABIqKYPszuSjXEOdMWLyEz59JuOuDxp7W87UC9Y7cSw0BwbagzivESq2M0UXZR4 +Yb8ObtoqvC8MC3GmsxY/nOb5zJ9TNeIDoKAYv7vxvvTWjIcNQvcGufFt7QSUqP620wbGQGHfnZ3z +VHbOUzoBppJB7ASjjw2i1QnK1sua8e9DXcCrpUHPXFNwcMmIpi3Ua2FzUCaGYQ5fG8Ir4ozVu53B +A0K6lNpfqbDKzE0K70dpAy8i+/Eozr9dUGWokG2zdLAIx6yo0es+nPxdGoMuK8u180SdOqcXYZai +cdNwlhVNt0xz7hlcxVs+Qf6sdWA7G2POAN3aCJBitOUt7kinaxeZVL6HSuOpXgRM6xBtVNbv8ejy +YhbLgGvtPe31HzClrkvJE+2KAQHJuFFYwGY6sWZLxNUxAmLpdIQM201GLQIDAQABo0IwQDAdBgNV +HQ4EFgQU0p+I36HNLL3s9TsBAZMzJ7LrYEswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQEMBQADggIBADLKgLOdPVQG3dLSLvCkASELZ0jKbY7gyKoNqo0hV4/GPnrK +21HUUrPUloSlWGB/5QuOH/XcChWB5Tu2tyIvCZwTFrFsDDUIbatjcu3cvuzHV+YwIHHW1xDBE1UB +jCpD5EHxzzp6U5LOogMFDTjfArsQLtk70pt6wKGm+LUx5vR1yblTmXVHIloUFcd4G7ad6Qz4G3bx +hYTeodoS76TiEJd6eN4MUZeoIUCLhr0N8F5OSza7OyAfikJW4Qsav3vQIkMsRIz75Sq0bBwcupTg +E34h5prCy8VCZLQelHsIJchxzIdFV4XTnyliIoNRlwAYl3dqmJLJfGBs32x9SuRwTMKeuB330DTH +D8z7p/8Dvq1wkNoL3chtl1+afwkyQf3NosxabUzyqkn+Zvjp2DXrDige7kgvOtB5CTh8piKCk5XQ +A76+AqAF3SAi428diDRgxuYKuQl1C/AH6GmWNcf7I4GOODm4RStDeKLRLBT/DShycpWbXgnbiUSY +qqFJu3FS8r/2/yehNq+4tneI3TqkbZs0kNwUXTC/t+sX5Ie3cdCh13cV1ELX8vMxmV2b3RZtP+oG +I/hGoiLtk/bdmuYqh7GYVPEi92tF4+KOdh2ajcQGjTa3FPOdVGm3jjzVpG2Tgbet9r1ke8LJaDmg +kpzNNIaRkPpkUZ3+/uul9XXeifdy +-----END CERTIFICATE----- + +AC RAIZ FNMT-RCM SERVIDORES SEGUROS +=================================== +-----BEGIN CERTIFICATE----- +MIICbjCCAfOgAwIBAgIQYvYybOXE42hcG2LdnC6dlTAKBggqhkjOPQQDAzB4MQswCQYDVQQGEwJF +UzERMA8GA1UECgwIRk5NVC1SQ00xDjAMBgNVBAsMBUNlcmVzMRgwFgYDVQRhDA9WQVRFUy1RMjgy +NjAwNEoxLDAqBgNVBAMMI0FDIFJBSVogRk5NVC1SQ00gU0VSVklET1JFUyBTRUdVUk9TMB4XDTE4 +MTIyMDA5MzczM1oXDTQzMTIyMDA5MzczM1oweDELMAkGA1UEBhMCRVMxETAPBgNVBAoMCEZOTVQt +UkNNMQ4wDAYDVQQLDAVDZXJlczEYMBYGA1UEYQwPVkFURVMtUTI4MjYwMDRKMSwwKgYDVQQDDCNB +QyBSQUlaIEZOTVQtUkNNIFNFUlZJRE9SRVMgU0VHVVJPUzB2MBAGByqGSM49AgEGBSuBBAAiA2IA +BPa6V1PIyqvfNkpSIeSX0oNnnvBlUdBeh8dHsVnyV0ebAAKTRBdp20LHsbI6GA60XYyzZl2hNPk2 +LEnb80b8s0RpRBNm/dfF/a82Tc4DTQdxz69qBdKiQ1oKUm8BA06Oi6NCMEAwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFAG5L++/EYZg8k/QQW6rcx/n0m5JMAoGCCqG +SM49BAMDA2kAMGYCMQCuSuMrQMN0EfKVrRYj3k4MGuZdpSRea0R7/DjiT8ucRRcRTBQnJlU5dUoD +zBOQn5ICMQD6SmxgiHPz7riYYqnOK8LZiqZwMR2vsJRM60/G49HzYqc8/5MuB1xJAWdpEgJyv+c= +-----END CERTIFICATE----- + +GlobalSign Root R46 +=================== +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUAMEYxCzAJBgNV +BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJv +b3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAX +BgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08Es +CVeJOaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQGvGIFAha/ +r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud316HCkD7rRlr+/fKYIje +2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo0q3v84RLHIf8E6M6cqJaESvWJ3En7YEt +bWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSEy132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvj +K8Cd+RTyG/FWaha/LIWFzXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD4 +12lPFzYE+cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCNI/on +ccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzsx2sZy/N78CsHpdls +eVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqaByFrgY/bxFn63iLABJzjqls2k+g9 +vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEM +BQADggIBAHx47PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg +JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti2kM3S+LGteWy +gxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIkpnnpHs6i58FZFZ8d4kuaPp92 +CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRFFRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZm +OUdkLG5NrmJ7v2B0GbhWrJKsFjLtrWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qq +JZ4d16GLuc1CLgSkZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwye +qiv5u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP4vkYxboz +nxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6N3ec592kD3ZDZopD8p/7 +DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3vouXsXgxT7PntgMTzlSdriVZzH81Xwj3 +QEUxeCp6 +-----END CERTIFICATE----- + +GlobalSign Root E46 +=================== +-----BEGIN CERTIFICATE----- +MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYxCzAJBgNVBAYT +AkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJvb3Qg +RTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNV +BAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkB +jtjqR+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGddyXqBPCCj +QjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQxCpCPtsad0kRL +gLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZk +vLtoURMMA/cVi4RguYv/Uo7njLwcAjA8+RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+ +CAezNIm8BZ/3Hobui3A= +-----END CERTIFICATE----- + +GLOBALTRUST 2020 +================ +-----BEGIN CERTIFICATE----- +MIIFgjCCA2qgAwIBAgILWku9WvtPilv6ZeUwDQYJKoZIhvcNAQELBQAwTTELMAkGA1UEBhMCQVQx +IzAhBgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVT +VCAyMDIwMB4XDTIwMDIxMDAwMDAwMFoXDTQwMDYxMDAwMDAwMFowTTELMAkGA1UEBhMCQVQxIzAh +BgNVBAoTGmUtY29tbWVyY2UgbW9uaXRvcmluZyBHbWJIMRkwFwYDVQQDExBHTE9CQUxUUlVTVCAy +MDIwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAri5WrRsc7/aVj6B3GyvTY4+ETUWi +D59bRatZe1E0+eyLinjF3WuvvcTfk0Uev5E4C64OFudBc/jbu9G4UeDLgztzOG53ig9ZYybNpyrO +VPu44sB8R85gfD+yc/LAGbaKkoc1DZAoouQVBGM+uq/ufF7MpotQsjj3QWPKzv9pj2gOlTblzLmM +CcpL3TGQlsjMH/1WljTbjhzqLL6FLmPdqqmV0/0plRPwyJiT2S0WR5ARg6I6IqIoV6Lr/sCMKKCm +fecqQjuCgGOlYx8ZzHyyZqjC0203b+J+BlHZRYQfEs4kUmSFC0iAToexIiIwquuuvuAC4EDosEKA +A1GqtH6qRNdDYfOiaxaJSaSjpCuKAsR49GiKweR6NrFvG5Ybd0mN1MkGco/PU+PcF4UgStyYJ9OR +JitHHmkHr96i5OTUawuzXnzUJIBHKWk7buis/UDr2O1xcSvy6Fgd60GXIsUf1DnQJ4+H4xj04KlG +DfV0OoIu0G4skaMxXDtG6nsEEFZegB31pWXogvziB4xiRfUg3kZwhqG8k9MedKZssCz3AwyIDMvU +clOGvGBG85hqwvG/Q/lwIHfKN0F5VVJjjVsSn8VoxIidrPIwq7ejMZdnrY8XD2zHc+0klGvIg5rQ +mjdJBKuxFshsSUktq6HQjJLyQUp5ISXbY9e2nKd+Qmn7OmMCAwEAAaNjMGEwDwYDVR0TAQH/BAUw +AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFNwuH9FhN3nkq9XVsxJxaD1qaJwiMB8GA1Ud +IwQYMBaAFNwuH9FhN3nkq9XVsxJxaD1qaJwiMA0GCSqGSIb3DQEBCwUAA4ICAQCR8EICaEDuw2jA +VC/f7GLDw56KoDEoqoOOpFaWEhCGVrqXctJUMHytGdUdaG/7FELYjQ7ztdGl4wJCXtzoRlgHNQIw +4Lx0SsFDKv/bGtCwr2zD/cuz9X9tAy5ZVp0tLTWMstZDFyySCstd6IwPS3BD0IL/qMy/pJTAvoe9 +iuOTe8aPmxadJ2W8esVCgmxcB9CpwYhgROmYhRZf+I/KARDOJcP5YBugxZfD0yyIMaK9MOzQ0MAS +8cE54+X1+NZK3TTN+2/BT+MAi1bikvcoskJ3ciNnxz8RFbLEAwW+uxF7Cr+obuf/WEPPm2eggAe2 +HcqtbepBEX4tdJP7wry+UUTF72glJ4DjyKDUEuzZpTcdN3y0kcra1LGWge9oXHYQSa9+pTeAsRxS +vTOBTI/53WXZFM2KJVj04sWDpQmQ1GwUY7VA3+vA/MRYfg0UFodUJ25W5HCEuGwyEn6CMUO+1918 +oa2u1qsgEu8KwxCMSZY13At1XrFP1U80DhEgB3VDRemjEdqso5nCtnkn4rnvyOL2NSl6dPrFf4IF +YqYK6miyeUcGbvJXqBUzxvd4Sj1Ce2t+/vdG6tHrju+IaFvowdlxfv1k7/9nR4hYJS8+hge9+6jl +gqispdNpQ80xiEmEU5LAsTkbOYMBMMTyqfrQA71yN2BWHzZ8vTmR9W0Nv3vXkg== +-----END CERTIFICATE----- + +ANF Secure Server Root CA +========================= +-----BEGIN CERTIFICATE----- +MIIF7zCCA9egAwIBAgIIDdPjvGz5a7EwDQYJKoZIhvcNAQELBQAwgYQxEjAQBgNVBAUTCUc2MzI4 +NzUxMDELMAkGA1UEBhMCRVMxJzAlBgNVBAoTHkFORiBBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lv +bjEUMBIGA1UECxMLQU5GIENBIFJhaXoxIjAgBgNVBAMTGUFORiBTZWN1cmUgU2VydmVyIFJvb3Qg +Q0EwHhcNMTkwOTA0MTAwMDM4WhcNMzkwODMwMTAwMDM4WjCBhDESMBAGA1UEBRMJRzYzMjg3NTEw +MQswCQYDVQQGEwJFUzEnMCUGA1UEChMeQU5GIEF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uMRQw +EgYDVQQLEwtBTkYgQ0EgUmFpejEiMCAGA1UEAxMZQU5GIFNlY3VyZSBTZXJ2ZXIgUm9vdCBDQTCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANvrayvmZFSVgpCjcqQZAZ2cC4Ffc0m6p6zz +BE57lgvsEeBbphzOG9INgxwruJ4dfkUyYA8H6XdYfp9qyGFOtibBTI3/TO80sh9l2Ll49a2pcbnv +T1gdpd50IJeh7WhM3pIXS7yr/2WanvtH2Vdy8wmhrnZEE26cLUQ5vPnHO6RYPUG9tMJJo8gN0pcv +B2VSAKduyK9o7PQUlrZXH1bDOZ8rbeTzPvY1ZNoMHKGESy9LS+IsJJ1tk0DrtSOOMspvRdOoiXse +zx76W0OLzc2oD2rKDF65nkeP8Nm2CgtYZRczuSPkdxl9y0oukntPLxB3sY0vaJxizOBQ+OyRp1RM +VwnVdmPF6GUe7m1qzwmd+nxPrWAI/VaZDxUse6mAq4xhj0oHdkLePfTdsiQzW7i1o0TJrH93PB0j +7IKppuLIBkwC/qxcmZkLLxCKpvR/1Yd0DVlJRfbwcVw5Kda/SiOL9V8BY9KHcyi1Swr1+KuCLH5z +JTIdC2MKF4EA/7Z2Xue0sUDKIbvVgFHlSFJnLNJhiQcND85Cd8BEc5xEUKDbEAotlRyBr+Qc5RQe +8TZBAQIvfXOn3kLMTOmJDVb3n5HUA8ZsyY/b2BzgQJhdZpmYgG4t/wHFzstGH6wCxkPmrqKEPMVO +Hj1tyRRM4y5Bu8o5vzY8KhmqQYdOpc5LMnndkEl/AgMBAAGjYzBhMB8GA1UdIwQYMBaAFJxf0Gxj +o1+TypOYCK2Mh6UsXME3MB0GA1UdDgQWBBScX9BsY6Nfk8qTmAitjIelLFzBNzAOBgNVHQ8BAf8E +BAMCAYYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEATh65isagmD9uw2nAalxJ +UqzLK114OMHVVISfk/CHGT0sZonrDUL8zPB1hT+L9IBdeeUXZ701guLyPI59WzbLWoAAKfLOKyzx +j6ptBZNscsdW699QIyjlRRA96Gejrw5VD5AJYu9LWaL2U/HANeQvwSS9eS9OICI7/RogsKQOLHDt +dD+4E5UGUcjohybKpFtqFiGS3XNgnhAY3jyB6ugYw3yJ8otQPr0R4hUDqDZ9MwFsSBXXiJCZBMXM +5gf0vPSQ7RPi6ovDj6MzD8EpTBNO2hVWcXNyglD2mjN8orGoGjR0ZVzO0eurU+AagNjqOknkJjCb +5RyKqKkVMoaZkgoQI1YS4PbOTOK7vtuNknMBZi9iPrJyJ0U27U1W45eZ/zo1PqVUSlJZS2Db7v54 +EX9K3BR5YLZrZAPbFYPhor72I5dQ8AkzNqdxliXzuUJ92zg/LFis6ELhDtjTO0wugumDLmsx2d1H +hk9tl5EuT+IocTUW0fJz/iUrB0ckYyfI+PbZa/wSMVYIwFNCr5zQM378BvAxRAMU8Vjq8moNqRGy +g77FGr8H6lnco4g175x2MjxNBiLOFeXdntiP2t7SxDnlF4HPOEfrf4htWRvfn0IUrn7PqLBmZdo3 +r5+qPeoott7VMVgWglvquxl1AnMaykgaIZOQCo6ThKd9OyMYkomgjaw= +-----END CERTIFICATE----- + +Certum EC-384 CA +================ +-----BEGIN CERTIFICATE----- +MIICZTCCAeugAwIBAgIQeI8nXIESUiClBNAt3bpz9DAKBggqhkjOPQQDAzB0MQswCQYDVQQGEwJQ +TDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy +dGlmaWNhdGlvbiBBdXRob3JpdHkxGTAXBgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwHhcNMTgwMzI2 +MDcyNDU0WhcNNDMwMzI2MDcyNDU0WjB0MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERh +dGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx +GTAXBgNVBAMTEENlcnR1bSBFQy0zODQgQ0EwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATEKI6rGFtq +vm5kN2PkzeyrOvfMobgOgknXhimfoZTy42B4mIF4Bk3y7JoOV2CDn7TmFy8as10CW4kjPMIRBSqn +iBMY81CE1700LCeJVf/OTOffph8oxPBUw7l8t1Ot68KjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFI0GZnQkdjrzife81r1HfS+8EF9LMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNo +ADBlAjADVS2m5hjEfO/JUG7BJw+ch69u1RsIGL2SKcHvlJF40jocVYli5RsJHrpka/F2tNQCMQC0 +QoSZ/6vnnvuRlydd3LBbMHHOXjgaatkl5+r3YZJW+OraNsKHZZYuciUvf9/DE8k= +-----END CERTIFICATE----- + +Certum Trusted Root CA +====================== +-----BEGIN CERTIFICATE----- +MIIFwDCCA6igAwIBAgIQHr9ZULjJgDdMBvfrVU+17TANBgkqhkiG9w0BAQ0FADB6MQswCQYDVQQG +EwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0g +Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0Ew +HhcNMTgwMzE2MTIxMDEzWhcNNDMwMzE2MTIxMDEzWjB6MQswCQYDVQQGEwJQTDEhMB8GA1UEChMY +QXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBB +dXRob3JpdHkxHzAdBgNVBAMTFkNlcnR1bSBUcnVzdGVkIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDRLY67tzbqbTeRn06TpwXkKQMlzhyC93yZn0EGze2jusDbCSzBfN8p +fktlL5On1AFrAygYo9idBcEq2EXxkd7fO9CAAozPOA/qp1x4EaTByIVcJdPTsuclzxFUl6s1wB52 +HO8AU5853BSlLCIls3Jy/I2z5T4IHhQqNwuIPMqw9MjCoa68wb4pZ1Xi/K1ZXP69VyywkI3C7Te2 +fJmItdUDmj0VDT06qKhF8JVOJVkdzZhpu9PMMsmN74H+rX2Ju7pgE8pllWeg8xn2A1bUatMn4qGt +g/BKEiJ3HAVz4hlxQsDsdUaakFjgao4rpUYwBI4Zshfjvqm6f1bxJAPXsiEodg42MEx51UGamqi4 +NboMOvJEGyCI98Ul1z3G4z5D3Yf+xOr1Uz5MZf87Sst4WmsXXw3Hw09Omiqi7VdNIuJGmj8PkTQk +fVXjjJU30xrwCSss0smNtA0Aq2cpKNgB9RkEth2+dv5yXMSFytKAQd8FqKPVhJBPC/PgP5sZ0jeJ +P/J7UhyM9uH3PAeXjA6iWYEMspA90+NZRu0PqafegGtaqge2Gcu8V/OXIXoMsSt0Puvap2ctTMSY +njYJdmZm/Bo/6khUHL4wvYBQv3y1zgD2DGHZ5yQD4OMBgQ692IU0iL2yNqh7XAjlRICMb/gv1SHK +HRzQ+8S1h9E6Tsd2tTVItQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSM+xx1 +vALTn04uSNn5YFSqxLNP+jAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQENBQADggIBAEii1QAL +LtA/vBzVtVRJHlpr9OTy4EA34MwUe7nJ+jW1dReTagVphZzNTxl4WxmB82M+w85bj/UvXgF2Ez8s +ALnNllI5SW0ETsXpD4YN4fqzX4IS8TrOZgYkNCvozMrnadyHncI013nR03e4qllY/p0m+jiGPp2K +h2RX5Rc64vmNueMzeMGQ2Ljdt4NR5MTMI9UGfOZR0800McD2RrsLrfw9EAUqO0qRJe6M1ISHgCq8 +CYyqOhNf6DR5UMEQGfnTKB7U0VEwKbOukGfWHwpjscWpxkIxYxeU72nLL/qMFH3EQxiJ2fAyQOaA +4kZf5ePBAFmo+eggvIksDkc0C+pXwlM2/KfUrzHN/gLldfq5Jwn58/U7yn2fqSLLiMmq0Uc9Nneo +WWRrJ8/vJ8HjJLWG965+Mk2weWjROeiQWMODvA8s1pfrzgzhIMfatz7DP78v3DSk+yshzWePS/Tj +6tQ/50+6uaWTRRxmHyH6ZF5v4HaUMst19W7l9o/HuKTMqJZ9ZPskWkoDbGs4xugDQ5r3V7mzKWmT +OPQD8rv7gmsHINFSH5pkAnuYZttcTVoP0ISVoDwUQwbKytu4QTbaakRnh6+v40URFWkIsr4WOZck +bxJF0WddCajJFdr60qZfE2Efv4WstK2tBZQIgx51F9NxO5NQI1mg7TyRVJ12AMXDuDjb +-----END CERTIFICATE----- + +TunTrust Root CA +================ +-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIUEwLV4kBMkkaGFmddtLu7sms+/BMwDQYJKoZIhvcNAQELBQAwYTELMAkG +A1UEBhMCVE4xNzA1BgNVBAoMLkFnZW5jZSBOYXRpb25hbGUgZGUgQ2VydGlmaWNhdGlvbiBFbGVj +dHJvbmlxdWUxGTAXBgNVBAMMEFR1blRydXN0IFJvb3QgQ0EwHhcNMTkwNDI2MDg1NzU2WhcNNDQw +NDI2MDg1NzU2WjBhMQswCQYDVQQGEwJUTjE3MDUGA1UECgwuQWdlbmNlIE5hdGlvbmFsZSBkZSBD +ZXJ0aWZpY2F0aW9uIEVsZWN0cm9uaXF1ZTEZMBcGA1UEAwwQVHVuVHJ1c3QgUm9vdCBDQTCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMPN0/y9BFPdDCA61YguBUtB9YOCfvdZn56eY+hz +2vYGqU8ftPkLHzmMmiDQfgbU7DTZhrx1W4eI8NLZ1KMKsmwb60ksPqxd2JQDoOw05TDENX37Jk0b +bjBU2PWARZw5rZzJJQRNmpA+TkBuimvNKWfGzC3gdOgFVwpIUPp6Q9p+7FuaDmJ2/uqdHYVy7BG7 +NegfJ7/Boce7SBbdVtfMTqDhuazb1YMZGoXRlJfXyqNlC/M4+QKu3fZnz8k/9YosRxqZbwUN/dAd +gjH8KcwAWJeRTIAAHDOFli/LQcKLEITDCSSJH7UP2dl3RxiSlGBcx5kDPP73lad9UKGAwqmDrViW +VSHbhlnUr8a83YFuB9tgYv7sEG7aaAH0gxupPqJbI9dkxt/con3YS7qC0lH4Zr8GRuR5KiY2eY8f +Tpkdso8MDhz/yV3A/ZAQprE38806JG60hZC/gLkMjNWb1sjxVj8agIl6qeIbMlEsPvLfe/ZdeikZ +juXIvTZxi11Mwh0/rViizz1wTaZQmCXcI/m4WEEIcb9PuISgjwBUFfyRbVinljvrS5YnzWuioYas +DXxU5mZMZl+QviGaAkYt5IPCgLnPSz7ofzwB7I9ezX/SKEIBlYrilz0QIX32nRzFNKHsLA4KUiwS +VXAkPcvCFDVDXSdOvsC9qnyW5/yeYa1E0wCXAgMBAAGjYzBhMB0GA1UdDgQWBBQGmpsfU33x9aTI +04Y+oXNZtPdEITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFAaamx9TffH1pMjThj6hc1m0 +90QhMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAqgVutt0Vyb+zxiD2BkewhpMl +0425yAA/l/VSJ4hxyXT968pk21vvHl26v9Hr7lxpuhbI87mP0zYuQEkHDVneixCwSQXi/5E/S7fd +Ao74gShczNxtr18UnH1YeA32gAm56Q6XKRm4t+v4FstVEuTGfbvE7Pi1HE4+Z7/FXxttbUcoqgRY +YdZ2vyJ/0Adqp2RT8JeNnYA/u8EH22Wv5psymsNUk8QcCMNE+3tjEUPRahphanltkE8pjkcFwRJp +adbGNjHh/PqAulxPxOu3Mqz4dWEX1xAZufHSCe96Qp1bWgvUxpVOKs7/B9dPfhgGiPEZtdmYu65x +xBzndFlY7wyJz4sfdZMaBBSSSFCp61cpABbjNhzI+L/wM9VBD8TMPN3pM0MBkRArHtG5Xc0yGYuP +jCB31yLEQtyEFpslbei0VXF/sHyz03FJuc9SpAQ/3D2gu68zngowYI7bnV2UqL1g52KAdoGDDIzM +MEZJ4gzSqK/rYXHv5yJiqfdcZGyfFoxnNidF9Ql7v/YQCvGwjVRDjAS6oz/v4jXH+XTgbzRB0L9z +ZVcg+ZtnemZoJE6AZb0QmQZZ8mWvuMZHu/2QeItBcy6vVR/cO5JyboTT0GFMDcx2V+IthSIVNg3r +AZ3r2OvEhJn7wAzMMujjd9qDRIueVSjAi1jTkD5OGwDxFa2DK5o= +-----END CERTIFICATE----- + +HARICA TLS RSA Root CA 2021 +=========================== +-----BEGIN CERTIFICATE----- +MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQG +EwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u +cyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0EgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTEwNTUz +OFoXDTQ1MDIxMzEwNTUzN1owbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRl +bWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNB +IFJvb3QgQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569lmwVnlskN +JLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FHws+RFjZiPTgE4VGC/6zStGndLuwRo0Xu +a2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uva9of08WRiFukiZLRgeaMOVig1mlDqa2Y +Ulhu2wr7a89o+uOkXjpFc5gH6l8Cct4MpbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K +5FrZx40d/JiZ+yykgmvwKh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEv +dmn8kN3bLW7R8pv1GmuebxWMevBLKKAiOIAkbDakO/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcYAuUR +0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqBAGMUuTNe3QvboEUH +GjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYqE613TBoYm5EPWNgGVMWX+Ko/IIqm +haZ39qb8HOLubpQzKoNQhArlT4b4UEV4AIHrW2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQ +CPxrvrNQKlr9qEgYRtaQQJKQCoReaDH46+0N0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8G +A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLuMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAUX15QvWiWkKQU +EapobQk1OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3f5Z2EMVGpdAgS1D0NTsY9FVq +QRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar5AWMxajaH6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxD +QpSbIPDRzbLrLFPCU3hKTwSUQZqPJzLB5UkZv/HywouoCjkxKLR9YjYsTewfM7Z+d21+UPCfDtcR +j88YxeMn/ibvBZ3PzzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5 +vZStjBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0/L5H9MG0 +qPzVMIho7suuyWPEdr6sOBjhXlzPrjoiUevRi7PzKzMHVIf6tLITe7pTBGIBnfHAT+7hOtSLIBD6 +Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79aPib8qXPMThcFarmlwDB31qlpzmq6YR/ +PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YWxw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnn +kf3/W9b3raYvAwtt41dU63ZTGI0RmLo= +-----END CERTIFICATE----- + +HARICA TLS ECC Root CA 2021 +=========================== +-----BEGIN CERTIFICATE----- +MIICVDCCAdugAwIBAgIQZ3SdjXfYO2rbIvT/WeK/zjAKBggqhkjOPQQDAzBsMQswCQYDVQQGEwJH +UjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBD +QTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBFQ0MgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTExMDExMFoX +DTQ1MDIxMzExMDEwOVowbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWlj +IGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgRUNDIFJv +b3QgQ0EgMjAyMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDgI/rGgltJ6rK9JOtDA4MM7KKrxcm1l +AEeIhPyaJmuqS7psBAqIXhfyVYf8MLA04jRYVxqEU+kw2anylnTDUR9YSTHMmE5gEYd103KUkE+b +ECUqqHgtvpBBWJAVcqeht6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUyRtTgRL+BNUW +0aq8mm+3oJUZbsowDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2cAMGQCMBHervjcToiwqfAi +rcJRQO9gcS3ujwLEXQNwSaSS6sUUiHCm0w2wqsosQJz76YJumgIwK0eaB8bRwoF8yguWGEEbo/Qw +CZ61IygNnxS2PFOiTAZpffpskcYqSUXm7LcT4Tps +-----END CERTIFICATE----- + +Autoridad de Certificacion Firmaprofesional CIF A62634068 +========================================================= +-----BEGIN CERTIFICATE----- +MIIGFDCCA/ygAwIBAgIIG3Dp0v+ubHEwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCRVMxQjBA +BgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1hcHJvZmVzaW9uYWwgQ0lGIEE2 +MjYzNDA2ODAeFw0xNDA5MjMxNTIyMDdaFw0zNjA1MDUxNTIyMDdaMFExCzAJBgNVBAYTAkVTMUIw +QAYDVQQDDDlBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBB +NjI2MzQwNjgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDD +Utd9thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQMcas9UX4P +B99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefGL9ItWY16Ck6WaVICqjaY +7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15iNA9wBj4gGFrO93IbJWyTdBSTo3OxDqqH +ECNZXyAFGUftaI6SEspd/NYrspI8IM/hX68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyI +plD9amML9ZMWGxmPsu2bm8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctX +MbScyJCyZ/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirjaEbsX +LZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/TKI8xWVvTyQKmtFLK +bpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF6NkBiDkal4ZkQdU7hwxu+g/GvUgU +vzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVhOSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMB0GA1Ud +DgQWBBRlzeurNR4APn7VdMActHNHDhpkLzASBgNVHRMBAf8ECDAGAQH/AgEBMIGmBgNVHSAEgZ4w +gZswgZgGBFUdIAAwgY8wLwYIKwYBBQUHAgEWI2h0dHA6Ly93d3cuZmlybWFwcm9mZXNpb25hbC5j +b20vY3BzMFwGCCsGAQUFBwICMFAeTgBQAGEAcwBlAG8AIABkAGUAIABsAGEAIABCAG8AbgBhAG4A +bwB2AGEAIAA0ADcAIABCAGEAcgBjAGUAbABvAG4AYQAgADAAOAAwADEANzAOBgNVHQ8BAf8EBAMC +AQYwDQYJKoZIhvcNAQELBQADggIBAHSHKAIrdx9miWTtj3QuRhy7qPj4Cx2Dtjqn6EWKB7fgPiDL +4QjbEwj4KKE1soCzC1HA01aajTNFSa9J8OA9B3pFE1r/yJfY0xgsfZb43aJlQ3CTkBW6kN/oGbDb +LIpgD7dvlAceHabJhfa9NPhAeGIQcDq+fUs5gakQ1JZBu/hfHAsdCPKxsIl68veg4MSPi3i1O1il +I45PVf42O+AMt8oqMEEgtIDNrvx2ZnOorm7hfNoD6JQg5iKj0B+QXSBTFCZX2lSX3xZEEAEeiGaP +cjiT3SC3NL7X8e5jjkd5KAb881lFJWAiMxujX6i6KtoaPc1A6ozuBRWV1aUsIC+nmCjuRfzxuIgA +LI9C2lHVnOUTaHFFQ4ueCyE8S1wF3BqfmI7avSKecs2tCsvMo2ebKHTEm9caPARYpoKdrcd7b/+A +lun4jWq9GJAd/0kakFI3ky88Al2CdgtR5xbHV/g4+afNmyJU72OwFW1TZQNKXkqgsqeOSQBZONXH +9IBk9W6VULgRfhVwOEqwf9DEMnDAGf/JOC0ULGb0QkTmVXYbgBVX/8Cnp6o5qtjTcNAuuuuUavpf +NIbnYrX9ivAwhZTJryQCL2/W3Wf+47BVTwSYT6RBVuKT0Gro1vP7ZeDOdcQxWQzugsgMYDNKGbqE +ZycPvEJdvSRUDewdcAZfpLz6IHxV +-----END CERTIFICATE----- + +vTrus ECC Root CA +================= +-----BEGIN CERTIFICATE----- +MIICDzCCAZWgAwIBAgIUbmq8WapTvpg5Z6LSa6Q75m0c1towCgYIKoZIzj0EAwMwRzELMAkGA1UE +BhMCQ04xHDAaBgNVBAoTE2lUcnVzQ2hpbmEgQ28uLEx0ZC4xGjAYBgNVBAMTEXZUcnVzIEVDQyBS +b290IENBMB4XDTE4MDczMTA3MjY0NFoXDTQzMDczMTA3MjY0NFowRzELMAkGA1UEBhMCQ04xHDAa +BgNVBAoTE2lUcnVzQ2hpbmEgQ28uLEx0ZC4xGjAYBgNVBAMTEXZUcnVzIEVDQyBSb290IENBMHYw +EAYHKoZIzj0CAQYFK4EEACIDYgAEZVBKrox5lkqqHAjDo6LN/llWQXf9JpRCux3NCNtzslt188+c +ToL0v/hhJoVs1oVbcnDS/dtitN9Ti72xRFhiQgnH+n9bEOf+QP3A2MMrMudwpremIFUde4BdS49n +TPEQo0IwQDAdBgNVHQ4EFgQUmDnNvtiyjPeyq+GtJK97fKHbH88wDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwV53dVvHH4+m4SVBrm2nDb+zDfSXkV5UT +QJtS0zvzQBm8JsctBp61ezaf9SXUY2sAAjEA6dPGnlaaKsyh2j/IZivTWJwghfqrkYpwcBE4YGQL +YgmRWAD5Tfs0aNoJrSEGGJTO +-----END CERTIFICATE----- + +vTrus Root CA +============= +-----BEGIN CERTIFICATE----- +MIIFVjCCAz6gAwIBAgIUQ+NxE9izWRRdt86M/TX9b7wFjUUwDQYJKoZIhvcNAQELBQAwQzELMAkG +A1UEBhMCQ04xHDAaBgNVBAoTE2lUcnVzQ2hpbmEgQ28uLEx0ZC4xFjAUBgNVBAMTDXZUcnVzIFJv +b3QgQ0EwHhcNMTgwNzMxMDcyNDA1WhcNNDMwNzMxMDcyNDA1WjBDMQswCQYDVQQGEwJDTjEcMBoG +A1UEChMTaVRydXNDaGluYSBDby4sTHRkLjEWMBQGA1UEAxMNdlRydXMgUm9vdCBDQTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAL1VfGHTuB0EYgWgrmy3cLRB6ksDXhA/kFocizuwZots +SKYcIrrVQJLuM7IjWcmOvFjai57QGfIvWcaMY1q6n6MLsLOaXLoRuBLpDLvPbmyAhykUAyyNJJrI +ZIO1aqwTLDPxn9wsYTwaP3BVm60AUn/PBLn+NvqcwBauYv6WTEN+VRS+GrPSbcKvdmaVayqwlHeF +XgQPYh1jdfdr58tbmnDsPmcF8P4HCIDPKNsFxhQnL4Z98Cfe/+Z+M0jnCx5Y0ScrUw5XSmXX+6KA +YPxMvDVTAWqXcoKv8R1w6Jz1717CbMdHflqUhSZNO7rrTOiwCcJlwp2dCZtOtZcFrPUGoPc2BX70 +kLJrxLT5ZOrpGgrIDajtJ8nU57O5q4IikCc9Kuh8kO+8T/3iCiSn3mUkpF3qwHYw03dQ+A0Em5Q2 +AXPKBlim0zvc+gRGE1WKyURHuFE5Gi7oNOJ5y1lKCn+8pu8fA2dqWSslYpPZUxlmPCdiKYZNpGvu +/9ROutW04o5IWgAZCfEF2c6Rsffr6TlP9m8EQ5pV9T4FFL2/s1m02I4zhKOQUqqzApVg+QxMaPnu +1RcN+HFXtSXkKe5lXa/R7jwXC1pDxaWG6iSe4gUH3DRCEpHWOXSuTEGC2/KmSNGzm/MzqvOmwMVO +9fSddmPmAsYiS8GVP1BkLFTltvA8Kc9XAgMBAAGjQjBAMB0GA1UdDgQWBBRUYnBj8XWEQ1iO0RYg +scasGrz2iTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOC +AgEAKbqSSaet8PFww+SX8J+pJdVrnjT+5hpk9jprUrIQeBqfTNqK2uwcN1LgQkv7bHbKJAs5EhWd +nxEt/Hlk3ODg9d3gV8mlsnZwUKT+twpw1aA08XXXTUm6EdGz2OyC/+sOxL9kLX1jbhd47F18iMjr +jld22VkE+rxSH0Ws8HqA7Oxvdq6R2xCOBNyS36D25q5J08FsEhvMKar5CKXiNxTKsbhm7xqC5PD4 +8acWabfbqWE8n/Uxy+QARsIvdLGx14HuqCaVvIivTDUHKgLKeBRtRytAVunLKmChZwOgzoy8sHJn +xDHO2zTlJQNgJXtxmOTAGytfdELSS8VZCAeHvsXDf+eW2eHcKJfWjwXj9ZtOyh1QRwVTsMo554Wg +icEFOwE30z9J4nfrI8iIZjs9OXYhRvHsXyO466JmdXTBQPfYaJqT4i2pLr0cox7IdMakLXogqzu4 +sEb9b91fUlV1YvCXoHzXOP0l382gmxDPi7g4Xl7FtKYCNqEeXxzP4padKar9mK5S4fNBUvupLnKW +nyfjqnN9+BojZns7q2WwMgFLFT49ok8MKzWixtlnEjUwzXYuFrOZnk1PTi07NEPhmg4NpGaXutIc +SkwsKouLgU9xGqndXHt7CMUADTdA43x7VF8vhV929vensBxXVsFy6K2ir40zSbofitzmdHxghm+H +l3s= +-----END CERTIFICATE----- + +ISRG Root X2 +============ +-----BEGIN CERTIFICATE----- +MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQswCQYDVQQGEwJV +UzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElT +UkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVT +MSkwJwYDVQQKEyBJbnRlcm5ldCBTZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNS +RyBSb290IFgyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0H +ttwW+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9ItgKbppb +d9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZIzj0EAwMDaAAwZQIwe3lORlCEwkSHRhtF +cP9Ymd70/aTSVaYgLXTWNLxBo1BfASdWtL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5 +U6VR5CmD1/iQMVtCnwr1/q4AaOeMSQ+2b1tbFfLn +-----END CERTIFICATE----- + +HiPKI Root CA - G1 +================== +-----BEGIN CERTIFICATE----- +MIIFajCCA1KgAwIBAgIQLd2szmKXlKFD6LDNdmpeYDANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQG +EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xGzAZBgNVBAMMEkhpUEtJ +IFJvb3QgQ0EgLSBHMTAeFw0xOTAyMjIwOTQ2MDRaFw0zNzEyMzExNTU5NTlaME8xCzAJBgNVBAYT +AlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEbMBkGA1UEAwwSSGlQS0kg +Um9vdCBDQSAtIEcxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9B5/UnMyDHPkvRN0 +o9QwqNCuS9i233VHZvR85zkEHmpwINJaR3JnVfSl6J3VHiGh8Ge6zCFovkRTv4354twvVcg3Px+k +wJyz5HdcoEb+d/oaoDjq7Zpy3iu9lFc6uux55199QmQ5eiY29yTw1S+6lZgRZq2XNdZ1AYDgr/SE +YYwNHl98h5ZeQa/rh+r4XfEuiAU+TCK72h8q3VJGZDnzQs7ZngyzsHeXZJzA9KMuH5UHsBffMNsA +GJZMoYFL3QRtU6M9/Aes1MU3guvklQgZKILSQjqj2FPseYlgSGDIcpJQ3AOPgz+yQlda22rpEZfd +hSi8MEyr48KxRURHH+CKFgeW0iEPU8DtqX7UTuybCeyvQqww1r/REEXgphaypcXTT3OUM3ECoWqj +1jOXTyFjHluP2cFeRXF3D4FdXyGarYPM+l7WjSNfGz1BryB1ZlpK9p/7qxj3ccC2HTHsOyDry+K4 +9a6SsvfhhEvyovKTmiKe0xRvNlS9H15ZFblzqMF8b3ti6RZsR1pl8w4Rm0bZ/W3c1pzAtH2lsN0/ +Vm+h+fbkEkj9Bn8SV7apI09bA8PgcSojt/ewsTu8mL3WmKgMa/aOEmem8rJY5AIJEzypuxC00jBF +8ez3ABHfZfjcK0NVvxaXxA/VLGGEqnKG/uY6fsI/fe78LxQ+5oXdUG+3Se0CAwEAAaNCMEAwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ncX+l6o/vY9cdVouslGDDjYr7AwDgYDVR0PAQH/BAQD +AgGGMA0GCSqGSIb3DQEBCwUAA4ICAQBQUfB13HAE4/+qddRxosuej6ip0691x1TPOhwEmSKsxBHi +7zNKpiMdDg1H2DfHb680f0+BazVP6XKlMeJ45/dOlBhbQH3PayFUhuaVevvGyuqcSE5XCV0vrPSl +tJczWNWseanMX/mF+lLFjfiRFOs6DRfQUsJ748JzjkZ4Bjgs6FzaZsT0pPBWGTMpWmWSBUdGSquE +wx4noR8RkpkndZMPvDY7l1ePJlsMu5wP1G4wB9TcXzZoZjmDlicmisjEOf6aIW/Vcobpf2Lll07Q +JNBAsNB1CI69aO4I1258EHBGG3zgiLKecoaZAeO/n0kZtCW+VmWuF2PlHt/o/0elv+EmBYTksMCv +5wiZqAxeJoBF1PhoL5aPruJKHJwWDBNvOIf2u8g0X5IDUXlwpt/L9ZlNec1OvFefQ05rLisY+Gpz +jLrFNe85akEez3GoorKGB1s6yeHvP2UEgEcyRHCVTjFnanRbEEV16rCf0OY1/k6fi8wrkkVbbiVg +hUbN0aqwdmaTd5a+g744tiROJgvM7XpWGuDpWsZkrUx6AEhEL7lAuxM+vhV4nYWBSipX3tUZQ9rb +yltHhoMLP7YNdnhzeSJesYAfz77RP1YQmCuVh6EfnWQUYDksswBVLuT1sw5XxJFBAJw/6KXf6vb/ +yPCtbVKoF6ubYfwSUTXkJf2vqmqGOQ== +-----END CERTIFICATE----- + +GlobalSign ECC Root CA - R4 +=========================== +-----BEGIN CERTIFICATE----- +MIIB3DCCAYOgAwIBAgINAgPlfvU/k/2lCSGypjAKBggqhkjOPQQDAjBQMSQwIgYDVQQLExtHbG9i +YWxTaWduIEVDQyBSb290IENBIC0gUjQxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wHhcNMTIxMTEzMDAwMDAwWhcNMzgwMTE5MDMxNDA3WjBQMSQwIgYDVQQLExtHbG9i +YWxTaWduIEVDQyBSb290IENBIC0gUjQxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkds +b2JhbFNpZ24wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAS4xnnTj2wlDp8uORkcA6SumuU5BwkW +ymOxuYb4ilfBV85C+nOh92VC/x7BALJucw7/xyHlGKSq2XE/qNS5zowdo0IwQDAOBgNVHQ8BAf8E +BAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVLB7rUW44kB/+wpu+74zyTyjhNUwCgYI +KoZIzj0EAwIDRwAwRAIgIk90crlgr/HmnKAWBVBfw147bmF0774BxL4YSFlhgjICICadVGNA3jdg +UM/I2O2dgq43mLyjj0xMqTQrbO/7lZsm +-----END CERTIFICATE----- + +GTS Root R1 +=========== +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE +ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0G +CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaM +f/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7raKb0 +xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjwTcLCeoiKu7rPWRnWr4+w +B7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy4HqMLnXW +nOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk +9+aCEI3oncKKiPo4Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zq +kUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1A +K/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPvOmbsB4om3xPX +V2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDW +cfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQAD +ggIBAJ+qQibbC5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe +QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRi +ClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34ar +J45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYci +NuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5me +LMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJF +fbdT6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ0E6yove+ +7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm2tIMPNuzjsmhDYAPexZ3 +FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3 +gm3c +-----END CERTIFICATE----- + +GTS Root R2 +=========== +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJV +UzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3Qg +UjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UE +ChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0G +CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3Lv +CvptnfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY6Dlo7JUl +e3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAuMC6C/Pq8tBcKSOWIm8Wb +a96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7kRXuJVfeKH2JShBKzwkCX44ofR5GmdFrS ++LFjKBC4swm4VndAoiaYecb+3yXuPuWgf9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7M +kogwTZq9TwtImoS1mKPV+3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJG +r61K8YzodDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RWIr9q +S34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKaG73VululycslaVNV +J1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCqgc7dGtxRcw1PcOnlthYhGXmy5okL +dWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQAD +ggIBAB/Kzt3HvqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 +0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyCB19m3H0Q/gxh +swWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2uNmSRXbBoGOqKYcl3qJfEycel +/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMgyALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVn +jWQye+mew4K6Ki3pHrTgSAai/GevHyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y5 +9PYjJbigapordwj6xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M +7YNRTOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924SgJPFI/2R8 +0L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV7LXTWtiBmelDGDfrs7vR +WGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjW +HYbL +-----END CERTIFICATE----- + +GTS Root R3 +=========== +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPluILrIPglJ209ZjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJVUzEi +MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMw +HhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZ +R29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjMwdjAQBgcqhkjO +PQIBBgUrgQQAIgNiAAQfTzOHMymKoYTey8chWEGJ6ladK0uFxh1MJ7x/JlFyb+Kf1qPKzEUURout +736GjOyxfi//qXGdGIRFBEFVbivqJn+7kAHjSxm65FSWRQmx1WyRRK2EE46ajA2ADDL24CejQjBA +MA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTB8Sa6oC2uhYHP0/Eq +Er24Cmf9vDAKBggqhkjOPQQDAwNpADBmAjEA9uEglRR7VKOQFhG/hMjqb2sXnh5GmCCbn9MN2azT +L818+FsuVbu/3ZL3pAzcMeGiAjEA/JdmZuVDFhOD3cffL74UOO0BzrEXGhF16b0DjyZ+hOXJYKaV +11RZt+cRLInUue4X +-----END CERTIFICATE----- + +GTS Root R4 +=========== +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYDVQQGEwJVUzEi +MCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQw +HhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZ +R29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjO +PQIBBgUrgQQAIgNiAATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzu +hXyiQHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvRHYqjQjBA +MA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSATNbrdP9JNqPV2Py1 +PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/C +r8deVl5c1RxYIigL9zC2L7F8AjEA8GE8p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh +4rsUecrNIdSUtUlD +-----END CERTIFICATE----- + +Telia Root CA v2 +================ +-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIPAWdfJ9b+euPkrL4JWwWeMA0GCSqGSIb3DQEBCwUAMEQxCzAJBgNVBAYT +AkZJMRowGAYDVQQKDBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2 +MjAeFw0xODExMjkxMTU1NTRaFw00MzExMjkxMTU1NTRaMEQxCzAJBgNVBAYTAkZJMRowGAYDVQQK +DBFUZWxpYSBGaW5sYW5kIE95ajEZMBcGA1UEAwwQVGVsaWEgUm9vdCBDQSB2MjCCAiIwDQYJKoZI +hvcNAQEBBQADggIPADCCAgoCggIBALLQPwe84nvQa5n44ndp586dpAO8gm2h/oFlH0wnrI4AuhZ7 +6zBqAMCzdGh+sq/H1WKzej9Qyow2RCRj0jbpDIX2Q3bVTKFgcmfiKDOlyzG4OiIjNLh9vVYiQJ3q +9HsDrWj8soFPmNB06o3lfc1jw6P23pLCWBnglrvFxKk9pXSW/q/5iaq9lRdU2HhE8Qx3FZLgmEKn +pNaqIJLNwaCzlrI6hEKNfdWV5Nbb6WLEWLN5xYzTNTODn3WhUidhOPFZPY5Q4L15POdslv5e2QJl +tI5c0BE0312/UqeBAMN/mUWZFdUXyApT7GPzmX3MaRKGwhfwAZ6/hLzRUssbkmbOpFPlob/E2wnW +5olWK8jjfN7j/4nlNW4o6GwLI1GpJQXrSPjdscr6bAhR77cYbETKJuFzxokGgeWKrLDiKca5JLNr +RBH0pUPCTEPlcDaMtjNXepUugqD0XBCzYYP2AgWGLnwtbNwDRm41k9V6lS/eINhbfpSQBGq6WT0E +BXWdN6IOLj3rwaRSg/7Qa9RmjtzG6RJOHSpXqhC8fF6CfaamyfItufUXJ63RDolUK5X6wK0dmBR4 +M0KGCqlztft0DbcbMBnEWg4cJ7faGND/isgFuvGqHKI3t+ZIpEYslOqodmJHixBTB0hXbOKSTbau +BcvcwUpej6w9GU7C7WB1K9vBykLVAgMBAAGjYzBhMB8GA1UdIwQYMBaAFHKs5DN5qkWH9v2sHZ7W +xy+G2CQ5MB0GA1UdDgQWBBRyrOQzeapFh/b9rB2e1scvhtgkOTAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAoDtZpwmUPjaE0n4vOaWWl/oRrfxn83EJ +8rKJhGdEr7nv7ZbsnGTbMjBvZ5qsfl+yqwE2foH65IRe0qw24GtixX1LDoJt0nZi0f6X+J8wfBj5 +tFJ3gh1229MdqfDBmgC9bXXYfef6xzijnHDoRnkDry5023X4blMMA8iZGok1GTzTyVR8qPAs5m4H +eW9q4ebqkYJpCh3DflminmtGFZhb069GHWLIzoBSSRE/yQQSwxN8PzuKlts8oB4KtItUsiRnDe+C +y748fdHif64W1lZYudogsYMVoe+KTTJvQS8TUoKU1xrBeKJR3Stwbbca+few4GeXVtt8YVMJAygC +QMez2P2ccGrGKMOF6eLtGpOg3kuYooQ+BXcBlj37tCAPnHICehIv1aO6UXivKitEZU61/Qrowc15 +h2Er3oBXRb9n8ZuRXqWk7FlIEA04x7D6w0RtBPV4UBySllva9bguulvP5fBqnUsvWHMtTy3EHD70 +sz+rFQ47GUGKpMFXEmZxTPpT41frYpUJnlTd0cI8Vzy9OK2YZLe4A5pTVmBds9hCG1xLEooc6+t9 +xnppxyd/pPiL8uSUZodL6ZQHCRJ5irLrdATczvREWeAWysUsWNc8e89ihmpQfTU2Zqf7N+cox9jQ +raVplI/owd8k+BsHMYeB2F326CjYSlKArBPuUBQemMc= +-----END CERTIFICATE----- + +D-TRUST BR Root CA 1 2020 +========================= +-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQfMmPK4TX3+oPyWWa00tNljAKBggqhkjOPQQDAzBIMQswCQYDVQQGEwJE +RTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEJSIFJvb3QgQ0EgMSAy +MDIwMB4XDTIwMDIxMTA5NDUwMFoXDTM1MDIxMTA5NDQ1OVowSDELMAkGA1UEBhMCREUxFTATBgNV +BAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDEgMjAyMDB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABMbLxyjR+4T1mu9CFCDhQ2tuda38KwOE1HaTJddZO0Flax7mNCq7 +dPYSzuht56vkPE4/RAiLzRZxy7+SmfSk1zxQVFKQhYN4lGdnoxwJGT11NIXe7WB9xwy0QVK5buXu +QqOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFHOREKv/VbNafAkl1bK6CKBrqx9t +MA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6gPKA6hjhodHRwOi8vY3JsLmQtdHJ1c3Qu +bmV0L2NybC9kLXRydXN0X2JyX3Jvb3RfY2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVj +dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwQlIlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxP +PUQtVHJ1c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjOPQQD +AwNpADBmAjEAlJAtE/rhY/hhY+ithXhUkZy4kzg+GkHaQBZTQgjKL47xPoFWwKrY7RjEsK70Pvom +AjEA8yjixtsrmfu3Ubgko6SUeho/5jbiA1czijDLgsfWFBHVdWNbFJWcHwHP2NVypw87 +-----END CERTIFICATE----- + +D-TRUST EV Root CA 1 2020 +========================= +-----BEGIN CERTIFICATE----- +MIIC2zCCAmCgAwIBAgIQXwJB13qHfEwDo6yWjfv/0DAKBggqhkjOPQQDAzBIMQswCQYDVQQGEwJE +RTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEVWIFJvb3QgQ0EgMSAy +MDIwMB4XDTIwMDIxMTEwMDAwMFoXDTM1MDIxMTA5NTk1OVowSDELMAkGA1UEBhMCREUxFTATBgNV +BAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDEgMjAyMDB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABPEL3YZDIBnfl4XoIkqbz52Yv7QFJsnL46bSj8WeeHsxiamJrSc8 +ZRCC/N/DnU7wMyPE0jL1HLDfMxddxfCxivnvubcUyilKwg+pf3VlSSowZ/Rk99Yad9rDwpdhQntJ +raOCAQ0wggEJMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFH8QARY3OqQo5FD4pPfsazK2/umL +MA4GA1UdDwEB/wQEAwIBBjCBxgYDVR0fBIG+MIG7MD6gPKA6hjhodHRwOi8vY3JsLmQtdHJ1c3Qu +bmV0L2NybC9kLXRydXN0X2V2X3Jvb3RfY2FfMV8yMDIwLmNybDB5oHegdYZzbGRhcDovL2RpcmVj +dG9yeS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwRVYlMjBSb290JTIwQ0ElMjAxJTIwMjAyMCxP +PUQtVHJ1c3QlMjBHbWJILEM9REU/Y2VydGlmaWNhdGVyZXZvY2F0aW9ubGlzdDAKBggqhkjOPQQD +AwNpADBmAjEAyjzGKnXCXnViOTYAYFqLwZOZzNnbQTs7h5kXO9XMT8oi96CAy/m0sRtW9XLS/BnR +AjEAkfcwkz8QRitxpNA7RJvAKQIFskF3UfN5Wp6OFKBOQtJbgfM0agPnIjhQW+0ZT0MW +-----END CERTIFICATE----- + +DigiCert TLS ECC P384 Root G5 +============================= +-----BEGIN CERTIFICATE----- +MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURpZ2lDZXJ0IFRMUyBFQ0MgUDM4 +NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMx +FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQg +Um9vdCBHNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1Tzvd +lHJS7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp0zVozptj +n4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICISB4CIfBFqMA4GA1UdDwEB +/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2gAMGUCMQCJao1H5+z8blUD2Wds +Jk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQLgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIx +AJSdYsiJvRmEFOml+wG4DXZDjC5Ty3zfDBeWUA== +-----END CERTIFICATE----- + +DigiCert TLS RSA4096 Root G5 +============================ +-----BEGIN CERTIFICATE----- +MIIFZjCCA06gAwIBAgIQCPm0eKj6ftpqMzeJ3nzPijANBgkqhkiG9w0BAQwFADBNMQswCQYDVQQG +EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0 +MDk2IFJvb3QgRzUwHhcNMjEwMTE1MDAwMDAwWhcNNDYwMTE0MjM1OTU5WjBNMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJTAjBgNVBAMTHERpZ2lDZXJ0IFRMUyBSU0E0MDk2 +IFJvb3QgRzUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCz0PTJeRGd/fxmgefM1eS8 +7IE+ajWOLrfn3q/5B03PMJ3qCQuZvWxX2hhKuHisOjmopkisLnLlvevxGs3npAOpPxG02C+JFvuU +AT27L/gTBaF4HI4o4EXgg/RZG5Wzrn4DReW+wkL+7vI8toUTmDKdFqgpwgscONyfMXdcvyej/Ces +tyu9dJsXLfKB2l2w4SMXPohKEiPQ6s+d3gMXsUJKoBZMpG2T6T867jp8nVid9E6P/DsjyG244gXa +zOvswzH016cpVIDPRFtMbzCe88zdH5RDnU1/cHAN1DrRN/BsnZvAFJNY781BOHW8EwOVfH/jXOnV +DdXifBBiqmvwPXbzP6PosMH976pXTayGpxi0KcEsDr9kvimM2AItzVwv8n/vFfQMFawKsPHTDU9q +TXeXAaDxZre3zu/O7Oyldcqs4+Fj97ihBMi8ez9dLRYiVu1ISf6nL3kwJZu6ay0/nTvEF+cdLvvy +z6b84xQslpghjLSR6Rlgg/IwKwZzUNWYOwbpx4oMYIwo+FKbbuH2TbsGJJvXKyY//SovcfXWJL5/ +MZ4PbeiPT02jP/816t9JXkGPhvnxd3lLG7SjXi/7RgLQZhNeXoVPzthwiHvOAbWWl9fNff2C+MIk +wcoBOU+NosEUQB+cZtUMCUbW8tDRSHZWOkPLtgoRObqME2wGtZ7P6wIDAQABo0IwQDAdBgNVHQ4E +FgQUUTMc7TZArxfTJc1paPKvTiM+s0EwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQEMBQADggIBAGCmr1tfV9qJ20tQqcQjNSH/0GEwhJG3PxDPJY7Jv0Y02cEhJhxw +GXIeo8mH/qlDZJY6yFMECrZBu8RHANmfGBg7sg7zNOok992vIGCukihfNudd5N7HPNtQOa27PShN +lnx2xlv0wdsUpasZYgcYQF+Xkdycx6u1UQ3maVNVzDl92sURVXLFO4uJ+DQtpBflF+aZfTCIITfN +MBc9uPK8qHWgQ9w+iUuQrm0D4ByjoJYJu32jtyoQREtGBzRj7TG5BO6jm5qu5jF49OokYTurWGT/ +u4cnYiWB39yhL/btp/96j1EuMPikAdKFOV8BmZZvWltwGUb+hmA+rYAQCd05JS9Yf7vSdPD3Rh9G +OUrYU9DzLjtxpdRv/PNn5AeP3SYZ4Y1b+qOTEZvpyDrDVWiakuFSdjjo4bq9+0/V77PnSIMx8IIh +47a+p6tv75/fTM8BuGJqIz3nCU2AG3swpMPdB380vqQmsvZB6Akd4yCYqjdP//fx4ilwMUc/dNAU +FvohigLVigmUdy7yWSiLfFCSCmZ4OIN1xLVaqBHG5cGdZlXPU8Sv13WFqUITVuwhd4GTWgzqltlJ +yqEI8pc7bZsEGCREjnwB8twl2F6GmrE52/WRMmrRpnCKovfepEWFJqgejF0pW8hL2JpqA15w8oVP +bEtoL8pU9ozaMv7Da4M/OMZ+ +-----END CERTIFICATE----- + +Certainly Root R1 +================= +-----BEGIN CERTIFICATE----- +MIIFRzCCAy+gAwIBAgIRAI4P+UuQcWhlM1T01EQ5t+AwDQYJKoZIhvcNAQELBQAwPTELMAkGA1UE +BhMCVVMxEjAQBgNVBAoTCUNlcnRhaW5seTEaMBgGA1UEAxMRQ2VydGFpbmx5IFJvb3QgUjEwHhcN +MjEwNDAxMDAwMDAwWhcNNDYwNDAxMDAwMDAwWjA9MQswCQYDVQQGEwJVUzESMBAGA1UEChMJQ2Vy +dGFpbmx5MRowGAYDVQQDExFDZXJ0YWlubHkgUm9vdCBSMTCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBANA21B/q3avk0bbm+yLA3RMNansiExyXPGhjZjKcA7WNpIGD2ngwEc/csiu+kr+O +5MQTvqRoTNoCaBZ0vrLdBORrKt03H2As2/X3oXyVtwxwhi7xOu9S98zTm/mLvg7fMbedaFySpvXl +8wo0tf97ouSHocavFwDvA5HtqRxOcT3Si2yJ9HiG5mpJoM610rCrm/b01C7jcvk2xusVtyWMOvwl +DbMicyF0yEqWYZL1LwsYpfSt4u5BvQF5+paMjRcCMLT5r3gajLQ2EBAHBXDQ9DGQilHFhiZ5shGI +XsXwClTNSaa/ApzSRKft43jvRl5tcdF5cBxGX1HpyTfcX35pe0HfNEXgO4T0oYoKNp43zGJS4YkN +KPl6I7ENPT2a/Z2B7yyQwHtETrtJ4A5KVpK8y7XdeReJkd5hiXSSqOMyhb5OhaRLWcsrxXiOcVTQ +AjeZjOVJ6uBUcqQRBi8LjMFbvrWhsFNunLhgkR9Za/kt9JQKl7XsxXYDVBtlUrpMklZRNaBA2Cnb +rlJ2Oy0wQJuK0EJWtLeIAaSHO1OWzaMWj/Nmqhexx2DgwUMFDO6bW2BvBlyHWyf5QBGenDPBt+U1 +VwV/J84XIIwc/PH72jEpSe31C4SnT8H2TsIonPru4K8H+zMReiFPCyEQtkA6qyI6BJyLm4SGcprS +p6XEtHWRqSsjAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBTgqj8ljZ9EXME66C6ud0yEPmcM9DANBgkqhkiG9w0BAQsFAAOCAgEAuVevuBLaV4OPaAsz +HQNTVfSVcOQrPbA56/qJYv331hgELyE03fFo8NWWWt7CgKPBjcZq91l3rhVkz1t5BXdm6ozTaw3d +8VkswTOlMIAVRQdFGjEitpIAq5lNOo93r6kiyi9jyhXWx8bwPWz8HA2YEGGeEaIi1wrykXprOQ4v +MMM2SZ/g6Q8CRFA3lFV96p/2O7qUpUzpvD5RtOjKkjZUbVwlKNrdrRT90+7iIgXr0PK3aBLXWopB +GsaSpVo7Y0VPv+E6dyIvXL9G+VoDhRNCX8reU9ditaY1BMJH/5n9hN9czulegChB8n3nHpDYT3Y+ +gjwN/KUD+nsa2UUeYNrEjvn8K8l7lcUq/6qJ34IxD3L/DCfXCh5WAFAeDJDBlrXYFIW7pw0WwfgH +JBu6haEaBQmAupVjyTrsJZ9/nbqkRxWbRHDxakvWOF5D8xh+UG7pWijmZeZ3Gzr9Hb4DJqPb1OG7 +fpYnKx3upPvaJVQTA945xsMfTZDsjxtK0hzthZU4UHlG1sGQUDGpXJpuHfUzVounmdLyyCwzk5Iw +x06MZTMQZBf9JBeW0Y3COmor6xOLRPIh80oat3df1+2IpHLlOR+Vnb5nwXARPbv0+Em34yaXOp/S +X3z7wJl8OSngex2/DaeP0ik0biQVy96QXr8axGbqwua6OV+KmalBWQewLK8= +-----END CERTIFICATE----- + +Certainly Root E1 +================= +-----BEGIN CERTIFICATE----- +MIIB9zCCAX2gAwIBAgIQBiUzsUcDMydc+Y2aub/M+DAKBggqhkjOPQQDAzA9MQswCQYDVQQGEwJV +UzESMBAGA1UEChMJQ2VydGFpbmx5MRowGAYDVQQDExFDZXJ0YWlubHkgUm9vdCBFMTAeFw0yMTA0 +MDEwMDAwMDBaFw00NjA0MDEwMDAwMDBaMD0xCzAJBgNVBAYTAlVTMRIwEAYDVQQKEwlDZXJ0YWlu +bHkxGjAYBgNVBAMTEUNlcnRhaW5seSBSb290IEUxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE3m/4 +fxzf7flHh4axpMCK+IKXgOqPyEpeKn2IaKcBYhSRJHpcnqMXfYqGITQYUBsQ3tA3SybHGWCA6TS9 +YBk2QNYphwk8kXr2vBMj3VlOBF7PyAIcGFPBMdjaIOlEjeR2o0IwQDAOBgNVHQ8BAf8EBAMCAQYw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU8ygYy2R17ikq6+2uI1g4hevIIgcwCgYIKoZIzj0E +AwMDaAAwZQIxALGOWiDDshliTd6wT99u0nCK8Z9+aozmut6Dacpps6kFtZaSF4fC0urQe87YQVt8 +rgIwRt7qy12a7DLCZRawTDBcMPPaTnOGBtjOiQRINzf43TNRnXCve1XYAS59BWQOhriR +-----END CERTIFICATE----- + +Security Communication ECC RootCA1 +================================== +-----BEGIN CERTIFICATE----- +MIICODCCAb6gAwIBAgIJANZdm7N4gS7rMAoGCCqGSM49BAMDMGExCzAJBgNVBAYTAkpQMSUwIwYD +VQQKExxTRUNPTSBUcnVzdCBTeXN0ZW1zIENPLixMVEQuMSswKQYDVQQDEyJTZWN1cml0eSBDb21t +dW5pY2F0aW9uIEVDQyBSb290Q0ExMB4XDTE2MDYxNjA1MTUyOFoXDTM4MDExODA1MTUyOFowYTEL +MAkGA1UEBhMCSlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xKzApBgNV +BAMTIlNlY3VyaXR5IENvbW11bmljYXRpb24gRUNDIFJvb3RDQTEwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAASkpW9gAwPDvTH00xecK4R1rOX9PVdu12O/5gSJko6BnOPpR27KkBLIE+CnnfdldB9sELLo +5OnvbYUymUSxXv3MdhDYW72ixvnWQuRXdtyQwjWpS4g8EkdtXP9JTxpKULGjQjBAMB0GA1UdDgQW +BBSGHOf+LaVKiwj+KBH6vqNm+GBZLzAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAK +BggqhkjOPQQDAwNoADBlAjAVXUI9/Lbu9zuxNuie9sRGKEkz0FhDKmMpzE2xtHqiuQ04pV1IKv3L +snNdo4gIxwwCMQDAqy0Obe0YottT6SXbVQjgUMzfRGEWgqtJsLKB7HOHeLRMsmIbEvoWTSVLY70e +N9k= +-----END CERTIFICATE----- + +BJCA Global Root CA1 +==================== +-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIQVW9l47TZkGobCdFsPsBsIDANBgkqhkiG9w0BAQsFADBUMQswCQYDVQQG +EwJDTjEmMCQGA1UECgwdQkVJSklORyBDRVJUSUZJQ0FURSBBVVRIT1JJVFkxHTAbBgNVBAMMFEJK +Q0EgR2xvYmFsIFJvb3QgQ0ExMB4XDTE5MTIxOTAzMTYxN1oXDTQ0MTIxMjAzMTYxN1owVDELMAkG +A1UEBhMCQ04xJjAkBgNVBAoMHUJFSUpJTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZMR0wGwYDVQQD +DBRCSkNBIEdsb2JhbCBSb290IENBMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPFm +CL3ZxRVhy4QEQaVpN3cdwbB7+sN3SJATcmTRuHyQNZ0YeYjjlwE8R4HyDqKYDZ4/N+AZspDyRhyS +sTphzvq3Rp4Dhtczbu33RYx2N95ulpH3134rhxfVizXuhJFyV9xgw8O558dnJCNPYwpj9mZ9S1Wn +P3hkSWkSl+BMDdMJoDIwOvqfwPKcxRIqLhy1BDPapDgRat7GGPZHOiJBhyL8xIkoVNiMpTAK+BcW +yqw3/XmnkRd4OJmtWO2y3syJfQOcs4ll5+M7sSKGjwZteAf9kRJ/sGsciQ35uMt0WwfCyPQ10WRj +eulumijWML3mG90Vr4TqnMfK9Q7q8l0ph49pczm+LiRvRSGsxdRpJQaDrXpIhRMsDQa4bHlW/KNn +MoH1V6XKV0Jp6VwkYe/iMBhORJhVb3rCk9gZtt58R4oRTklH2yiUAguUSiz5EtBP6DF+bHq/pj+b +OT0CFqMYs2esWz8sgytnOYFcuX6U1WTdno9uruh8W7TXakdI136z1C2OVnZOz2nxbkRs1CTqjSSh +GL+9V/6pmTW12xB3uD1IutbB5/EjPtffhZ0nPNRAvQoMvfXnjSXWgXSHRtQpdaJCbPdzied9v3pK +H9MiyRVVz99vfFXQpIsHETdfg6YmV6YBW37+WGgHqel62bno/1Afq8K0wM7o6v0PvY1NuLxxAgMB +AAGjQjBAMB0GA1UdDgQWBBTF7+3M2I0hxkjk49cULqcWk+WYATAPBgNVHRMBAf8EBTADAQH/MA4G +A1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAUoKsITQfI/Ki2Pm4rzc2IInRNwPWaZ+4 +YRC6ojGYWUfo0Q0lHhVBDOAqVdVXUsv45Mdpox1NcQJeXyFFYEhcCY5JEMEE3KliawLwQ8hOnThJ +dMkycFRtwUf8jrQ2ntScvd0g1lPJGKm1Vrl2i5VnZu69mP6u775u+2D2/VnGKhs/I0qUJDAnyIm8 +60Qkmss9vk/Ves6OF8tiwdneHg56/0OGNFK8YT88X7vZdrRTvJez/opMEi4r89fO4aL/3Xtw+zuh +TaRjAv04l5U/BXCga99igUOLtFkNSoxUnMW7gZ/NfaXvCyUeOiDbHPwfmGcCCtRzRBPbUYQaVQNW +4AB+dAb/OMRyHdOoP2gxXdMJxy6MW2Pg6Nwe0uxhHvLe5e/2mXZgLR6UcnHGCyoyx5JO1UbXHfmp +GQrI+pXObSOYqgs4rZpWDW+N8TEAiMEXnM0ZNjX+VVOg4DwzX5Ze4jLp3zO7Bkqp2IRzznfSxqxx +4VyjHQy7Ct9f4qNx2No3WqB4K/TUfet27fJhcKVlmtOJNBir+3I+17Q9eVzYH6Eze9mCUAyTF6ps +3MKCuwJXNq+YJyo5UOGwifUll35HaBC07HPKs5fRJNz2YqAo07WjuGS3iGJCz51TzZm+ZGiPTx4S +SPfSKcOYKMryMguTjClPPGAyzQWWYezyr/6zcCwupvI= +-----END CERTIFICATE----- + +BJCA Global Root CA2 +==================== +-----BEGIN CERTIFICATE----- +MIICJTCCAaugAwIBAgIQLBcIfWQqwP6FGFkGz7RK6zAKBggqhkjOPQQDAzBUMQswCQYDVQQGEwJD +TjEmMCQGA1UECgwdQkVJSklORyBDRVJUSUZJQ0FURSBBVVRIT1JJVFkxHTAbBgNVBAMMFEJKQ0Eg +R2xvYmFsIFJvb3QgQ0EyMB4XDTE5MTIxOTAzMTgyMVoXDTQ0MTIxMjAzMTgyMVowVDELMAkGA1UE +BhMCQ04xJjAkBgNVBAoMHUJFSUpJTkcgQ0VSVElGSUNBVEUgQVVUSE9SSVRZMR0wGwYDVQQDDBRC +SkNBIEdsb2JhbCBSb290IENBMjB2MBAGByqGSM49AgEGBSuBBAAiA2IABJ3LgJGNU2e1uVCxA/jl +SR9BIgmwUVJY1is0j8USRhTFiy8shP8sbqjV8QnjAyEUxEM9fMEsxEtqSs3ph+B99iK++kpRuDCK +/eHeGBIK9ke35xe/J4rUQUyWPGCWwf0VHKNCMEAwHQYDVR0OBBYEFNJKsVF/BvDRgh9Obl+rg/xI +1LCRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMBq8 +W9f+qdJUDkpd0m2xQNz0Q9XSSpkZElaA94M04TVOSG0ED1cxMDAtsaqdAzjbBgIxAMvMh1PLet8g +UXOQwKhbYdDFUDn9hf7B43j4ptZLvZuHjw/l1lOWqzzIQNph91Oj9w== +-----END CERTIFICATE----- + +Sectigo Public Server Authentication Root E46 +============================================= +-----BEGIN CERTIFICATE----- +MIICOjCCAcGgAwIBAgIQQvLM2htpN0RfFf51KBC49DAKBggqhkjOPQQDAzBfMQswCQYDVQQGEwJH +QjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBTZXJ2 +ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwHhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5 +WjBfMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0 +aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAAR2+pmpbiDt+dd34wc7qNs9Xzjoq1WmVk/WSOrsfy2qw7LFeeyZYX8QeccCWvkEN/U0 +NSt3zn8gj1KjAIns1aeibVvjS5KToID1AZTc8GgHHs3u/iVStSBDHBv+6xnOQ6OjQjBAMB0GA1Ud +DgQWBBTRItpMWfFLXyY4qp3W7usNw/upYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB +/zAKBggqhkjOPQQDAwNnADBkAjAn7qRaqCG76UeXlImldCBteU/IvZNeWBj7LRoAasm4PdCkT0RH +lAFWovgzJQxC36oCMB3q4S6ILuH5px0CMk7yn2xVdOOurvulGu7t0vzCAxHrRVxgED1cf5kDW21U +SAGKcw== +-----END CERTIFICATE----- + +Sectigo Public Server Authentication Root R46 +============================================= +-----BEGIN CERTIFICATE----- +MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBfMQswCQYDVQQG +EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1YmxpYyBT +ZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwHhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1 +OTU5WjBfMQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1T +ZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3 +DQEBAQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDaef0rty2k +1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnzSDBh+oF8HqcIStw+Kxwf +GExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xfiOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMP +FF1bFOdLvt30yNoDN9HWOaEhUTCDsG3XME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vu +ZDCQOc2TZYEhMbUjUDM3IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5Qaz +Yw6A3OASVYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgESJ/A +wSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu+Zd4KKTIRJLpfSYF +plhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt8uaZFURww3y8nDnAtOFr94MlI1fZ +EoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+LHaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW +6aWWrL3DkJiy4Pmi1KZHQ3xtzwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWI +IUkwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c +mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQYKlJfp/imTYp +E0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52gDY9hAaLMyZlbcp+nv4fjFg4 +exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZAFv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M +0ejf5lG5Nkc/kLnHvALcWxxPDkjBJYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI +84HxZmduTILA7rpXDhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9m +pFuiTdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5dHn5Hrwd +Vw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65LvKRRFHQV80MNNVIIb/b +E/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmm +J1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAYQqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL +-----END CERTIFICATE----- + +SSL.com TLS RSA Root CA 2022 +============================ +-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIQb77arXO9CEDii02+1PdbkTANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQG +EwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxTU0wuY29tIFRMUyBSU0Eg +Um9vdCBDQSAyMDIyMB4XDTIyMDgyNTE2MzQyMloXDTQ2MDgxOTE2MzQyMVowTjELMAkGA1UEBhMC +VVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgUlNBIFJv +b3QgQ0EgMjAyMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANCkCXJPQIgSYT41I57u +9nTPL3tYPc48DRAokC+X94xI2KDYJbFMsBFMF3NQ0CJKY7uB0ylu1bUJPiYYf7ISf5OYt6/wNr/y +7hienDtSxUcZXXTzZGbVXcdotL8bHAajvI9AI7YexoS9UcQbOcGV0insS657Lb85/bRi3pZ7Qcac +oOAGcvvwB5cJOYF0r/c0WRFXCsJbwST0MXMwgsadugL3PnxEX4MN8/HdIGkWCVDi1FW24IBydm5M +R7d1VVm0U3TZlMZBrViKMWYPHqIbKUBOL9975hYsLfy/7PO0+r4Y9ptJ1O4Fbtk085zx7AGL0SDG +D6C1vBdOSHtRwvzpXGk3R2azaPgVKPC506QVzFpPulJwoxJF3ca6TvvC0PeoUidtbnm1jPx7jMEW +TO6Af77wdr5BUxIzrlo4QqvXDz5BjXYHMtWrifZOZ9mxQnUjbvPNQrL8VfVThxc7wDNY8VLS+YCk +8OjwO4s4zKTGkH8PnP2L0aPP2oOnaclQNtVcBdIKQXTbYxE3waWglksejBYSd66UNHsef8JmAOSq +g+qKkK3ONkRN0VHpvB/zagX9wHQfJRlAUW7qglFA35u5CCoGAtUjHBPW6dvbxrB6y3snm/vg1UYk +7RBLY0ulBY+6uB0rpvqR4pJSvezrZ5dtmi2fgTIFZzL7SAg/2SW4BCUvAgMBAAGjYzBhMA8GA1Ud +EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU+y437uOEeicuzRk1sTN8/9REQrkwHQYDVR0OBBYEFPsu +N+7jhHonLs0ZNbEzfP/UREK5MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAjYlt +hEUY8U+zoO9opMAdrDC8Z2awms22qyIZZtM7QbUQnRC6cm4pJCAcAZli05bg4vsMQtfhWsSWTVTN +j8pDU/0quOr4ZcoBwq1gaAafORpR2eCNJvkLTqVTJXojpBzOCBvfR4iyrT7gJ4eLSYwfqUdYe5by +iB0YrrPRpgqU+tvT5TgKa3kSM/tKWTcWQA673vWJDPFs0/dRa1419dvAJuoSc06pkZCmF8NsLzjU +o3KUQyxi4U5cMj29TH0ZR6LDSeeWP4+a0zvkEdiLA9z2tmBVGKaBUfPhqBVq6+AL8BQx1rmMRTqo +ENjwuSfr98t67wVylrXEj5ZzxOhWc5y8aVFjvO9nHEMaX3cZHxj4HCUp+UmZKbaSPaKDN7Egkaib +MOlqbLQjk2UEqxHzDh1TJElTHaE/nUiSEeJ9DU/1172iWD54nR4fK/4huxoTtrEoZP2wAgDHbICi +vRZQIA9ygV/MlP+7mea6kMvq+cYMwq7FGc4zoWtcu358NFcXrfA/rs3qr5nsLFR+jM4uElZI7xc7 +P0peYNLcdDa8pUNjyw9bowJWCZ4kLOGGgYz+qxcs+sjiMho6/4UIyYOf8kpIEFR3N+2ivEC+5BB0 +9+Rbu7nzifmPQdjH5FCQNYA+HLhNkNPU98OwoX6EyneSMSy4kLGCenROmxMmtNVQZlR4rmA= +-----END CERTIFICATE----- + +SSL.com TLS ECC Root CA 2022 +============================ +-----BEGIN CERTIFICATE----- +MIICOjCCAcCgAwIBAgIQFAP1q/s3ixdAW+JDsqXRxDAKBggqhkjOPQQDAzBOMQswCQYDVQQGEwJV +UzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxTU0wuY29tIFRMUyBFQ0MgUm9v +dCBDQSAyMDIyMB4XDTIyMDgyNTE2MzM0OFoXDTQ2MDgxOTE2MzM0N1owTjELMAkGA1UEBhMCVVMx +GDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBUTFMgRUNDIFJvb3Qg +Q0EgMjAyMjB2MBAGByqGSM49AgEGBSuBBAAiA2IABEUpNXP6wrgjzhR9qLFNoFs27iosU8NgCTWy +JGYmacCzldZdkkAZDsalE3D07xJRKF3nzL35PIXBz5SQySvOkkJYWWf9lCcQZIxPBLFNSeR7T5v1 +5wj4A4j3p8OSSxlUgaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSJjy+j6CugFFR7 +81a4Jl9nOAuc0DAdBgNVHQ4EFgQUiY8vo+groBRUe/NWuCZfZzgLnNAwDgYDVR0PAQH/BAQDAgGG +MAoGCCqGSM49BAMDA2gAMGUCMFXjIlbp15IkWE8elDIPDAI2wv2sdDJO4fscgIijzPvX6yv/N33w +7deedWo1dlJF4AIxAMeNb0Igj762TVntd00pxCAgRWSGOlDGxK0tk/UYfXLtqc/ErFc2KAhl3zx5 +Zn6g6g== +-----END CERTIFICATE----- + +Atos TrustedRoot Root CA ECC TLS 2021 +===================================== +-----BEGIN CERTIFICATE----- +MIICFTCCAZugAwIBAgIQPZg7pmY9kGP3fiZXOATvADAKBggqhkjOPQQDAzBMMS4wLAYDVQQDDCVB +dG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgRUNDIFRMUyAyMDIxMQ0wCwYDVQQKDARBdG9zMQswCQYD +VQQGEwJERTAeFw0yMTA0MjIwOTI2MjNaFw00MTA0MTcwOTI2MjJaMEwxLjAsBgNVBAMMJUF0b3Mg +VHJ1c3RlZFJvb3QgUm9vdCBDQSBFQ0MgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNVBAYT +AkRFMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEloZYKDcKZ9Cg3iQZGeHkBQcfl+3oZIK59sRxUM6K +DP/XtXa7oWyTbIOiaG6l2b4siJVBzV3dscqDY4PMwL502eCdpO5KTlbgmClBk1IQ1SQ4AjJn8ZQS +b+/Xxd4u/RmAo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR2KCXWfeBmmnoJsmo7jjPX +NtNPojAOBgNVHQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIwW5kp85wxtolrbNa9d+F851F+ +uDrNozZffPc8dz7kUK2o59JZDCaOMDtuCCrCp1rIAjEAmeMM56PDr9NJLkaCI2ZdyQAUEv049OGY +a3cpetskz2VAv9LcjBHo9H1/IISpQuQo +-----END CERTIFICATE----- + +Atos TrustedRoot Root CA RSA TLS 2021 +===================================== +-----BEGIN CERTIFICATE----- +MIIFZDCCA0ygAwIBAgIQU9XP5hmTC/srBRLYwiqipDANBgkqhkiG9w0BAQwFADBMMS4wLAYDVQQD +DCVBdG9zIFRydXN0ZWRSb290IFJvb3QgQ0EgUlNBIFRMUyAyMDIxMQ0wCwYDVQQKDARBdG9zMQsw +CQYDVQQGEwJERTAeFw0yMTA0MjIwOTIxMTBaFw00MTA0MTcwOTIxMDlaMEwxLjAsBgNVBAMMJUF0 +b3MgVHJ1c3RlZFJvb3QgUm9vdCBDQSBSU0EgVExTIDIwMjExDTALBgNVBAoMBEF0b3MxCzAJBgNV +BAYTAkRFMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtoAOxHm9BYx9sKOdTSJNy/BB +l01Z4NH+VoyX8te9j2y3I49f1cTYQcvyAh5x5en2XssIKl4w8i1mx4QbZFc4nXUtVsYvYe+W/CBG +vevUez8/fEc4BKkbqlLfEzfTFRVOvV98r61jx3ncCHvVoOX3W3WsgFWZkmGbzSoXfduP9LVq6hdK +ZChmFSlsAvFr1bqjM9xaZ6cF4r9lthawEO3NUDPJcFDsGY6wx/J0W2tExn2WuZgIWWbeKQGb9Cpt +0xU6kGpn8bRrZtkh68rZYnxGEFzedUlnnkL5/nWpo63/dgpnQOPF943HhZpZnmKaau1Fh5hnstVK +PNe0OwANwI8f4UDErmwh3El+fsqyjW22v5MvoVw+j8rtgI5Y4dtXz4U2OLJxpAmMkokIiEjxQGMY +sluMWuPD0xeqqxmjLBvk1cbiZnrXghmmOxYsL3GHX0WelXOTwkKBIROW1527k2gV+p2kHYzygeBY +Br3JtuP2iV2J+axEoctr+hbxx1A9JNr3w+SH1VbxT5Aw+kUJWdo0zuATHAR8ANSbhqRAvNncTFd+ +rrcztl524WWLZt+NyteYr842mIycg5kDcPOvdO3GDjbnvezBc6eUWsuSZIKmAMFwoW4sKeFYV+xa +fJlrJaSQOoD0IJ2azsct+bJLKZWD6TWNp0lIpw9MGZHQ9b8Q4HECAwEAAaNCMEAwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUdEmZ0f+0emhFdcN+tNzMzjkz2ggwDgYDVR0PAQH/BAQDAgGGMA0G +CSqGSIb3DQEBDAUAA4ICAQAjQ1MkYlxt/T7Cz1UAbMVWiLkO3TriJQ2VSpfKgInuKs1l+NsW4AmS +4BjHeJi78+xCUvuppILXTdiK/ORO/auQxDh1MoSf/7OwKwIzNsAQkG8dnK/haZPso0UvFJ/1TCpl +Q3IM98P4lYsU84UgYt1UU90s3BiVaU+DR3BAM1h3Egyi61IxHkzJqM7F78PRreBrAwA0JrRUITWX +AdxfG/F851X6LWh3e9NpzNMOa7pNdkTWwhWaJuywxfW70Xp0wmzNxbVe9kzmWy2B27O3Opee7c9G +slA9hGCZcbUztVdF5kJHdWoOsAgMrr3e97sPWD2PAzHoPYJQyi9eDF20l74gNAf0xBLh7tew2Vkt +afcxBPTy+av5EzH4AXcOPUIjJsyacmdRIXrMPIWo6iFqO9taPKU0nprALN+AnCng33eU0aKAQv9q +TFsR0PXNor6uzFFcw9VUewyu1rkGd4Di7wcaaMxZUa1+XGdrudviB0JbuAEFWDlN5LuYo7Ey7Nmj +1m+UI/87tyll5gfp77YZ6ufCOB0yiJA8EytuzO+rdwY0d4RPcuSBhPm5dDTedk+SKlOxJTnbPP/l +PqYO5Wue/9vsL3SD3460s6neFE3/MaNFcyT6lSnMEpcEoji2jbDwN/zIIX8/syQbPYtuzE2wFg2W +HYMfRsCbvUOZ58SWLs5fyQ== +-----END CERTIFICATE----- + +TrustAsia Global Root CA G3 +=========================== +-----BEGIN CERTIFICATE----- +MIIFpTCCA42gAwIBAgIUZPYOZXdhaqs7tOqFhLuxibhxkw8wDQYJKoZIhvcNAQEMBQAwWjELMAkG +A1UEBhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xJDAiBgNVBAMM +G1RydXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHMzAeFw0yMTA1MjAwMjEwMTlaFw00NjA1MTkwMjEw +MTlaMFoxCzAJBgNVBAYTAkNOMSUwIwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMu +MSQwIgYDVQQDDBtUcnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzMwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQDAMYJhkuSUGwoqZdC+BqmHO1ES6nBBruL7dOoKjbmzTNyPtxNST1QY4Sxz +lZHFZjtqz6xjbYdT8PfxObegQ2OwxANdV6nnRM7EoYNl9lA+sX4WuDqKAtCWHwDNBSHvBm3dIZwZ +Q0WhxeiAysKtQGIXBsaqvPPW5vxQfmZCHzyLpnl5hkA1nyDvP+uLRx+PjsXUjrYsyUQE49RDdT/V +P68czH5GX6zfZBCK70bwkPAPLfSIC7Epqq+FqklYqL9joDiR5rPmd2jE+SoZhLsO4fWvieylL1Ag +dB4SQXMeJNnKziyhWTXAyB1GJ2Faj/lN03J5Zh6fFZAhLf3ti1ZwA0pJPn9pMRJpxx5cynoTi+jm +9WAPzJMshH/x/Gr8m0ed262IPfN2dTPXS6TIi/n1Q1hPy8gDVI+lhXgEGvNz8teHHUGf59gXzhqc +D0r83ERoVGjiQTz+LISGNzzNPy+i2+f3VANfWdP3kXjHi3dqFuVJhZBFcnAvkV34PmVACxmZySYg +WmjBNb9Pp1Hx2BErW+Canig7CjoKH8GB5S7wprlppYiU5msTf9FkPz2ccEblooV7WIQn3MSAPmea +mseaMQ4w7OYXQJXZRe0Blqq/DPNL0WP3E1jAuPP6Z92bfW1K/zJMtSU7/xxnD4UiWQWRkUF3gdCF +TIcQcf+eQxuulXUtgQIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFEDk5PIj +7zjKsK5Xf/IhMBY027ySMB0GA1UdDgQWBBRA5OTyI+84yrCuV3/yITAWNNu8kjAOBgNVHQ8BAf8E +BAMCAQYwDQYJKoZIhvcNAQEMBQADggIBACY7UeFNOPMyGLS0XuFlXsSUT9SnYaP4wM8zAQLpw6o1 +D/GUE3d3NZ4tVlFEbuHGLige/9rsR82XRBf34EzC4Xx8MnpmyFq2XFNFV1pF1AWZLy4jVe5jaN/T +G3inEpQGAHUNcoTpLrxaatXeL1nHo+zSh2bbt1S1JKv0Q3jbSwTEb93mPmY+KfJLaHEih6D4sTNj +duMNhXJEIlU/HHzp/LgV6FL6qj6jITk1dImmasI5+njPtqzn59ZW/yOSLlALqbUHM/Q4X6RJpstl +cHboCoWASzY9M/eVVHUl2qzEc4Jl6VL1XP04lQJqaTDFHApXB64ipCz5xUG3uOyfT0gA+QEEVcys ++TIxxHWVBqB/0Y0n3bOppHKH/lmLmnp0Ft0WpWIp6zqW3IunaFnT63eROfjXy9mPX1onAX1daBli +2MjN9LdyR75bl87yraKZk62Uy5P2EgmVtqvXO9A/EcswFi55gORngS1d7XB4tmBZrOFdRWOPyN9y +aFvqHbgB8X7754qz41SgOAngPN5C8sLtLpvzHzW2NtjjgKGLzZlkD8Kqq7HK9W+eQ42EVJmzbsAS +ZthwEPEGNTNDqJwuuhQxzhB/HIbjj9LV+Hfsm6vxL2PZQl/gZ4FkkfGXL/xuJvYz+NO1+MRiqzFR +JQJ6+N1rZdVtTTDIZbpoFGWsJwt0ivKH +-----END CERTIFICATE----- + +TrustAsia Global Root CA G4 +=========================== +-----BEGIN CERTIFICATE----- +MIICVTCCAdygAwIBAgIUTyNkuI6XY57GU4HBdk7LKnQV1tcwCgYIKoZIzj0EAwMwWjELMAkGA1UE +BhMCQ04xJTAjBgNVBAoMHFRydXN0QXNpYSBUZWNobm9sb2dpZXMsIEluYy4xJDAiBgNVBAMMG1Ry +dXN0QXNpYSBHbG9iYWwgUm9vdCBDQSBHNDAeFw0yMTA1MjAwMjEwMjJaFw00NjA1MTkwMjEwMjJa +MFoxCzAJBgNVBAYTAkNOMSUwIwYDVQQKDBxUcnVzdEFzaWEgVGVjaG5vbG9naWVzLCBJbmMuMSQw +IgYDVQQDDBtUcnVzdEFzaWEgR2xvYmFsIFJvb3QgQ0EgRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATxs8045CVD5d4ZCbuBeaIVXxVjAd7Cq92zphtnS4CDr5nLrBfbK5bKfFJV4hrhPVbwLxYI+hW8 +m7tH5j/uqOFMjPXTNvk4XatwmkcN4oFBButJ+bAp3TPsUKV/eSm4IJijYzBhMA8GA1UdEwEB/wQF +MAMBAf8wHwYDVR0jBBgwFoAUpbtKl86zK3+kMd6Xg1mDpm9xy94wHQYDVR0OBBYEFKW7SpfOsyt/ +pDHel4NZg6ZvccveMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjBe8usGzEkxn0AA +bbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xk +dUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA== +-----END CERTIFICATE----- + +CommScope Public Trust ECC Root-01 +================================== +-----BEGIN CERTIFICATE----- +MIICHTCCAaOgAwIBAgIUQ3CCd89NXTTxyq4yLzf39H91oJ4wCgYIKoZIzj0EAwMwTjELMAkGA1UE +BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz +dCBFQ0MgUm9vdC0wMTAeFw0yMTA0MjgxNzM1NDNaFw00NjA0MjgxNzM1NDJaME4xCzAJBgNVBAYT +AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg +RUNDIFJvb3QtMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARLNumuV16ocNfQj3Rid8NeeqrltqLx +eP0CflfdkXmcbLlSiFS8LwS+uM32ENEp7LXQoMPwiXAZu1FlxUOcw5tjnSCDPgYLpkJEhRGnSjot +6dZoL0hOUysHP029uax3OVejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBSOB2LAUN3GGQYARnQE9/OufXVNMDAKBggqhkjOPQQDAwNoADBlAjEAnDPfQeMjqEI2 +Jpc1XHvr20v4qotzVRVcrHgpD7oh2MSg2NED3W3ROT3Ek2DS43KyAjB8xX6I01D1HiXo+k515liW +pDVfG2XqYZpwI7UNo5uSUm9poIyNStDuiw7LR47QjRE= +-----END CERTIFICATE----- + +CommScope Public Trust ECC Root-02 +================================== +-----BEGIN CERTIFICATE----- +MIICHDCCAaOgAwIBAgIUKP2ZYEFHpgE6yhR7H+/5aAiDXX0wCgYIKoZIzj0EAwMwTjELMAkGA1UE +BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz +dCBFQ0MgUm9vdC0wMjAeFw0yMTA0MjgxNzQ0NTRaFw00NjA0MjgxNzQ0NTNaME4xCzAJBgNVBAYT +AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg +RUNDIFJvb3QtMDIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR4MIHoYx7l63FRD/cHB8o5mXxO1Q/M +MDALj2aTPs+9xYa9+bG3tD60B8jzljHz7aRP+KNOjSkVWLjVb3/ubCK1sK9IRQq9qEmUv4RDsNuE +SgMjGWdqb8FuvAY5N9GIIvejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G +A1UdDgQWBBTmGHX/72DehKT1RsfeSlXjMjZ59TAKBggqhkjOPQQDAwNnADBkAjAmc0l6tqvmSfR9 +Uj/UQQSugEODZXW5hYA4O9Zv5JOGq4/nich/m35rChJVYaoR4HkCMHfoMXGsPHED1oQmHhS48zs7 +3u1Z/GtMMH9ZzkXpc2AVmkzw5l4lIhVtwodZ0LKOag== +-----END CERTIFICATE----- + +CommScope Public Trust RSA Root-01 +================================== +-----BEGIN CERTIFICATE----- +MIIFbDCCA1SgAwIBAgIUPgNJgXUWdDGOTKvVxZAplsU5EN0wDQYJKoZIhvcNAQELBQAwTjELMAkG +A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU +cnVzdCBSU0EgUm9vdC0wMTAeFw0yMTA0MjgxNjQ1NTRaFw00NjA0MjgxNjQ1NTNaME4xCzAJBgNV +BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1 +c3QgUlNBIFJvb3QtMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwSGWjDR1C45Ft +nYSkYZYSwu3D2iM0GXb26v1VWvZVAVMP8syMl0+5UMuzAURWlv2bKOx7dAvnQmtVzslhsuitQDy6 +uUEKBU8bJoWPQ7VAtYXR1HHcg0Hz9kXHgKKEUJdGzqAMxGBWBB0HW0alDrJLpA6lfO741GIDuZNq +ihS4cPgugkY4Iw50x2tBt9Apo52AsH53k2NC+zSDO3OjWiE260f6GBfZumbCk6SP/F2krfxQapWs +vCQz0b2If4b19bJzKo98rwjyGpg/qYFlP8GMicWWMJoKz/TUyDTtnS+8jTiGU+6Xn6myY5QXjQ/c +Zip8UlF1y5mO6D1cv547KI2DAg+pn3LiLCuz3GaXAEDQpFSOm117RTYm1nJD68/A6g3czhLmfTif +BSeolz7pUcZsBSjBAg/pGG3svZwG1KdJ9FQFa2ww8esD1eo9anbCyxooSU1/ZOD6K9pzg4H/kQO9 +lLvkuI6cMmPNn7togbGEW682v3fuHX/3SZtS7NJ3Wn2RnU3COS3kuoL4b/JOHg9O5j9ZpSPcPYeo +KFgo0fEbNttPxP/hjFtyjMcmAyejOQoBqsCyMWCDIqFPEgkBEa801M/XrmLTBQe0MXXgDW1XT2mH ++VepuhX2yFJtocucH+X8eKg1mp9BFM6ltM6UCBwJrVbl2rZJmkrqYxhTnCwuwwIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUN12mmnQywsL5x6YVEFm4 +5P3luG0wDQYJKoZIhvcNAQELBQADggIBAK+nz97/4L1CjU3lIpbfaOp9TSp90K09FlxD533Ahuh6 +NWPxzIHIxgvoLlI1pKZJkGNRrDSsBTtXAOnTYtPZKdVUvhwQkZyybf5Z/Xn36lbQnmhUQo8mUuJM +3y+Xpi/SB5io82BdS5pYV4jvguX6r2yBS5KPQJqTRlnLX3gWsWc+QgvfKNmwrZggvkN80V4aCRck +jXtdlemrwWCrWxhkgPut4AZ9HcpZuPN4KWfGVh2vtrV0KnahP/t1MJ+UXjulYPPLXAziDslg+Mkf +Foom3ecnf+slpoq9uC02EJqxWE2aaE9gVOX2RhOOiKy8IUISrcZKiX2bwdgt6ZYD9KJ0DLwAHb/W +NyVntHKLr4W96ioDj8z7PEQkguIBpQtZtjSNMgsSDesnwv1B10A8ckYpwIzqug/xBpMu95yo9GA+ +o/E4Xo4TwbM6l4c/ksp4qRyv0LAbJh6+cOx69TOY6lz/KwsETkPdY34Op054A5U+1C0wlREQKC6/ +oAI+/15Z0wUOlV9TRe9rh9VIzRamloPh37MG88EU26fsHItdkJANclHnYfkUyq+Dj7+vsQpZXdxc +1+SWrVtgHdqul7I52Qb1dgAT+GhMIbA1xNxVssnBQVocicCMb3SgazNNtQEo/a2tiRc7ppqEvOuM +6sRxJKi6KfkIsidWNTJf6jn7MZrVGczw +-----END CERTIFICATE----- + +CommScope Public Trust RSA Root-02 +================================== +-----BEGIN CERTIFICATE----- +MIIFbDCCA1SgAwIBAgIUVBa/O345lXGN0aoApYYNK496BU4wDQYJKoZIhvcNAQELBQAwTjELMAkG +A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU +cnVzdCBSU0EgUm9vdC0wMjAeFw0yMTA0MjgxNzE2NDNaFw00NjA0MjgxNzE2NDJaME4xCzAJBgNV +BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1 +c3QgUlNBIFJvb3QtMDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDh+g77aAASyE3V +rCLENQE7xVTlWXZjpX/rwcRqmL0yjReA61260WI9JSMZNRTpf4mnG2I81lDnNJUDMrG0kyI9p+Kx +7eZ7Ti6Hmw0zdQreqjXnfuU2mKKuJZ6VszKWpCtYHu8//mI0SFHRtI1CrWDaSWqVcN3SAOLMV2MC +e5bdSZdbkk6V0/nLKR8YSvgBKtJjCW4k6YnS5cciTNxzhkcAqg2Ijq6FfUrpuzNPDlJwnZXjfG2W +Wy09X6GDRl224yW4fKcZgBzqZUPckXk2LHR88mcGyYnJ27/aaL8j7dxrrSiDeS/sOKUNNwFnJ5rp +M9kzXzehxfCrPfp4sOcsn/Y+n2Dg70jpkEUeBVF4GiwSLFworA2iI540jwXmojPOEXcT1A6kHkIf +hs1w/tkuFT0du7jyU1fbzMZ0KZwYszZ1OC4PVKH4kh+Jlk+71O6d6Ts2QrUKOyrUZHk2EOH5kQMr +eyBUzQ0ZGshBMjTRsJnhkB4BQDa1t/qp5Xd1pCKBXbCL5CcSD1SIxtuFdOa3wNemKfrb3vOTlycE +VS8KbzfFPROvCgCpLIscgSjX74Yxqa7ybrjKaixUR9gqiC6vwQcQeKwRoi9C8DfF8rhW3Q5iLc4t +Vn5V8qdE9isy9COoR+jUKgF4z2rDN6ieZdIs5fq6M8EGRPbmz6UNp2YINIos8wIDAQABo0IwQDAP +BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUR9DnsSL/nSz12Vdgs7Gx +cJXvYXowDQYJKoZIhvcNAQELBQADggIBAIZpsU0v6Z9PIpNojuQhmaPORVMbc0RTAIFhzTHjCLqB +KCh6krm2qMhDnscTJk3C2OVVnJJdUNjCK9v+5qiXz1I6JMNlZFxHMaNlNRPDk7n3+VGXu6TwYofF +1gbTl4MgqX67tiHCpQ2EAOHyJxCDut0DgdXdaMNmEMjRdrSzbymeAPnCKfWxkxlSaRosTKCL4BWa +MS/TiJVZbuXEs1DIFAhKm4sTg7GkcrI7djNB3NyqpgdvHSQSn8h2vS/ZjvQs7rfSOBAkNlEv41xd +gSGn2rtO/+YHqP65DSdsu3BaVXoT6fEqSWnHX4dXTEN5bTpl6TBcQe7rd6VzEojov32u5cSoHw2O +HG1QAk8mGEPej1WFsQs3BWDJVTkSBKEqz3EWnzZRSb9wO55nnPt7eck5HHisd5FUmrh1CoFSl+Nm +YWvtPjgelmFV4ZFUjO2MJB+ByRCac5krFk5yAD9UG/iNuovnFNa2RU9g7Jauwy8CTl2dlklyALKr +dVwPaFsdZcJfMw8eD/A7hvWwTruc9+olBdytoptLFwG+Qt81IR2tq670v64fG9PiO/yzcnMcmyiQ +iRM9HcEARwmWmjgb3bHPDcK0RPOWlc4yOo80nOAXx17Org3bhzjlP1v9mxnhMUF6cKojawHhRUzN +lM47ni3niAIi9G7oyOzWPPO5std3eqx7 +-----END CERTIFICATE----- + +Telekom Security TLS ECC Root 2020 +================================== +-----BEGIN CERTIFICATE----- +MIICQjCCAcmgAwIBAgIQNjqWjMlcsljN0AFdxeVXADAKBggqhkjOPQQDAzBjMQswCQYDVQQGEwJE +RTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBHbWJIMSswKQYDVQQDDCJUZWxl +a29tIFNlY3VyaXR5IFRMUyBFQ0MgUm9vdCAyMDIwMB4XDTIwMDgyNTA3NDgyMFoXDTQ1MDgyNTIz +NTk1OVowYzELMAkGA1UEBhMCREUxJzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJpdHkg +R21iSDErMCkGA1UEAwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgRUNDIFJvb3QgMjAyMDB2MBAGByqG +SM49AgEGBSuBBAAiA2IABM6//leov9Wq9xCazbzREaK9Z0LMkOsVGJDZos0MKiXrPk/OtdKPD/M1 +2kOLAoC+b1EkHQ9rK8qfwm9QMuU3ILYg/4gND21Ju9sGpIeQkpT0CdDPf8iAC8GXs7s1J8nCG6NC +MEAwHQYDVR0OBBYEFONyzG6VmUex5rNhTNHLq+O6zd6fMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMAoGCCqGSM49BAMDA2cAMGQCMHVSi7ekEE+uShCLsoRbQuHmKjYC2qBuGT8lv9pZ +Mo7k+5Dck2TOrbRBR2Diz6fLHgIwN0GMZt9Ba9aDAEH9L1r3ULRn0SyocddDypwnJJGDSA3PzfdU +ga/sf+Rn27iQ7t0l +-----END CERTIFICATE----- + +Telekom Security TLS RSA Root 2023 +================================== +-----BEGIN CERTIFICATE----- +MIIFszCCA5ugAwIBAgIQIZxULej27HF3+k7ow3BXlzANBgkqhkiG9w0BAQwFADBjMQswCQYDVQQG +EwJERTEnMCUGA1UECgweRGV1dHNjaGUgVGVsZWtvbSBTZWN1cml0eSBHbWJIMSswKQYDVQQDDCJU +ZWxla29tIFNlY3VyaXR5IFRMUyBSU0EgUm9vdCAyMDIzMB4XDTIzMDMyODEyMTY0NVoXDTQ4MDMy +NzIzNTk1OVowYzELMAkGA1UEBhMCREUxJzAlBgNVBAoMHkRldXRzY2hlIFRlbGVrb20gU2VjdXJp +dHkgR21iSDErMCkGA1UEAwwiVGVsZWtvbSBTZWN1cml0eSBUTFMgUlNBIFJvb3QgMjAyMzCCAiIw +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAO01oYGA88tKaVvC+1GDrib94W7zgRJ9cUD/h3VC +KSHtgVIs3xLBGYSJwb3FKNXVS2xE1kzbB5ZKVXrKNoIENqil/Cf2SfHVcp6R+SPWcHu79ZvB7JPP +GeplfohwoHP89v+1VmLhc2o0mD6CuKyVU/QBoCcHcqMAU6DksquDOFczJZSfvkgdmOGjup5czQRx +UX11eKvzWarE4GC+j4NSuHUaQTXtvPM6Y+mpFEXX5lLRbtLevOP1Czvm4MS9Q2QTps70mDdsipWo +l8hHD/BeEIvnHRz+sTugBTNoBUGCwQMrAcjnj02r6LX2zWtEtefdi+zqJbQAIldNsLGyMcEWzv/9 +FIS3R/qy8XDe24tsNlikfLMR0cN3f1+2JeANxdKz+bi4d9s3cXFH42AYTyS2dTd4uaNir73Jco4v +zLuu2+QVUhkHM/tqty1LkCiCc/4YizWN26cEar7qwU02OxY2kTLvtkCJkUPg8qKrBC7m8kwOFjQg +rIfBLX7JZkcXFBGk8/ehJImr2BrIoVyxo/eMbcgByU/J7MT8rFEz0ciD0cmfHdRHNCk+y7AO+oML +KFjlKdw/fKifybYKu6boRhYPluV75Gp6SG12mAWl3G0eQh5C2hrgUve1g8Aae3g1LDj1H/1Joy7S +WWO/gLCMk3PLNaaZlSJhZQNg+y+TS/qanIA7AgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAdBgNV +HQ4EFgQUtqeXgj10hZv3PJ+TmpV5dVKMbUcwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS2 +p5eCPXSFm/c8n5OalXl1UoxtRzANBgkqhkiG9w0BAQwFAAOCAgEAqMxhpr51nhVQpGv7qHBFfLp+ +sVr8WyP6Cnf4mHGCDG3gXkaqk/QeoMPhk9tLrbKmXauw1GLLXrtm9S3ul0A8Yute1hTWjOKWi0Fp +kzXmuZlrYrShF2Y0pmtjxrlO8iLpWA1WQdH6DErwM807u20hOq6OcrXDSvvpfeWxm4bu4uB9tPcy +/SKE8YXJN3nptT+/XOR0so8RYgDdGGah2XsjX/GO1WfoVNpbOms2b/mBsTNHM3dA+VKq3dSDz4V4 +mZqTuXNnQkYRIer+CqkbGmVps4+uFrb2S1ayLfmlyOw7YqPta9BO1UAJpB+Y1zqlklkg5LB9zVtz +aL1txKITDmcZuI1CfmwMmm6gJC3VRRvcxAIU/oVbZZfKTpBQCHpCNfnqwmbU+AGuHrS+w6jv/naa +oqYfRvaE7fzbzsQCzndILIyy7MMAo+wsVRjBfhnu4S/yrYObnqsZ38aKL4x35bcF7DvB7L6Gs4a8 +wPfc5+pbrrLMtTWGS9DiP7bY+A4A7l3j941Y/8+LN+ljX273CXE2whJdV/LItM3z7gLfEdxquVeE +HVlNjM7IDiPCtyaaEBRx/pOyiriA8A4QntOoUAw3gi/q4Iqd4Sw5/7W0cwDk90imc6y/st53BIe0 +o82bNSQ3+pCTE4FCxpgmdTdmQRCsu/WU48IxK63nI1bMNSWSs1A= +-----END CERTIFICATE----- + +FIRMAPROFESIONAL CA ROOT-A WEB +============================== +-----BEGIN CERTIFICATE----- +MIICejCCAgCgAwIBAgIQMZch7a+JQn81QYehZ1ZMbTAKBggqhkjOPQQDAzBuMQswCQYDVQQGEwJF +UzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25hbCBTQTEYMBYGA1UEYQwPVkFURVMtQTYyNjM0MDY4 +MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFMIENBIFJPT1QtQSBXRUIwHhcNMjIwNDA2MDkwMTM2 +WhcNNDcwMzMxMDkwMTM2WjBuMQswCQYDVQQGEwJFUzEcMBoGA1UECgwTRmlybWFwcm9mZXNpb25h +bCBTQTEYMBYGA1UEYQwPVkFURVMtQTYyNjM0MDY4MScwJQYDVQQDDB5GSVJNQVBST0ZFU0lPTkFM +IENBIFJPT1QtQSBXRUIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARHU+osEaR3xyrq89Zfe9MEkVz6 +iMYiuYMQYneEMy3pA4jU4DP37XcsSmDq5G+tbbT4TIqk5B/K6k84Si6CcyvHZpsKjECcfIr28jlg +st7L7Ljkb+qbXbdTkBgyVcUgt5SjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUk+FD +Y1w8ndYn81LsF7Kpryz3dvgwHQYDVR0OBBYEFJPhQ2NcPJ3WJ/NS7Beyqa8s93b4MA4GA1UdDwEB +/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjAdfKR7w4l1M+E7qUW/Runpod3JIha3RxEL2Jq68cgL +cFBTApFwhVmpHqTm6iMxoAACMQD94vizrxa5HnPEluPBMBnYfubDl94cT7iJLzPrSA8Z94dGXSaQ +pYXFuXqUPoeovQA= +-----END CERTIFICATE----- + +TWCA CYBER Root CA +================== +-----BEGIN CERTIFICATE----- +MIIFjTCCA3WgAwIBAgIQQAE0jMIAAAAAAAAAATzyxjANBgkqhkiG9w0BAQwFADBQMQswCQYDVQQG +EwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NB +IENZQkVSIFJvb3QgQ0EwHhcNMjIxMTIyMDY1NDI5WhcNNDcxMTIyMTU1OTU5WjBQMQswCQYDVQQG +EwJUVzESMBAGA1UEChMJVEFJV0FOLUNBMRAwDgYDVQQLEwdSb290IENBMRswGQYDVQQDExJUV0NB +IENZQkVSIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDG+Moe2Qkgfh1s +Ts6P40czRJzHyWmqOlt47nDSkvgEs1JSHWdyKKHfi12VCv7qze33Kc7wb3+szT3vsxxFavcokPFh +V8UMxKNQXd7UtcsZyoC5dc4pztKFIuwCY8xEMCDa6pFbVuYdHNWdZsc/34bKS1PE2Y2yHer43CdT +o0fhYcx9tbD47nORxc5zb87uEB8aBs/pJ2DFTxnk684iJkXXYJndzk834H/nY62wuFm40AZoNWDT +Nq5xQwTxaWV4fPMf88oon1oglWa0zbfuj3ikRRjpJi+NmykosaS3Om251Bw4ckVYsV7r8Cibt4LK +/c/WMw+f+5eesRycnupfXtuq3VTpMCEobY5583WSjCb+3MX2w7DfRFlDo7YDKPYIMKoNM+HvnKkH +IuNZW0CP2oi3aQiotyMuRAlZN1vH4xfyIutuOVLF3lSnmMlLIJXcRolftBL5hSmO68gnFSDAS9TM +fAxsNAwmmyYxpjyn9tnQS6Jk/zuZQXLB4HCX8SS7K8R0IrGsayIyJNN4KsDAoS/xUgXJP+92ZuJF +2A09rZXIx4kmyA+upwMu+8Ff+iDhcK2wZSA3M2Cw1a/XDBzCkHDXShi8fgGwsOsVHkQGzaRP6AzR +wyAQ4VRlnrZR0Bp2a0JaWHY06rc3Ga4udfmW5cFZ95RXKSWNOkyrTZpB0F8mAwIDAQABo2MwYTAO +BgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBSdhWEUfMFib5do5E83 +QOGt4A1WNzAdBgNVHQ4EFgQUnYVhFHzBYm+XaORPN0DhreANVjcwDQYJKoZIhvcNAQEMBQADggIB +AGSPesRiDrWIzLjHhg6hShbNcAu3p4ULs3a2D6f/CIsLJc+o1IN1KriWiLb73y0ttGlTITVX1olN +c79pj3CjYcya2x6a4CD4bLubIp1dhDGaLIrdaqHXKGnK/nZVekZn68xDiBaiA9a5F/gZbG0jAn/x +X9AKKSM70aoK7akXJlQKTcKlTfjF/biBzysseKNnTKkHmvPfXvt89YnNdJdhEGoHK4Fa0o635yDR +IG4kqIQnoVesqlVYL9zZyvpoBJ7tRCT5dEA7IzOrg1oYJkK2bVS1FmAwbLGg+LhBoF1JSdJlBTrq +/p1hvIbZv97Tujqxf36SNI7JAG7cmL3c7IAFrQI932XtCwP39xaEBDG6k5TY8hL4iuO/Qq+n1M0R +FxbIQh0UqEL20kCGoE8jypZFVmAGzbdVAaYBlGX+bgUJurSkquLvWL69J1bY73NxW0Qz8ppy6rBe +Pm6pUlvscG21h483XjyMnM7k8M4MZ0HMzvaAq07MTFb1wWFZk7Q+ptq4NxKfKjLji7gh7MMrZQzv +It6IKTtM1/r+t+FHvpw+PoP7UV31aPcuIYXcv/Fa4nzXxeSDwWrruoBa3lwtcHb4yOWHh8qgnaHl +IhInD0Q9HWzq1MKLL295q39QpsQZp6F6t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X +-----END CERTIFICATE----- + +SecureSign Root CA12 +==================== +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQELBQAwUTELMAkG +A1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRT +ZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgwNTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJ +BgNVBAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMU +U2VjdXJlU2lnbiBSb290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3 +emhFKxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mtp7JIKwcc +J/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zdJ1M3s6oYwlkm7Fsf0uZl +fO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gurFzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBF +EaCeVESE99g2zvVQR9wsMJvuwPWW0v4JhscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1Uef +NzFJM3IFTQy2VYzxV4+Kh9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsFAAOC +AQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6LdmmQOmFxv3Y67ilQi +LUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJmBClnW8Zt7vPemVV2zfrPIpyMpce +mik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPS +vWKErI4cqc1avTc7bgoitPQV55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhga +aaI5gdka9at/yOPiZwud9AzqVN/Ssq+xIvEg37xEHA== +-----END CERTIFICATE----- + +SecureSign Root CA14 +==================== +-----BEGIN CERTIFICATE----- +MIIFcjCCA1qgAwIBAgIUZNtaDCBO6Ncpd8hQJ6JaJ90t8sswDQYJKoZIhvcNAQEMBQAwUTELMAkG +A1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRT +ZWN1cmVTaWduIFJvb3QgQ0ExNDAeFw0yMDA0MDgwNzA2MTlaFw00NTA0MDgwNzA2MTlaMFExCzAJ +BgNVBAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMU +U2VjdXJlU2lnbiBSb290IENBMTQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDF0nqh +1oq/FjHQmNE6lPxauG4iwWL3pwon71D2LrGeaBLwbCRjOfHw3xDG3rdSINVSW0KZnvOgvlIfX8xn +bacuUKLBl422+JX1sLrcneC+y9/3OPJH9aaakpUqYllQC6KxNedlsmGy6pJxaeQp8E+BgQQ8sqVb +1MWoWWd7VRxJq3qdwudzTe/NCcLEVxLbAQ4jeQkHO6Lo/IrPj8BGJJw4J+CDnRugv3gVEOuGTgpa +/d/aLIJ+7sr2KeH6caH3iGicnPCNvg9JkdjqOvn90Ghx2+m1K06Ckm9mH+Dw3EzsytHqunQG+bOE +kJTRX45zGRBdAuVwpcAQ0BB8b8VYSbSwbprafZX1zNoCr7gsfXmPvkPx+SgojQlD+Ajda8iLLCSx +jVIHvXiby8posqTdDEx5YMaZ0ZPxMBoH064iwurO8YQJzOAUbn8/ftKChazcqRZOhaBgy/ac18iz +ju3Gm5h1DVXoX+WViwKkrkMpKBGk5hIwAUt1ax5mnXkvpXYvHUC0bcl9eQjs0Wq2XSqypWa9a4X0 +dFbD9ed1Uigspf9mR6XU/v6eVL9lfgHWMI+lNpyiUBzuOIABSMbHdPTGrMNASRZhdCyvjG817XsY +AFs2PJxQDcqSMxDxJklt33UkN4Ii1+iW/RVLApY+B3KVfqs9TC7XyvDf4Fg/LS8EmjijAQIDAQAB +o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUBpOjCl4oaTeq +YR3r6/wtbyPk86AwDQYJKoZIhvcNAQEMBQADggIBAJaAcgkGfpzMkwQWu6A6jZJOtxEaCnFxEM0E +rX+lRVAQZk5KQaID2RFPeje5S+LGjzJmdSX7684/AykmjbgWHfYfM25I5uj4V7Ibed87hwriZLoA +ymzvftAj63iP/2SbNDefNWWipAA9EiOWWF3KY4fGoweITedpdopTzfFP7ELyk+OZpDc8h7hi2/Ds +Hzc/N19DzFGdtfCXwreFamgLRB7lUe6TzktuhsHSDCRZNhqfLJGP4xjblJUK7ZGqDpncllPjYYPG +FrojutzdfhrGe0K22VoF3Jpf1d+42kd92jjbrDnVHmtsKheMYc2xbXIBw8MgAGJoFjHVdqqGuw6q +nsb58Nn4DSEC5MUoFlkRudlpcyqSeLiSV5sI8jrlL5WwWLdrIBRtFO8KvH7YVdiI2i/6GaX7i+B/ +OfVyK4XELKzvGUWSTLNhB9xNH27SgRNcmvMSZ4PPmz+Ln52kuaiWA3rF7iDeM9ovnhp6dB7h7sxa +OgTdsxoEqBRjrLdHEoOabPXm6RUVkRqEGQ6UROcSjiVbgGcZ3GOTEAtlLor6CZpO2oYofaphNdgO +pygau1LgePhsumywbrmHXumZNTfxPWQrqaA0k89jL9WB365jJ6UeTo3cKXhZ+PmhIIynJkBugnLN +eLLIjzwec+fBH7/PzqUqm9tEZDKgu39cJRNItX+S +-----END CERTIFICATE----- + +SecureSign Root CA15 +==================== +-----BEGIN CERTIFICATE----- +MIICIzCCAamgAwIBAgIUFhXHw9hJp75pDIqI7fBw+d23PocwCgYIKoZIzj0EAwMwUTELMAkGA1UE +BhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28uLCBMdGQuMR0wGwYDVQQDExRTZWN1 +cmVTaWduIFJvb3QgQ0ExNTAeFw0yMDA0MDgwODMyNTZaFw00NTA0MDgwODMyNTZaMFExCzAJBgNV +BAYTAkpQMSMwIQYDVQQKExpDeWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2Vj +dXJlU2lnbiBSb290IENBMTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQLUHSNZDKZmbPSYAi4Io5G +dCx4wCtELW1fHcmuS1Iggz24FG1Th2CeX2yF2wYUleDHKP+dX+Sq8bOLbe1PL0vJSpSRZHX+AezB +2Ot6lHhWGENfa4HL9rzatAy2KZMIaY+jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD +AgEGMB0GA1UdDgQWBBTrQciu/NWeUUj1vYv0hyCTQSvT9DAKBggqhkjOPQQDAwNoADBlAjEA2S6J +fl5OpBEHvVnCB96rMjhTKkZEBhd6zlHp4P9mLQlO4E/0BdGF9jVg3PVys0Z9AjBEmEYagoUeYWmJ +SwdLZrWeqrqgHkHZAXQ6bkU6iYAZezKYVWOr62Nuk22rGwlgMU4= +-----END CERTIFICATE----- + +D-TRUST BR Root CA 2 2023 +========================= +-----BEGIN CERTIFICATE----- +MIIFqTCCA5GgAwIBAgIQczswBEhb2U14LnNLyaHcZjANBgkqhkiG9w0BAQ0FADBIMQswCQYDVQQG +EwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEJSIFJvb3QgQ0Eg +MiAyMDIzMB4XDTIzMDUwOTA4NTYzMVoXDTM4MDUwOTA4NTYzMFowSDELMAkGA1UEBhMCREUxFTAT +BgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBCUiBSb290IENBIDIgMjAyMzCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK7/CVmRgApKaOYkP7in5Mg6CjoWzckjYaCT +cfKri3OPoGdlYNJUa2NRb0kz4HIHE304zQaSBylSa053bATTlfrdTIzZXcFhfUvnKLNEgXtRr90z +sWh81k5M/itoucpmacTsXld/9w3HnDY25QdgrMBM6ghs7wZ8T1soegj8k12b9py0i4a6Ibn08OhZ +WiihNIQaJZG2tY/vsvmA+vk9PBFy2OMvhnbFeSzBqZCTRphny4NqoFAjpzv2gTng7fC5v2Xx2Mt6 +++9zA84A9H3X4F07ZrjcjrqDy4d2A/wl2ecjbwb9Z/Pg/4S8R7+1FhhGaRTMBffb00msa8yr5LUL +QyReS2tNZ9/WtT5PeB+UcSTq3nD88ZP+npNa5JRal1QMNXtfbO4AHyTsA7oC9Xb0n9Sa7YUsOCIv +x9gvdhFP/Wxc6PWOJ4d/GUohR5AdeY0cW/jPSoXk7bNbjb7EZChdQcRurDhaTyN0dKkSw/bSuREV +MweR2Ds3OmMwBtHFIjYoYiMQ4EbMl6zWK11kJNXuHA7e+whadSr2Y23OC0K+0bpwHJwh5Q8xaRfX +/Aq03u2AnMuStIv13lmiWAmlY0cL4UEyNEHZmrHZqLAbWt4NDfTisl01gLmB1IRpkQLLddCNxbU9 +CZEJjxShFHR5PtbJFR2kWVki3PaKRT08EtY+XTIvAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUZ5Dw1t61GNVGKX5cq/ieCLxklRAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRC +MEAwPqA8oDqGOGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfYnJfcm9vdF9jYV8y +XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQA097N3U9swFrktpSHxQCF16+tIFoE9c+CeJyrr +d6kTpGoKWloUMz1oH4Guaf2Mn2VsNELZLdB/eBaxOqwjMa1ef67nriv6uvw8l5VAk1/DLQOj7aRv +U9f6QA4w9QAgLABMjDu0ox+2v5Eyq6+SmNMW5tTRVFxDWy6u71cqqLRvpO8NVhTaIasgdp4D/Ca4 +nj8+AybmTNudX0KEPUUDAxxZiMrcLmEkWqTqJwtzEr5SswrPMhfiHocaFpVIbVrg0M8JkiZmkdij +YQ6qgYF/6FKC0ULn4B0Y+qSFNueG4A3rvNTJ1jxD8V1Jbn6Bm2m1iWKPiFLY1/4nwSPFyysCu7Ff +/vtDhQNGvl3GyiEm/9cCnnRK3PgTFbGBVzbLZVzRHTF36SXDw7IyN9XxmAnkbWOACKsGkoHU6XCP +pz+y7YaMgmo1yEJagtFSGkUPFaUA8JR7ZSdXOUPPfH/mvTWze/EZTN46ls/pdu4D58JDUjxqgejB +WoC9EV2Ta/vH5mQ/u2kc6d0li690yVRAysuTEwrt+2aSEcr1wPrYg1UDfNPFIkZ1cGt5SAYqgpq/ +5usWDiJFAbzdNpQ0qTUmiteXue4Icr80knCDgKs4qllo3UCkGJCy89UDyibK79XH4I9TjvAA46jt +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----- +MIIFqTCCA5GgAwIBAgIQaSYJfoBLTKCnjHhiU19abzANBgkqhkiG9w0BAQ0FADBIMQswCQYDVQQG +EwJERTEVMBMGA1UEChMMRC1UcnVzdCBHbWJIMSIwIAYDVQQDExlELVRSVVNUIEVWIFJvb3QgQ0Eg +MiAyMDIzMB4XDTIzMDUwOTA5MTAzM1oXDTM4MDUwOTA5MTAzMlowSDELMAkGA1UEBhMCREUxFTAT +BgNVBAoTDEQtVHJ1c3QgR21iSDEiMCAGA1UEAxMZRC1UUlVTVCBFViBSb290IENBIDIgMjAyMzCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANiOo4mAC7JXUtypU0w3uX9jFxPvp1sjW2l1 +sJkKF8GLxNuo4MwxusLyzV3pt/gdr2rElYfXR8mV2IIEUD2BCP/kPbOx1sWy/YgJ25yE7CUXFId/ +MHibaljJtnMoPDT3mfd/06b4HEV8rSyMlD/YZxBTfiLNTiVR8CUkNRFeEMbsh2aJgWi6zCudR3Mf +vc2RpHJqnKIbGKBv7FD0fUDCqDDPvXPIEysQEx6Lmqg6lHPTGGkKSv/BAQP/eX+1SH977ugpbzZM +lWGG2Pmic4ruri+W7mjNPU0oQvlFKzIbRlUWaqZLKfm7lVa/Rh3sHZMdwGWyH6FDrlaeoLGPaxK3 +YG14C8qKXO0elg6DpkiVjTujIcSuWMYAsoS0I6SWhjW42J7YrDRJmGOVxcttSEfi8i4YHtAxq910 +7PncjLgcjmgjutDzUNzPZY9zOjLHfP7KgiJPvo5iR2blzYfi6NUPGJ/lBHJLRjwQ8kTCZFZxTnXo +nMkmdMV9WdEKWw9t/p51HBjGGjp82A0EzM23RWV6sY+4roRIPrN6TagD4uJ+ARZZaBhDM7DS3LAa +QzXupdqpRlyuhoFBAUp0JuyfBr/CBTdkdXgpaP3F9ev+R/nkhbDhezGdpn9yo7nELC7MmVcOIQxF +AZRl62UJxmMiCzNJkkg8/M3OsD6Onov4/knFNXJHAgMBAAGjgY4wgYswDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUqvyREBuHkV8Wub9PS5FeAByxMoAwDgYDVR0PAQH/BAQDAgEGMEkGA1UdHwRC +MEAwPqA8oDqGOGh0dHA6Ly9jcmwuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3RfZXZfcm9vdF9jYV8y +XzIwMjMuY3JsMA0GCSqGSIb3DQEBDQUAA4ICAQCTy6UfmRHsmg1fLBWTxj++EI14QvBukEdHjqOS +Mo1wj/Zbjb6JzkcBahsgIIlbyIIQbODnmaprxiqgYzWRaoUlrRc4pZt+UPJ26oUFKidBK7GB0aL2 +QHWpDsvxVUjY7NHss+jOFKE17MJeNRqrphYBBo7q3C+jisosketSjl8MmxfPy3MHGcRqwnNU73xD +UmPBEcrCRbH0O1P1aa4846XerOhUt7KR/aypH/KH5BfGSah82ApB9PI+53c0BFLd6IHyTS9URZ0V +4U/M5d40VxDJI3IXcI1QcB9WbMy5/zpaT2N6w25lBx2Eof+pDGOJbbJAiDnXH3dotfyc1dZnaVuo +dNv8ifYbMvekJKZ2t0dT741Jj6m2g1qllpBFYfXeA08mD6iL8AOWsKwV0HFaanuU5nCT2vFp4LJi +TZ6P/4mdm13NRemUAiKN4DV/6PEEeXFsVIP4M7kFMhtYVRFP0OUnR3Hs7dpn1mKmS00PaaLJvOwi +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----- + +OISTE Server Root ECC G1 +======================== +-----BEGIN CERTIFICATE----- +MIICNTCCAbqgAwIBAgIQI/nD1jWvjyhLH/BU6n6XnTAKBggqhkjOPQQDAzBLMQswCQYDVQQGEwJD +SDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwYT0lTVEUgU2VydmVyIFJvb3Qg +RUNDIEcxMB4XDTIzMDUzMTE0NDIyOFoXDTQ4MDUyNDE0NDIyN1owSzELMAkGA1UEBhMCQ0gxGTAX +BgNVBAoMEE9JU1RFIEZvdW5kYXRpb24xITAfBgNVBAMMGE9JU1RFIFNlcnZlciBSb290IEVDQyBH +MTB2MBAGByqGSM49AgEGBSuBBAAiA2IABBcv+hK8rBjzCvRE1nZCnrPoH7d5qVi2+GXROiFPqOuj +vqQycvO2Ackr/XeFblPdreqqLiWStukhEaivtUwL85Zgmjvn6hp4LrQ95SjeHIC6XG4N2xml4z+c +KrhAS93mT6NjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBQ3TYhlz/w9itWj8UnATgwQ +b0K0nDAdBgNVHQ4EFgQUN02IZc/8PYrVo/FJwE4MEG9CtJwwDgYDVR0PAQH/BAQDAgGGMAoGCCqG +SM49BAMDA2kAMGYCMQCpKjAd0MKfkFFRQD6VVCHNFmb3U2wIFjnQEnx/Yxvf4zgAOdktUyBFCxxg +ZzFDJe0CMQCSia7pXGKDYmH5LVerVrkR3SW+ak5KGoJr3M/TvEqzPNcum9v4KGm8ay3sMaE641c= +-----END CERTIFICATE----- + + OISTE Server Root RSA G1 +========================= +-----BEGIN CERTIFICATE----- +MIIFgzCCA2ugAwIBAgIQVaXZZ5Qoxu0M+ifdWwFNGDANBgkqhkiG9w0BAQwFADBLMQswCQYDVQQG +EwJDSDEZMBcGA1UECgwQT0lTVEUgRm91bmRhdGlvbjEhMB8GA1UEAwwYT0lTVEUgU2VydmVyIFJv +b3QgUlNBIEcxMB4XDTIzMDUzMTE0MzcxNloXDTQ4MDUyNDE0MzcxNVowSzELMAkGA1UEBhMCQ0gx +GTAXBgNVBAoMEE9JU1RFIEZvdW5kYXRpb24xITAfBgNVBAMMGE9JU1RFIFNlcnZlciBSb290IFJT +QSBHMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKqu9KuCz/vlNwvn1ZatkOhLKdxV +YOPMvLO8LZK55KN68YG0nnJyQ98/qwsmtO57Gmn7KNByXEptaZnwYx4M0rH/1ow00O7brEi56rAU +jtgHqSSY3ekJvqgiG1k50SeH3BzN+Puz6+mTeO0Pzjd8JnduodgsIUzkik/HEzxux9UTl7Ko2yRp +g1bTacuCErudG/L4NPKYKyqOBGf244ehHa1uzjZ0Dl4zO8vbUZeUapU8zhhabkvG/AePLhq5Svdk +NCncpo1Q4Y2LS+VIG24ugBA/5J8bZT8RtOpXaZ+0AOuFJJkk9SGdl6r7NH8CaxWQrbueWhl/pIzY ++m0o/DjH40ytas7ZTpOSjswMZ78LS5bOZmdTaMsXEY5Z96ycG7mOaES3GK/m5Q9l3JUJsJMStR8+ +lKXHiHUhsd4JJCpM4rzsTGdHwimIuQq6+cF0zowYJmXa92/GjHtoXAvuY8BeS/FOzJ8vD+HomnqT +8eDI278n5mUpezbgMxVz8p1rhAhoKzYHKyfMeNhqhw5HdPSqoBNdZH702xSu+zrkL8Fl47l6QGzw +Brd7KJvX4V84c5Ss2XCTLdyEr0YconosP4EmQufU2MVshGYRi3drVByjtdgQ8K4p92cIiBdcuJd5 +z+orKu5YM+Vt6SmqZQENghPsJQtdLEByFSnTkCz3GkPVavBpAgMBAAGjYzBhMA8GA1UdEwEB/wQF +MAMBAf8wHwYDVR0jBBgwFoAU8snBDw1jALvsRQ5KH7WxszbNDo0wHQYDVR0OBBYEFPLJwQ8NYwC7 +7EUOSh+1sbM2zQ6NMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQwFAAOCAgEANGd5sjrG5T33 +I3K5Ce+SrScfoE4KsvXaFwyihdJ+klH9FWXXXGtkFu6KRcoMQzZENdl//nk6HOjG5D1rd9QhEOP2 +8yBOqb6J8xycqd+8MDoX0TJD0KqKchxRKEzdNsjkLWd9kYccnbz8qyiWXmFcuCIzGEgWUOrKL+ml +Sdx/PKQZvDatkuK59EvV6wit53j+F8Bdh3foZ3dPAGav9LEDOr4SfEE15fSmG0eLy3n31r8Xbk5l +8PjaV8GUgeV6Vg27Rn9vkf195hfkgSe7BYhW3SCl95gtkRlpMV+bMPKZrXJAlszYd2abtNUOshD+ +FKrDgHGdPY3ofRRsYWSGRqbXVMW215AWRqWFyp464+YTFrYVI8ypKVL9AMb2kI5Wj4kI3Zaq5tNq +qYY19tVFeEJKRvwDyF7YZvZFZSS0vod7VSCd9521Kvy5YhnLbDuv0204bKt7ph6N/Ome/msVuduC +msuY33OhkKCgxeDoAaijFJzIwZqsFVAzje18KotzlUBDJvyBpCpfOZC3J8tRd/iWkx7P8nd9H0aT +olkelUTFLXVksNb54Dxp6gS1HAviRkRNQzuXSXERvSS2wq1yVAb+axj5d9spLFKebXd7Yv0PTY6Y +MjAwcRLWJTXjn/hvnLXrahut6hDTlhZyBiElxky8j3C7DOReIoMt0r7+hVu05L0= +-----END CERTIFICATE----- diff --git a/public/kirby/composer.json b/public/kirby/composer.json new file mode 100644 index 0000000..1508480 --- /dev/null +++ b/public/kirby/composer.json @@ -0,0 +1,117 @@ +{ + "name": "getkirby/cms", + "description": "The Kirby core", + "license": "proprietary", + "type": "kirby-cms", + "version": "5.1.4", + "keywords": [ + "kirby", + "cms", + "core" + ], + "authors": [ + { + "name": "Kirby Team", + "email": "support@getkirby.com", + "homepage": "https://getkirby.com" + } + ], + "homepage": "https://getkirby.com", + "support": { + "email": "support@getkirby.com", + "issues": "https://github.com/getkirby/kirby/issues", + "forum": "https://forum.getkirby.com", + "source": "https://github.com/getkirby/kirby" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "ext-SimpleXML": "*", + "ext-ctype": "*", + "ext-curl": "*", + "ext-dom": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "christian-riesen/base32": "1.6.0", + "claviska/simpleimage": "4.2.1", + "composer/semver": "3.4.4", + "filp/whoops": "2.18.4", + "getkirby/composer-installer": "^1.2.1", + "laminas/laminas-escaper": "2.18.0", + "michelf/php-smartypants": "1.8.1", + "phpmailer/phpmailer": "6.10.0", + "symfony/polyfill-intl-idn": "1.33.0", + "symfony/polyfill-mbstring": "1.33.0", + "symfony/yaml": "7.3.5" + }, + "replace": { + "symfony/polyfill-php72": "*" + }, + "suggest": { + "ext-PDO": "Support for using databases", + "ext-apcu": "Support for the Apcu cache driver", + "ext-exif": "Support for exif information from images", + "ext-fileinfo": "Improved mime type detection for files", + "ext-imagick": "Improved thumbnail generation", + "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" + }, + "autoload": { + "psr-4": { + "Kirby\\": "src/" + }, + "classmap": [ + "dependencies/" + ], + "files": [ + "config/setup.php", + "config/helpers.php" + ] + }, + "config": { + "allow-plugins": { + "getkirby/composer-installer": true + }, + "optimize-autoloader": true, + "platform": { + "php": "8.2.0" + }, + "platform-check": false + }, + "extra": { + "unused": [ + "symfony/polyfill-intl-idn" + ] + }, + "scripts": { + "post-update-cmd": "curl -o cacert.pem https://curl.se/ca/cacert.pem", + "analyze": [ + "@analyze:composer", + "@analyze:psalm", + "@analyze:phpmd" + ], + "analyze:composer": "composer validate --strict --no-check-version --no-check-all", + "analyze:phpmd": "phpmd . ansi phpmd.xml.dist --exclude 'dependencies/*,tests/*,vendor/*'", + "analyze:psalm": "psalm", + "bench": "phpbench run --report=aggregate --ref baseline", + "bench:baseline": "phpbench run --report=aggregate --tag baseline", + "build": "./scripts/build", + "ci": [ + "@fix", + "@analyze", + "@test" + ], + "fix": "php-cs-fixer fix", + "test": "phpunit", + "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-html=tests/coverage", + "zip": "composer archive --format=zip --file=dist" + } +} diff --git a/public/kirby/composer.lock b/public/kirby/composer.lock new file mode 100644 index 0000000..d24b008 --- /dev/null +++ b/public/kirby/composer.lock @@ -0,0 +1,1132 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "795ee10b23199d9cf9e58f499c13e69e", + "packages": [ + { + "name": "christian-riesen/base32", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/ChristianRiesen/base32.git", + "reference": "2e82dab3baa008e24a505649b0d583c31d31e894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristianRiesen/base32/zipball/2e82dab3baa008e24a505649b0d583c31d31e894", + "reference": "2e82dab3baa008e24a505649b0d583c31d31e894", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.17", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.5.13 || ^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Base32\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Riesen", + "email": "chris.riesen@gmail.com", + "homepage": "http://christianriesen.com", + "role": "Developer" + } + ], + "description": "Base32 encoder/decoder according to RFC 4648", + "homepage": "https://github.com/ChristianRiesen/base32", + "keywords": [ + "base32", + "decode", + "encode", + "rfc4648" + ], + "support": { + "issues": "https://github.com/ChristianRiesen/base32/issues", + "source": "https://github.com/ChristianRiesen/base32/tree/1.6.0" + }, + "time": "2021-02-26T10:19:33+00:00" + }, + { + "name": "claviska/simpleimage", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/claviska/SimpleImage.git", + "reference": "ec6d5021e5a7153a2520d64c59b86b6f3c4157c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/claviska/SimpleImage/zipball/ec6d5021e5a7153a2520d64c59b86b6f3c4157c5", + "reference": "ec6d5021e5a7153a2520d64c59b86b6f3c4157c5", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "league/color-extractor": "0.4.*", + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.5", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "autoload": { + "psr-0": { + "claviska": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cory LaViska", + "homepage": "http://www.abeautifulsite.net/", + "role": "Developer" + } + ], + "description": "A PHP class that makes working with images as simple as possible.", + "support": { + "issues": "https://github.com/claviska/SimpleImage/issues", + "source": "https://github.com/claviska/SimpleImage/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/claviska", + "type": "github" + } + ], + "time": "2024-11-22T13:25:03+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "getkirby/composer-installer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/getkirby/composer-installer.git", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^1.8 || ^2.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Kirby\\ComposerInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "Kirby\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins", + "homepage": "https://getkirby.com", + "support": { + "issues": "https://github.com/getkirby/composer-installer/issues", + "source": "https://github.com/getkirby/composer-installer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://getkirby.com/buy", + "type": "custom" + } + ], + "time": "2020-12-28T12:54:39+00:00" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-14T18:31:13+00:00" + }, + { + "name": "league/color-extractor", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/color-extractor.git", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/21fcac6249c5ef7d00eb83e128743ee6678fe505", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "php": "^7.3 || ^8.0" + }, + "replace": { + "matthecat/colorextractor": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\ColorExtractor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "description": "Extract colors from an image as a human would do.", + "homepage": "https://github.com/thephpleague/color-extractor", + "keywords": [ + "color", + "extract", + "human", + "image", + "palette" + ], + "support": { + "issues": "https://github.com/thephpleague/color-extractor/issues", + "source": "https://github.com/thephpleague/color-extractor/tree/0.4.0" + }, + "time": "2022-09-24T15:57:16+00:00" + }, + { + "name": "michelf/php-smartypants", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/michelf/php-smartypants.git", + "reference": "47d17c90a4dfd0ccf1f87e25c65e6c8012415aad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/michelf/php-smartypants/zipball/47d17c90a4dfd0ccf1f87e25c65e6c8012415aad", + "reference": "47d17c90a4dfd0ccf1f87e25c65e6c8012415aad", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Michelf": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Michel Fortin", + "email": "michel.fortin@michelf.ca", + "homepage": "https://michelf.ca/", + "role": "Developer" + }, + { + "name": "John Gruber", + "homepage": "https://daringfireball.net/" + } + ], + "description": "PHP SmartyPants", + "homepage": "https://michelf.ca/projects/php-smartypants/", + "keywords": [ + "dashes", + "quotes", + "spaces", + "typographer", + "typography" + ], + "support": { + "issues": "https://github.com/michelf/php-smartypants/issues", + "source": "https://github.com/michelf/php-smartypants/tree/1.8.1" + }, + "time": "2016-12-13T01:01:17+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "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.10.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2025-04-24T15:19:31+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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-09-27T09:00:46+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "ext-simplexml": "*", + "ext-ctype": "*", + "ext-curl": "*", + "ext-dom": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, + "plugin-api-version": "2.6.0" +} diff --git a/public/kirby/config/aliases.php b/public/kirby/config/aliases.php new file mode 100644 index 0000000..fe5537b --- /dev/null +++ b/public/kirby/config/aliases.php @@ -0,0 +1,97 @@ + 'Kirby\Cms\Collection', + 'file' => 'Kirby\Cms\File', + 'files' => 'Kirby\Cms\Files', + 'find' => 'Kirby\Cms\Find', + 'helpers' => 'Kirby\Cms\Helpers', + 'html' => 'Kirby\Cms\Html', + 'kirby' => 'Kirby\Cms\App', + 'page' => 'Kirby\Cms\Page', + 'pages' => 'Kirby\Cms\Pages', + 'pagination' => 'Kirby\Cms\Pagination', + 'r' => 'Kirby\Cms\R', + 'response' => 'Kirby\Cms\Response', + 's' => 'Kirby\Cms\S', + 'sane' => 'Kirby\Sane\Sane', + 'site' => 'Kirby\Cms\Site', + 'structure' => 'Kirby\Cms\Structure', + 'url' => 'Kirby\Cms\Url', + 'user' => 'Kirby\Cms\User', + 'users' => 'Kirby\Cms\Users', + 'visitor' => 'Kirby\Cms\Visitor', + + // content classes + 'field' => 'Kirby\Content\Field', + + // data handler + 'data' => 'Kirby\Data\Data', + 'json' => 'Kirby\Data\Json', + 'yaml' => 'Kirby\Data\Yaml', + + // file classes + 'asset' => 'Kirby\Filesystem\Asset', + 'dir' => 'Kirby\Filesystem\Dir', + 'f' => 'Kirby\Filesystem\F', + 'mime' => 'Kirby\Filesystem\Mime', + + // data classes + 'database' => 'Kirby\Database\Database', + 'db' => 'Kirby\Database\Db', + + // exceptions + 'errorpageexception' => 'Kirby\Exception\ErrorPageException', + + // http classes + 'cookie' => 'Kirby\Http\Cookie', + 'header' => 'Kirby\Http\Header', + 'remote' => 'Kirby\Http\Remote', + + // image classes + 'dimensions' => 'Kirby\Image\Dimensions', + + // panel classes + 'panel' => 'Kirby\Panel\Panel', + + // template classes + 'snippet' => 'Kirby\Template\Snippet', + 'slot' => 'Kirby\Template\Slot', + + // toolkit classes + 'a' => 'Kirby\Toolkit\A', + 'c' => 'Kirby\Toolkit\Config', + 'config' => 'Kirby\Toolkit\Config', + 'escape' => 'Kirby\Toolkit\Escape', + 'i18n' => 'Kirby\Toolkit\I18n', + 'obj' => 'Kirby\Toolkit\Obj', + 'str' => 'Kirby\Toolkit\Str', + 'tpl' => 'Kirby\Toolkit\Tpl', + 'v' => 'Kirby\Toolkit\V', + 'xml' => 'Kirby\Toolkit\Xml', + + // Deprecated aliases: + // 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\dir' => 'Kirby\Filesystem\Dir', + 'kirby\cms\filename' => 'Kirby\Filesystem\Filename', + 'kirby\cms\filefoundation' => 'Kirby\Filesystem\IsFile', + 'kirby\cms\field' => 'Kirby\Content\Field', + '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', + 'kirby\form\optionsquery' => 'Kirby\Option\OptionsQuery', + 'kirby\toolkit\dir' => 'Kirby\Filesystem\Dir', + 'kirby\toolkit\f' => 'Kirby\Filesystem\F', + 'kirby\toolkit\file' => 'Kirby\Filesystem\File', + 'kirby\toolkit\mime' => 'Kirby\Filesystem\Mime', + 'kirby\toolkit\query' => 'Kirby\Query\Query', +]; diff --git a/public/kirby/config/api/authentication.php b/public/kirby/config/api/authentication.php new file mode 100644 index 0000000..0cee131 --- /dev/null +++ b/public/kirby/config/api/authentication.php @@ -0,0 +1,27 @@ +kirby()->auth(); + $allowImpersonation = $this->kirby()->option('api.allowImpersonation') ?? false; + + // csrf token check + if ( + $auth->type($allowImpersonation) === 'session' && + $auth->csrf() === false + ) { + 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'); + } + + return $user; + } + + throw new AuthException(message: 'Unauthenticated'); +}; diff --git a/public/kirby/config/api/collections.php b/public/kirby/config/api/collections.php new file mode 100644 index 0000000..97b2180 --- /dev/null +++ b/public/kirby/config/api/collections.php @@ -0,0 +1,78 @@ + [ + 'model' => 'page', + 'type' => Pages::class, + 'view' => 'compact' + ], + + /** + * Files + */ + 'files' => [ + 'model' => 'file', + 'type' => Files::class, + ], + + /** + * Languages + */ + 'languages' => [ + 'model' => 'language', + 'type' => Languages::class, + ], + + /** + * Pages + */ + 'pages' => [ + 'model' => 'page', + 'type' => Pages::class, + 'view' => 'compact' + ], + + /** + * Roles + */ + 'roles' => [ + 'model' => 'role', + 'type' => Roles::class, + 'view' => 'compact' + ], + + /** + * Translations + */ + 'translations' => [ + 'model' => 'translation', + 'type' => Translations::class, + 'view' => 'compact' + ], + + /** + * Users + */ + 'users' => [ + 'default' => fn () => $this->users(), + 'model' => 'user', + 'type' => Users::class, + 'view' => 'compact' + ] + +]; diff --git a/public/kirby/config/api/models.php b/public/kirby/config/api/models.php new file mode 100644 index 0000000..9e6fc18 --- /dev/null +++ b/public/kirby/config/api/models.php @@ -0,0 +1,21 @@ + include __DIR__ . '/models/File.php', + 'FileBlueprint' => include __DIR__ . '/models/FileBlueprint.php', + 'FileVersion' => include __DIR__ . '/models/FileVersion.php', + 'Language' => include __DIR__ . '/models/Language.php', + 'License' => include __DIR__ . '/models/License.php', + 'Page' => include __DIR__ . '/models/Page.php', + 'PageBlueprint' => include __DIR__ . '/models/PageBlueprint.php', + 'Role' => include __DIR__ . '/models/Role.php', + 'Site' => include __DIR__ . '/models/Site.php', + 'SiteBlueprint' => include __DIR__ . '/models/SiteBlueprint.php', + 'System' => include __DIR__ . '/models/System.php', + 'Translation' => include __DIR__ . '/models/Translation.php', + 'User' => include __DIR__ . '/models/User.php', + 'UserBlueprint' => include __DIR__ . '/models/UserBlueprint.php', +]; diff --git a/public/kirby/config/api/models/File.php b/public/kirby/config/api/models/File.php new file mode 100644 index 0000000..b70919a --- /dev/null +++ b/public/kirby/config/api/models/File.php @@ -0,0 +1,119 @@ + [ + 'blueprint' => fn (File $file) => $file->blueprint(), + 'content' => fn (File $file) => Form::for($file)->values(), + 'dimensions' => fn (File $file) => $file->dimensions()->toArray(), + 'dragText' => fn (File $file) => $file->panel()->dragText(), + 'exists' => fn (File $file) => $file->exists(), + 'extension' => fn (File $file) => $file->extension(), + 'filename' => fn (File $file) => $file->filename(), + 'id' => fn (File $file) => $file->id(), + 'link' => fn (File $file) => $file->panel()->url(true), + 'mime' => fn (File $file) => $file->mime(), + 'modified' => fn (File $file) => $file->modified('c'), + 'name' => fn (File $file) => $file->name(), + 'next' => fn (File $file) => $file->next(), + 'nextWithTemplate' => function (File $file) { + $files = $file->templateSiblings()->sorted(); + $index = $files->indexOf($file); + + return $files->nth($index + 1); + }, + 'niceSize' => fn (File $file) => $file->niceSize(), + 'options' => fn (File $file) => $file->panel()->options(), + 'panelImage' => fn (File $file) => $file->panel()->image(), + 'panelUrl' => fn (File $file) => $file->panel()->url(true), + 'prev' => fn (File $file) => $file->prev(), + 'prevWithTemplate' => function (File $file) { + $files = $file->templateSiblings()->sorted(); + $index = $files->indexOf($file); + + return $files->nth($index - 1); + }, + 'parent' => fn (File $file) => $file->parent(), + 'parents' => fn (File $file) => $file->parents()->flip(), + 'size' => fn (File $file) => $file->size(), + 'template' => fn (File $file) => $file->template(), + 'thumbs' => function ($file) { + if ($file->isResizable() === false) { + return null; + } + + return [ + 'tiny' => $file->resize(128)->url(), + 'small' => $file->resize(256)->url(), + 'medium' => $file->resize(512)->url(), + 'large' => $file->resize(768)->url(), + 'huge' => $file->resize(1024)->url(), + ]; + }, + 'type' => fn (File $file) => $file->type(), + 'url' => fn (File $file) => $file->url(), + 'uuid' => fn (File $file) => $file->uuid()?->toString() + ], + 'type' => File::class, + 'views' => [ + 'default' => [ + 'content', + 'dimensions', + 'exists', + 'extension', + 'filename', + 'id', + 'link', + 'mime', + 'modified', + 'name', + 'next' => 'compact', + 'niceSize', + 'parent' => 'compact', + 'options', + 'prev' => 'compact', + 'size', + 'template', + 'type', + 'url', + 'uuid' + ], + 'compact' => [ + 'filename', + 'id', + 'link', + 'type', + 'url', + 'uuid' + ], + 'panel' => [ + 'blueprint', + 'content', + 'dimensions', + 'extension', + 'filename', + 'id', + 'link', + 'mime', + 'modified', + 'name', + 'nextWithTemplate' => 'compact', + 'niceSize', + 'options', + 'panelIcon', + 'panelImage', + 'parent' => 'compact', + 'parents' => ['id', 'slug', 'title'], + 'prevWithTemplate' => 'compact', + 'template', + 'type', + 'url', + 'uuid' + ] + ], +]; diff --git a/public/kirby/config/api/models/FileBlueprint.php b/public/kirby/config/api/models/FileBlueprint.php new file mode 100644 index 0000000..f255c14 --- /dev/null +++ b/public/kirby/config/api/models/FileBlueprint.php @@ -0,0 +1,17 @@ + [ + 'name' => fn (FileBlueprint $blueprint) => $blueprint->name(), + 'options' => fn (FileBlueprint $blueprint) => $blueprint->options(), + 'tabs' => fn (FileBlueprint $blueprint) => $blueprint->tabs(), + 'title' => fn (FileBlueprint $blueprint) => $blueprint->title(), + ], + 'type' => FileBlueprint::class, + 'views' => [], +]; diff --git a/public/kirby/config/api/models/FileVersion.php b/public/kirby/config/api/models/FileVersion.php new file mode 100644 index 0000000..063e10b --- /dev/null +++ b/public/kirby/config/api/models/FileVersion.php @@ -0,0 +1,59 @@ + [ + 'dimensions' => fn (FileVersion $file) => $file->dimensions()->toArray(), + 'exists' => fn (FileVersion $file) => $file->exists(), + 'extension' => fn (FileVersion $file) => $file->extension(), + 'filename' => fn (FileVersion $file) => $file->filename(), + 'id' => fn (FileVersion $file) => $file->id(), + 'mime' => fn (FileVersion $file) => $file->mime(), + 'modified' => fn (FileVersion $file) => $file->modified('c'), + 'name' => fn (FileVersion $file) => $file->name(), + 'niceSize' => fn (FileVersion $file) => $file->niceSize(), + 'size' => fn (FileVersion $file) => $file->size(), + 'type' => fn (FileVersion $file) => $file->type(), + 'url' => fn (FileVersion $file) => $file->url(), + ], + 'type' => FileVersion::class, + 'views' => [ + 'default' => [ + 'dimensions', + 'exists', + 'extension', + 'filename', + 'id', + 'mime', + 'modified', + 'name', + 'niceSize', + 'size', + 'type', + 'url' + ], + 'compact' => [ + 'filename', + 'id', + 'type', + 'url', + ], + 'panel' => [ + 'dimensions', + 'extension', + 'filename', + 'id', + 'mime', + 'modified', + 'name', + 'niceSize', + 'template', + 'type', + 'url' + ] + ], +]; diff --git a/public/kirby/config/api/models/Language.php b/public/kirby/config/api/models/Language.php new file mode 100644 index 0000000..fcebad1 --- /dev/null +++ b/public/kirby/config/api/models/Language.php @@ -0,0 +1,30 @@ + [ + 'code' => fn (Language $language) => $language->code(), + 'default' => fn (Language $language) => $language->isDefault(), + 'direction' => fn (Language $language) => $language->direction(), + 'locale' => fn (Language $language) => $language->locale(), + 'name' => fn (Language $language) => $language->name(), + 'rules' => fn (Language $language) => $language->rules(), + 'url' => fn (Language $language) => $language->url(), + ], + 'type' => Language::class, + 'views' => [ + 'default' => [ + 'code', + 'default', + 'direction', + 'locale', + 'name', + 'rules', + 'url' + ] + ] +]; diff --git a/public/kirby/config/api/models/License.php b/public/kirby/config/api/models/License.php new file mode 100644 index 0000000..3a56556 --- /dev/null +++ b/public/kirby/config/api/models/License.php @@ -0,0 +1,17 @@ + [ + 'status' => fn (License $license) => $license->status()->value(), + 'code' => function (License $license) { + return $this->kirby()->user()->isAdmin() ? $license->code() : $license->code(true); + }, + 'type' => fn (License $license) => $license->type()->label(), + ], + 'type' => License::class, +]; diff --git a/public/kirby/config/api/models/Page.php b/public/kirby/config/api/models/Page.php new file mode 100644 index 0000000..4ff6dbc --- /dev/null +++ b/public/kirby/config/api/models/Page.php @@ -0,0 +1,96 @@ + [ + 'blueprint' => fn (Page $page) => $page->blueprint(), + 'blueprints' => fn (Page $page) => $page->blueprints(), + 'children' => fn (Page $page) => $page->children(), + 'content' => fn (Page $page) => Form::for($page)->values(), + 'drafts' => fn (Page $page) => $page->drafts(), + 'errors' => fn (Page $page) => $page->errors(), + 'files' => fn (Page $page) => $page->files()->sorted(), + 'hasChildren' => fn (Page $page) => $page->hasChildren(), + 'hasDrafts' => fn (Page $page) => $page->hasDrafts(), + 'hasFiles' => fn (Page $page) => $page->hasFiles(), + 'id' => fn (Page $page) => $page->id(), + 'isSortable' => fn (Page $page) => $page->isSortable(), + 'num' => fn (Page $page) => $page->num(), + 'options' => fn (Page $page) => $page->panel()->options(['preview']), + 'panelImage' => fn (Page $page) => $page->panel()->image(), + 'parent' => fn (Page $page) => $page->parent(), + 'parents' => fn (Page $page) => $page->parents()->flip(), + 'previewUrl' => fn (Page $page) => $page->previewUrl(), + 'siblings' => function (Page $page) { + if ($page->isDraft() === true) { + return $page->parentModel()->children()->not($page); + } + + return $page->siblings(); + }, + 'slug' => fn (Page $page) => $page->slug(), + 'status' => fn (Page $page) => $page->status(), + 'template' => fn (Page $page) => $page->intendedTemplate()->name(), + 'title' => fn (Page $page) => $page->title()->value(), + 'url' => fn (Page $page) => $page->url(), + 'uuid' => fn (Page $page) => $page->uuid()?->toString() + ], + 'type' => Page::class, + 'views' => [ + 'compact' => [ + 'id', + 'title', + 'url', + 'num', + 'uuid' + ], + 'default' => [ + 'content', + 'id', + 'status', + 'num', + 'options', + 'parent' => 'compact', + 'slug', + 'template', + 'title', + 'url', + 'uuid' + ], + 'panel' => [ + 'id', + 'blueprint', + 'content', + 'status', + 'options', + 'next' => ['id', 'slug', 'title'], + 'parents' => ['id', 'slug', 'title'], + 'prev' => ['id', 'slug', 'title'], + 'previewUrl', + 'slug', + 'title', + 'url', + 'uuid' + ], + 'selector' => [ + 'id', + 'title', + 'parent' => [ + 'id', + 'title' + ], + 'children' => [ + 'hasChildren', + 'id', + 'panelIcon', + 'panelImage', + 'title', + ], + ] + ], +]; diff --git a/public/kirby/config/api/models/PageBlueprint.php b/public/kirby/config/api/models/PageBlueprint.php new file mode 100644 index 0000000..e993b91 --- /dev/null +++ b/public/kirby/config/api/models/PageBlueprint.php @@ -0,0 +1,20 @@ + [ + 'name' => fn (PageBlueprint $blueprint) => $blueprint->name(), + 'num' => fn (PageBlueprint $blueprint) => $blueprint->num(), + 'options' => fn (PageBlueprint $blueprint) => $blueprint->options(), + 'preview' => fn (PageBlueprint $blueprint) => $blueprint->preview(), + 'status' => fn (PageBlueprint $blueprint) => $blueprint->status(), + 'tabs' => fn (PageBlueprint $blueprint) => $blueprint->tabs(), + 'title' => fn (PageBlueprint $blueprint) => $blueprint->title(), + ], + 'type' => PageBlueprint::class, + 'views' => [], +]; diff --git a/public/kirby/config/api/models/Role.php b/public/kirby/config/api/models/Role.php new file mode 100644 index 0000000..3c2b468 --- /dev/null +++ b/public/kirby/config/api/models/Role.php @@ -0,0 +1,23 @@ + [ + 'description' => fn (Role $role) => $role->description(), + 'name' => fn (Role $role) => $role->name(), + 'permissions' => fn (Role $role) => $role->permissions()->toArray(), + 'title' => fn (Role $role) => $role->title(), + ], + 'type' => Role::class, + 'views' => [ + 'compact' => [ + 'description', + 'name', + 'title' + ] + ] +]; diff --git a/public/kirby/config/api/models/Site.php b/public/kirby/config/api/models/Site.php new file mode 100644 index 0000000..81cab62 --- /dev/null +++ b/public/kirby/config/api/models/Site.php @@ -0,0 +1,52 @@ + fn () => $this->site(), + 'fields' => [ + 'blueprint' => fn (Site $site) => $site->blueprint(), + 'children' => fn (Site $site) => $site->children(), + 'content' => fn (Site $site) => Form::for($site)->values(), + 'drafts' => fn (Site $site) => $site->drafts(), + 'files' => fn (Site $site) => $site->files()->sorted(), + 'options' => fn (Site $site) => $site->permissions()->toArray(), + 'previewUrl' => fn (Site $site) => $site->previewUrl(), + 'title' => fn (Site $site) => $site->title()->value(), + 'url' => fn (Site $site) => $site->url(), + ], + 'type' => Site::class, + 'views' => [ + 'compact' => [ + 'title', + 'url' + ], + 'default' => [ + 'content', + 'options', + 'title', + 'url' + ], + 'panel' => [ + 'title', + 'blueprint', + 'content', + 'options', + 'previewUrl', + 'url' + ], + 'selector' => [ + 'title', + 'children' => [ + 'id', + 'title', + 'panelIcon', + 'hasChildren' + ], + ] + ] +]; diff --git a/public/kirby/config/api/models/SiteBlueprint.php b/public/kirby/config/api/models/SiteBlueprint.php new file mode 100644 index 0000000..69aad5b --- /dev/null +++ b/public/kirby/config/api/models/SiteBlueprint.php @@ -0,0 +1,17 @@ + [ + 'name' => fn (SiteBlueprint $blueprint) => $blueprint->name(), + 'options' => fn (SiteBlueprint $blueprint) => $blueprint->options(), + 'tabs' => fn (SiteBlueprint $blueprint) => $blueprint->tabs(), + 'title' => fn (SiteBlueprint $blueprint) => $blueprint->title(), + ], + 'type' => SiteBlueprint::class, + 'views' => [], +]; diff --git a/public/kirby/config/api/models/System.php b/public/kirby/config/api/models/System.php new file mode 100644 index 0000000..4627226 --- /dev/null +++ b/public/kirby/config/api/models/System.php @@ -0,0 +1,91 @@ + [ + 'ascii' => fn () => Str::$ascii, + 'authStatus' => fn () => $this->kirby()->auth()->status()->toArray(), + 'defaultLanguage' => fn () => $this->kirby()->panelLanguage(), + 'isOk' => fn (System $system) => $system->isOk(), + 'isInstallable' => fn (System $system) => $system->isInstallable(), + 'isInstalled' => fn (System $system) => $system->isInstalled(), + 'isLocal' => fn (System $system) => $system->isLocal(), + 'multilang' => fn () => $this->kirby()->option('languages', false) !== false, + 'languages' => fn () => $this->kirby()->languages(), + 'license' => fn (System $system) => $system->license(), + 'locales' => function () { + $locales = []; + $translations = $this->kirby()->translations(); + foreach ($translations as $translation) { + $locales[$translation->code()] = $translation->locale(); + } + return $locales; + }, + 'loginMethods' => fn (System $system) => array_keys($system->loginMethods()), + 'requirements' => fn (System $system) => $system->toArray(), + 'site' => fn (System $system) => $system->title(), + 'slugs' => fn () => Str::$language, + 'title' => fn () => $this->site()->title()->value(), + 'translation' => function () { + $code = $this->user()?->language() ?? + $this->kirby()->panelLanguage(); + + return + $this->kirby()->translation($code) ?? + $this->kirby()->translation('en'); + }, + 'kirbytext' => fn () => $this->kirby()->option('panel.kirbytext') ?? true, + 'user' => fn () => $this->user(), + 'version' => function () { + if ($this->user()?->role()->permissions()->for('access', 'system') === true) { + return $this->kirby()->version(); + } + + return null; + } + ], + 'type' => System::class, + 'views' => [ + 'login' => [ + 'authStatus', + 'isOk', + 'isInstallable', + 'isInstalled', + 'loginMethods', + 'title', + 'translation' + ], + 'troubleshooting' => [ + 'isOk', + 'isInstallable', + 'isInstalled', + 'title', + 'translation', + 'requirements' + ], + 'panel' => [ + 'ascii', + 'defaultLanguage', + 'isOk', + 'isInstalled', + 'isLocal', + 'kirbytext', + 'languages', + 'license', + 'locales', + 'multilang', + 'requirements', + 'site', + 'slugs', + 'title', + 'translation', + 'user' => 'auth', + 'version' + ] + ], +]; diff --git a/public/kirby/config/api/models/Translation.php b/public/kirby/config/api/models/Translation.php new file mode 100644 index 0000000..94f9c02 --- /dev/null +++ b/public/kirby/config/api/models/Translation.php @@ -0,0 +1,24 @@ + [ + 'author' => fn (Translation $translation) => $translation->author(), + 'data' => fn (Translation $translation) => $translation->dataWithFallback(), + 'direction' => fn (Translation $translation) => $translation->direction(), + 'id' => fn (Translation $translation) => $translation->id(), + 'name' => fn (Translation $translation) => $translation->name(), + ], + 'type' => Translation::class, + 'views' => [ + 'compact' => [ + 'direction', + 'id', + 'name' + ] + ] +]; diff --git a/public/kirby/config/api/models/User.php b/public/kirby/config/api/models/User.php new file mode 100644 index 0000000..0da32e9 --- /dev/null +++ b/public/kirby/config/api/models/User.php @@ -0,0 +1,81 @@ + fn () => $this->user(), + 'fields' => [ + 'avatar' => fn (User $user) => $user->avatar()?->crop(512), + 'blueprint' => fn (User $user) => $user->blueprint(), + 'content' => fn (User $user) => Form::for($user)->values(), + 'email' => fn (User $user) => $user->email(), + 'files' => fn (User $user) => $user->files()->sorted(), + 'id' => fn (User $user) => $user->id(), + 'language' => fn (User $user) => $user->language(), + 'name' => fn (User $user) => $user->name()->value(), + 'next' => fn (User $user) => $user->next(), + 'options' => fn (User $user) => $user->panel()->options(), + 'panelImage' => fn (User $user) => $user->panel()->image(), + 'permissions' => fn (User $user) => $user->role()->permissions()->toArray(), + 'prev' => fn (User $user) => $user->prev(), + 'role' => fn (User $user) => $user->role(), + 'roles' => fn (User $user) => $user->roles(), + 'username' => fn (User $user) => $user->username(), + 'uuid' => fn (User $user) => $user->uuid()?->toString() + ], + 'type' => User::class, + 'views' => [ + 'default' => [ + 'avatar', + 'content', + 'email', + 'id', + 'language', + 'name', + 'next' => 'compact', + 'options', + 'prev' => 'compact', + 'role', + 'username', + 'uuid' + ], + 'compact' => [ + 'avatar' => 'compact', + 'id', + 'email', + 'language', + 'name', + 'role' => 'compact', + 'username', + 'uuid' + ], + 'auth' => [ + 'avatar' => 'compact', + 'permissions', + 'email', + 'id', + 'name', + 'role', + 'language' + ], + 'panel' => [ + 'avatar' => 'compact', + 'blueprint', + 'content', + 'email', + 'id', + 'language', + 'name', + 'next' => ['id', 'name'], + 'options', + 'prev' => ['id', 'name'], + 'role', + 'username', + 'uuid' + ], + ] +]; diff --git a/public/kirby/config/api/models/UserBlueprint.php b/public/kirby/config/api/models/UserBlueprint.php new file mode 100644 index 0000000..2fa83e9 --- /dev/null +++ b/public/kirby/config/api/models/UserBlueprint.php @@ -0,0 +1,17 @@ + [ + 'name' => fn (UserBlueprint $blueprint) => $blueprint->name(), + 'options' => fn (UserBlueprint $blueprint) => $blueprint->options(), + 'tabs' => fn (UserBlueprint $blueprint) => $blueprint->tabs(), + 'title' => fn (UserBlueprint $blueprint) => $blueprint->title(), + ], + 'type' => UserBlueprint::class, + 'views' => [], +]; diff --git a/public/kirby/config/api/routes.php b/public/kirby/config/api/routes.php new file mode 100644 index 0000000..62ce665 --- /dev/null +++ b/public/kirby/config/api/routes.php @@ -0,0 +1,29 @@ +option('languages', false) !== false) { + $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 new file mode 100644 index 0000000..ef79996 --- /dev/null +++ b/public/kirby/config/api/routes/auth.php @@ -0,0 +1,126 @@ + 'auth', + 'method' => 'GET', + 'action' => function () { + if ($user = $this->kirby()->auth()->user()) { + return $this->resolve($user)->view('auth'); + } + + throw new NotFoundException( + message: 'The user cannot be found' + ); + } + ], + [ + 'pattern' => 'auth/code', + 'method' => 'POST', + 'auth' => false, + 'action' => function () { + $auth = $this->kirby()->auth(); + + // csrf token check + if ($auth->type() === 'session' && $auth->csrf() === false) { + throw new InvalidArgumentException( + message: 'Invalid CSRF token' + ); + } + + $user = $auth->verifyChallenge($this->requestBody('code')); + + return [ + 'code' => 200, + 'status' => 'ok', + 'user' => $this->resolve($user)->view('auth')->toArray() + ]; + } + ], + [ + 'pattern' => 'auth/login', + 'method' => 'POST', + 'auth' => false, + 'action' => function () { + $auth = $this->kirby()->auth(); + $methods = $this->kirby()->system()->loginMethods(); + + // csrf token check + if ($auth->type() === 'session' && $auth->csrf() === false) { + throw new InvalidArgumentException( + message: 'Invalid CSRF token' + ); + } + + $email = $this->requestBody('email'); + $long = $this->requestBody('long'); + $password = $this->requestBody('password'); + + if ($password) { + if (isset($methods['password']) !== true) { + throw new InvalidArgumentException( + message: 'Login with password is not enabled' + ); + } + + if ( + isset($methods['password']['2fa']) === true && + $methods['password']['2fa'] === true + ) { + $status = $auth->login2fa($email, $password, $long); + } else { + $user = $auth->login($email, $password, $long); + } + } else { + $mode = match (true) { + isset($methods['code']) => 'login', + isset($methods['password-reset']) => 'password-reset', + default => throw new InvalidArgumentException( + message: 'Login without password is not enabled' + ) + }; + + $status = $auth->createChallenge($email, $long, $mode); + } + + if (isset($user)) { + return [ + 'code' => 200, + 'status' => 'ok', + 'user' => $this->resolve($user)->view('auth')->toArray() + ]; + } + + return [ + 'code' => 200, + 'status' => 'ok', + 'challenge' => $status->challenge() + ]; + } + ], + [ + 'pattern' => 'auth/logout', + 'method' => 'POST', + 'auth' => false, + 'action' => function () { + $this->kirby()->auth()->logout(); + return true; + } + ], + [ + 'pattern' => 'auth/ping', + 'method' => 'POST', + 'auth' => false, + 'action' => function () { + // refresh the session timeout + $this->kirby()->session(); + return true; + } + ], +]; 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 new file mode 100644 index 0000000..3b61a2c --- /dev/null +++ b/public/kirby/config/api/routes/files.php @@ -0,0 +1,146 @@ + $filePattern . '/fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function (string $parent, string $filename, string $fieldName, string|null $path = null) { + if ($file = $this->file($parent, $filename)) { + return $this->fieldApi($file, $fieldName, $path); + } + } + ], + [ + 'pattern' => $filePattern . '/sections/(:any)', + 'method' => 'GET', + 'action' => function (string $path, string $filename, string $sectionName) { + return $this->file($path, $filename)->blueprint()->section($sectionName)?->toResponse(); + } + ], + [ + 'pattern' => $filePattern . '/sections/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function (string $parent, string $filename, string $sectionName, string|null $path = null) { + if ($file = $this->file($parent, $filename)) { + return $this->sectionApi($file, $sectionName, $path); + } + } + ], + [ + 'pattern' => $parentPattern, + 'method' => 'GET', + 'action' => function (string $path) { + return $this->files($path)->sorted(); + } + ], + [ + 'pattern' => $parentPattern, + 'method' => 'POST', + 'action' => function (string $path) { + // move_uploaded_file() not working with unit test + // @codeCoverageIgnoreStart + return $this->upload(function ($source, $filename) use ($path) { + // move the source file to the content folder + return $this->parent($path)->createFile([ + 'content' => [ + 'sort' => $this->requestBody('sort') + ], + 'source' => $source, + 'template' => $this->requestBody('template'), + 'filename' => $filename + ], true); + }); + // @codeCoverageIgnoreEnd + } + ], + [ + 'pattern' => $parentPattern . '/search', + 'method' => 'GET|POST', + 'action' => function (string $path) { + $files = $this->files($path); + + if ($this->requestMethod() === 'GET') { + return $files->search($this->requestQuery('q')); + } + + return $files->query($this->requestBody()); + } + ], + [ + 'pattern' => $parentPattern . '/sort', + 'method' => 'PATCH', + 'action' => function (string $path) { + return $this->files($path)->changeSort( + $this->requestBody('files'), + $this->requestBody('index') + ); + } + ], + [ + 'pattern' => $filePattern, + 'method' => 'GET', + 'action' => function (string $path, string $filename) { + return $this->file($path, $filename); + } + ], + [ + 'pattern' => $filePattern, + 'method' => 'PATCH', + 'action' => function (string $path, string $filename) { + return $this->file($path, $filename)->update( + $this->requestBody(), + $this->language(), + true + ); + } + ], + [ + 'pattern' => $filePattern, + 'method' => 'POST', + 'action' => function (string $path, string $filename) { + // move the source file from the temp dir + return $this->upload( + fn ($source) => $this->file($path, $filename)->replace($source, true) + ); + } + ], + [ + 'pattern' => $filePattern, + 'method' => 'DELETE', + 'action' => function (string $path, string $filename) { + return $this->file($path, $filename)->delete(); + } + ], + [ + 'pattern' => $filePattern . '/name', + 'method' => 'PATCH', + 'action' => function (string $path, string $filename) { + return $this->file($path, $filename)->changeName($this->requestBody('name')); + } + ], + [ + 'pattern' => $parentPattern . '/search', + 'method' => 'GET|POST', + 'action' => function () { + $files = $this + ->site() + ->index(true) + ->filter('isListable', true) + ->files() + ->filter('isListable', true); + + if ($this->requestMethod() === 'GET') { + return $files->search($this->requestQuery('q')); + } + + return $files->query($this->requestBody()); + } + ], +]; diff --git a/public/kirby/config/api/routes/kql.php b/public/kirby/config/api/routes/kql.php new file mode 100644 index 0000000..68710ec --- /dev/null +++ b/public/kirby/config/api/routes/kql.php @@ -0,0 +1,35 @@ + function ($kirby) { + return [ + [ + 'pattern' => 'query', + 'method' => 'POST|GET', + 'auth' => $kirby->option('kql.auth') !== false, + 'action' => function () use ($kirby) { + $kql = '\Kirby\Kql\Kql'; + + if (class_exists($kql) === false) { + return [ + 'code' => 500, + 'status' => 'error', + 'message' => 'KQL plugin is not installed', + ]; + } + + $input = $kirby->request()->get(); + $result = $kql::run($input); + + return [ + 'code' => 200, + 'result' => $result, + 'status' => 'ok', + ]; + } + ] + ]; + } +]; +// @codeCoverageIgnoreEnd diff --git a/public/kirby/config/api/routes/languages.php b/public/kirby/config/api/routes/languages.php new file mode 100644 index 0000000..e2d4f6b --- /dev/null +++ b/public/kirby/config/api/routes/languages.php @@ -0,0 +1,42 @@ + 'languages', + 'method' => 'GET', + 'action' => function () { + return $this->kirby()->languages(); + } + ], + [ + 'pattern' => 'languages', + 'method' => 'POST', + 'action' => function () { + return $this->kirby()->languages()->create($this->requestBody()); + } + ], + [ + 'pattern' => 'languages/(:any)', + 'method' => 'GET', + 'action' => function (string $code) { + return $this->kirby()->languages()->find($code); + } + ], + [ + 'pattern' => 'languages/(:any)', + 'method' => 'PATCH', + 'action' => function (string $code) { + return $this->kirby()->languages()->find($code)?->update($this->requestBody()); + } + ], + [ + 'pattern' => 'languages/(:any)', + 'method' => 'DELETE', + 'action' => function (string $code) { + return $this->kirby()->languages()->find($code)?->delete(); + } + ] +]; diff --git a/public/kirby/config/api/routes/pages.php b/public/kirby/config/api/routes/pages.php new file mode 100644 index 0000000..6de9c1a --- /dev/null +++ b/public/kirby/config/api/routes/pages.php @@ -0,0 +1,129 @@ + 'pages/(:any)', + 'method' => 'GET', + 'action' => function (string $id) { + return $this->page($id); + } + ], + [ + 'pattern' => 'pages/(:any)', + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->page($id)->update($this->requestBody(), $this->language(), true); + } + ], + [ + 'pattern' => 'pages/(:any)', + 'method' => 'DELETE', + 'action' => function (string $id) { + return $this->page($id)->delete($this->requestBody('force', false)); + } + ], + [ + 'pattern' => 'pages/(:any)/blueprint', + 'method' => 'GET', + 'action' => function (string $id) { + return $this->page($id)->blueprint(); + } + ], + [ + 'pattern' => 'pages/(:any)/blueprints', + 'method' => 'GET', + 'action' => function (string $id) { + return $this->page($id)->blueprints($this->requestQuery('section')); + } + ], + [ + 'pattern' => 'pages/(:any)/children', + 'method' => 'GET', + 'action' => function (string $id) { + return $this->pages($id, $this->requestQuery('status')); + } + ], + [ + 'pattern' => 'pages/(:any)/children', + 'method' => 'POST', + 'action' => function (string $id) { + return $this->page($id)->createChild($this->requestBody()); + } + ], + [ + 'pattern' => 'pages/(:any)/children/search', + 'method' => 'GET|POST', + 'action' => function (string $id) { + return $this->searchPages($id); + } + ], + [ + 'pattern' => 'pages/(:any)/duplicate', + 'method' => 'POST', + 'action' => function (string $id) { + return $this->page($id)->duplicate($this->requestBody('slug'), [ + 'children' => $this->requestBody('children'), + 'files' => $this->requestBody('files'), + ]); + } + ], + [ + 'pattern' => 'pages/(:any)/slug', + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->page($id)->changeSlug($this->requestBody('slug')); + } + ], + [ + 'pattern' => 'pages/(:any)/status', + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->page($id)->changeStatus($this->requestBody('status'), $this->requestBody('position')); + } + ], + [ + 'pattern' => 'pages/(:any)/template', + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->page($id)->changeTemplate($this->requestBody('template')); + } + ], + [ + 'pattern' => 'pages/(:any)/title', + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->page($id)->changeTitle($this->requestBody('title')); + } + ], + [ + 'pattern' => 'pages/(:any)/fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function (string $id, string $fieldName, string|null $path = null) { + if ($page = $this->page($id)) { + return $this->fieldApi($page, $fieldName, $path); + } + } + ], + [ + 'pattern' => 'pages/(:any)/sections/(:any)', + 'method' => 'GET', + 'action' => function (string $id, string $sectionName) { + return $this->page($id)->blueprint()->section($sectionName)?->toResponse(); + } + ], + [ + 'pattern' => 'pages/(:any)/sections/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function (string $id, string $sectionName, string|null $path = null) { + if ($page = $this->page($id)) { + return $this->sectionApi($page, $sectionName, $path); + } + } + ], + // @codeCoverageIgnoreEnd +]; diff --git a/public/kirby/config/api/routes/roles.php b/public/kirby/config/api/routes/roles.php new file mode 100644 index 0000000..56ecfea --- /dev/null +++ b/public/kirby/config/api/routes/roles.php @@ -0,0 +1,27 @@ + 'roles', + 'method' => 'GET', + 'action' => function () { + $kirby = $this->kirby(); + + return match ($kirby->request()->get('canBe')) { + 'changed' => $kirby->roles()->canBeChanged(), + 'created' => $kirby->roles()->canBeCreated(), + default => $kirby->roles() + }; + } + ], + [ + 'pattern' => 'roles/(:any)', + 'method' => 'GET', + 'action' => function (string $name) { + return $this->kirby()->roles()->find($name); + } + ] +]; diff --git a/public/kirby/config/api/routes/site.php b/public/kirby/config/api/routes/site.php new file mode 100644 index 0000000..abc2ac5 --- /dev/null +++ b/public/kirby/config/api/routes/site.php @@ -0,0 +1,109 @@ + 'site', + 'action' => function () { + return $this->site(); + } + ], + [ + 'pattern' => 'site', + 'method' => 'PATCH', + 'action' => function () { + return $this->site()->update($this->requestBody(), $this->language(), true); + } + ], + [ + 'pattern' => 'site/children', + 'method' => 'GET', + 'action' => function () { + return $this->pages(null, $this->requestQuery('status')); + } + ], + [ + 'pattern' => 'site/children', + 'method' => 'POST', + 'action' => function () { + return $this->site()->createChild($this->requestBody()); + } + ], + [ + 'pattern' => 'site/children/search', + 'method' => 'GET|POST', + 'action' => function () { + return $this->searchPages(); + } + ], + [ + 'pattern' => 'site/blueprint', + 'method' => 'GET', + 'action' => function () { + return $this->site()->blueprint(); + } + ], + [ + 'pattern' => 'site/blueprints', + 'method' => 'GET', + 'action' => function () { + return $this->site()->blueprints($this->requestQuery('section')); + } + ], + [ + 'pattern' => 'site/find', + 'method' => 'POST', + 'action' => function () { + return $this->site()->find(false, ...$this->requestBody()); + } + ], + [ + 'pattern' => 'site/title', + 'method' => 'PATCH', + 'action' => function () { + return $this->site()->changeTitle($this->requestBody('title')); + } + ], + [ + 'pattern' => 'site/search', + 'method' => 'GET|POST', + 'action' => function () { + $pages = $this + ->site() + ->index(true) + ->filter('isListable', true); + + if ($this->requestMethod() === 'GET') { + return $pages->search($this->requestQuery('q')); + } + + return $pages->query($this->requestBody()); + } + ], + [ + 'pattern' => 'site/fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function (string $fieldName, string|null $path = null) { + return $this->fieldApi($this->site(), $fieldName, $path); + } + ], + [ + 'pattern' => 'site/sections/(:any)', + 'method' => 'GET', + 'action' => function (string $sectionName) { + return $this->site()->blueprint()->section($sectionName)?->toResponse(); + } + ], + [ + 'pattern' => 'site/sections/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function (string $sectionName, string|null $path = null) { + return $this->sectionApi($this->site(), $sectionName, $path); + } + ], + // @codeCoverageIgnoreEnd +]; diff --git a/public/kirby/config/api/routes/system.php b/public/kirby/config/api/routes/system.php new file mode 100644 index 0000000..c810750 --- /dev/null +++ b/public/kirby/config/api/routes/system.php @@ -0,0 +1,86 @@ + 'system', + 'method' => 'GET', + 'auth' => false, + 'action' => function () { + $system = $this->kirby()->system(); + + if ($this->kirby()->user()) { + return $system; + } + + $info = match ($system->isOk()) { + true => $this->resolve($system)->view('login')->toArray(), + false => $this->resolve($system)->view('troubleshooting')->toArray() + }; + + return [ + 'status' => 'ok', + 'data' => $info, + 'type' => 'model' + ]; + } + ], + [ + 'pattern' => 'system/register', + 'method' => 'POST', + 'action' => function () { + return $this->kirby()->system()->register($this->requestBody('license'), $this->requestBody('email')); + } + ], + [ + 'pattern' => 'system/install', + 'method' => 'POST', + 'auth' => false, + 'action' => function () { + $system = $this->kirby()->system(); + $auth = $this->kirby()->auth(); + + // csrf token check + if ($auth->type() === 'session' && $auth->csrf() === false) { + throw new InvalidArgumentException( + message: 'Invalid CSRF token' + ); + } + + if ($system->isOk() === false) { + throw new Exception( + message: 'The server is not setup correctly' + ); + } + + if ($system->isInstallable() === false) { + throw new Exception( + message: 'The Panel cannot be installed' + ); + } + + if ($system->isInstalled() === true) { + throw new Exception( + message: 'The Panel is already installed' + ); + } + + // create the first user + $user = $this->users()->create($this->requestBody()); + $token = $user->login($this->requestBody('password')); + + return [ + 'status' => 'ok', + 'token' => $token, + 'user' => $this->resolve($user)->view('auth')->toArray() + ]; + } + ] + +]; diff --git a/public/kirby/config/api/routes/translations.php b/public/kirby/config/api/routes/translations.php new file mode 100644 index 0000000..2d949c5 --- /dev/null +++ b/public/kirby/config/api/routes/translations.php @@ -0,0 +1,24 @@ + 'translations', + 'method' => 'GET', + 'auth' => false, + 'action' => function () { + return $this->kirby()->translations(); + } + ], + [ + 'pattern' => 'translations/(:any)', + 'method' => 'GET', + 'auth' => false, + 'action' => function (string $code) { + return $this->kirby()->translations()->find($code); + } + ] + +]; diff --git a/public/kirby/config/api/routes/users.php b/public/kirby/config/api/routes/users.php new file mode 100644 index 0000000..e55a009 --- /dev/null +++ b/public/kirby/config/api/routes/users.php @@ -0,0 +1,260 @@ + 'users', + 'method' => 'GET', + 'action' => function () { + return $this->users()->sort('username', 'asc', 'email', 'asc'); + } + ], + [ + 'pattern' => 'users', + 'method' => 'POST', + 'action' => function () { + return $this->users()->create($this->requestBody()); + } + ], + [ + 'pattern' => 'users/search', + 'method' => 'GET|POST', + 'action' => function () { + if ($this->requestMethod() === 'GET') { + return $this->users()->search($this->requestQuery('q')); + } + + return $this->users()->query($this->requestBody()); + } + ], + [ + 'pattern' => [ + '(account)', + 'users/(:any)', + ], + 'method' => 'GET', + 'action' => function (string $id) { + return $this->user($id); + } + ], + [ + 'pattern' => [ + '(account)', + 'users/(:any)', + ], + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->user($id)->update($this->requestBody(), $this->language(), true); + } + ], + [ + 'pattern' => [ + '(account)', + 'users/(:any)', + ], + 'method' => 'DELETE', + 'action' => function (string $id) { + return $this->user($id)->delete(); + } + ], + [ + 'pattern' => [ + '(account)/avatar', + 'users/(:any)/avatar', + ], + 'method' => 'GET', + 'action' => function (string $id) { + return $this->user($id)->avatar(); + } + ], + // @codeCoverageIgnoreStart + [ + 'pattern' => [ + '(account)/avatar', + 'users/(:any)/avatar', + ], + 'method' => 'POST', + 'action' => function (string $id) { + return $this->upload( + function ($source, $filename) use ($id) { + $type = F::type($filename); + if ($type !== 'image') { + 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') + ); + } + + // delete the old avatar + $this->user($id)->avatar()?->delete(); + + $props = [ + 'filename' => 'profile.' . F::extension($filename), + 'template' => 'avatar', + 'source' => $source + ]; + + // move the source file from the temp dir + return $this->user($id)->createFile($props, true); + }, + single: true + ); + } + ], + // @codeCoverageIgnoreEnd + [ + 'pattern' => [ + '(account)/avatar', + 'users/(:any)/avatar', + ], + 'method' => 'DELETE', + 'action' => function (string $id) { + return $this->user($id)->avatar()->delete(); + } + ], + [ + 'pattern' => [ + '(account)/blueprint', + 'users/(:any)/blueprint', + ], + 'method' => 'GET', + 'action' => function (string $id) { + return $this->user($id)->blueprint(); + } + ], + [ + 'pattern' => [ + '(account)/blueprints', + 'users/(:any)/blueprints', + ], + 'method' => 'GET', + 'action' => function (string $id) { + return $this->user($id)->blueprints($this->requestQuery('section')); + } + ], + [ + 'pattern' => [ + '(account)/email', + 'users/(:any)/email', + ], + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->user($id)->changeEmail($this->requestBody('email')); + } + ], + [ + 'pattern' => [ + '(account)/language', + 'users/(:any)/language', + ], + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->user($id)->changeLanguage($this->requestBody('language')); + } + ], + [ + 'pattern' => [ + '(account)/name', + 'users/(:any)/name', + ], + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->user($id)->changeName($this->requestBody('name')); + } + ], + [ + 'pattern' => [ + '(account)/password', + 'users/(:any)/password', + ], + 'method' => 'PATCH', + 'action' => function (string $id) { + $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; + } + ], + [ + 'pattern' => [ + '(account)/role', + 'users/(:any)/role', + ], + 'method' => 'PATCH', + 'action' => function (string $id) { + return $this->user($id)->changeRole($this->requestBody('role')); + } + ], + [ + 'pattern' => [ + '(account)/roles', + 'users/(:any)/roles', + ], + 'action' => function (string $id) { + $kirby = $this->kirby(); + $purpose = $kirby->request()->get('purpose'); + return $this->user($id)->roles($purpose); + } + ], + [ + 'pattern' => [ + '(account)/fields/(:any)/(:all?)', + 'users/(:any)/fields/(:any)/(:all?)', + ], + 'method' => 'ALL', + 'action' => function (string $id, string $fieldName, string|null $path = null) { + return $this->fieldApi($this->user($id), $fieldName, $path); + } + ], + [ + 'pattern' => [ + '(account)/sections/(:any)', + 'users/(:any)/sections/(:any)', + ], + 'method' => 'GET', + 'action' => function (string $id, string $sectionName) { + if ($section = $this->user($id)->blueprint()->section($sectionName)) { + return $section->toResponse(); + } + } + ], + [ + 'pattern' => [ + '(account)/sections/(:any)/(:all?)', + 'users/(:any)/sections/(:any)/(:all?)', + ], + 'method' => 'ALL', + 'action' => function (string $id, string $sectionName, string|null $path = null) { + return $this->sectionApi($this->user($id), $sectionName, $path); + } + ], + // @codeCoverageIgnoreEnd +]; diff --git a/public/kirby/config/areas/account.php b/public/kirby/config/areas/account.php new file mode 100644 index 0000000..9f9fe73 --- /dev/null +++ b/public/kirby/config/areas/account.php @@ -0,0 +1,16 @@ + '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', + 'views' => require __DIR__ . '/account/views.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 new file mode 100644 index 0000000..fbdf0e8 --- /dev/null +++ b/public/kirby/config/areas/account/dialogs.php @@ -0,0 +1,65 @@ + [ + ...$dialogs['user.changeEmail'], + 'pattern' => '(account)/changeEmail', + ], + 'account.changeLanguage' => [ + ...$dialogs['user.changeLanguage'], + 'pattern' => '(account)/changeLanguage', + ], + 'account.changeName' => [ + ...$dialogs['user.changeName'], + 'pattern' => '(account)/changeName', + ], + 'account.changePassword' => [ + ...$dialogs['user.changePassword'], + 'pattern' => '(account)/changePassword', + ], + 'account.changeRole' => [ + ...$dialogs['user.changeRole'], + 'pattern' => '(account)/changeRole', + ], + 'account.delete' => [ + ...$dialogs['user.delete'], + 'pattern' => '(account)/delete', + ], + 'account.fields' => [ + ...$dialogs['user.fields'], + 'pattern' => '(account)/fields/(:any)/(:all?)', + ], + 'account.file.changeName' => [ + ...$dialogs['user.file.changeName'], + 'pattern' => '(account)/files/(:any)/changeName', + ], + 'account.file.changeSort' => [ + ...$dialogs['user.file.changeSort'], + 'pattern' => '(account)/files/(:any)/changeSort', + ], + 'account.file.changeTemplate' => [ + ...$dialogs['user.file.changeTemplate'], + 'pattern' => '(account)/files/(:any)/changeTemplate', + ], + 'account.file.delete' => [ + ...$dialogs['user.file.delete'], + 'pattern' => '(account)/files/(:any)/delete', + ], + 'account.file.fields' => [ + ...$dialogs['user.file.fields'], + 'pattern' => '(account)/files/(:any)/fields/(:any)/(:all?)', + ], + 'account.totp.enable' => [ + 'pattern' => '(account)/totp/enable', + 'load' => fn () => (new UserTotpEnableDialog())->load(), + 'submit' => fn () => (new UserTotpEnableDialog())->submit() + ], + 'account.totp.disable' => [ + 'pattern' => '(account)/totp/disable', + ...$dialogs['user.totp.disable'], + ], +]; diff --git a/public/kirby/config/areas/account/drawers.php b/public/kirby/config/areas/account/drawers.php new file mode 100644 index 0000000..714d6c5 --- /dev/null +++ b/public/kirby/config/areas/account/drawers.php @@ -0,0 +1,14 @@ + [ + ...$drawers['user.fields'], + 'pattern' => '(account)/fields/(:any)/(:all?)', + ], + 'account.file.fields' => [ + ...$drawers['user.file.fields'], + 'pattern' => '(account)/files/(:any)/fields/(:any)/(:all?)', + ], +]; diff --git a/public/kirby/config/areas/account/dropdowns.php b/public/kirby/config/areas/account/dropdowns.php new file mode 100644 index 0000000..6011c30 --- /dev/null +++ b/public/kirby/config/areas/account/dropdowns.php @@ -0,0 +1,22 @@ + [ + ...$dropdowns['user'], + 'pattern' => '(account)', + ], + 'account.languages' => [ + ...$dropdowns['user.languages'], + 'pattern' => '(account)/languages', + ], + 'account.file' => [ + ...$dropdowns['user.file'], + 'pattern' => '(account)/files/(:any)', + ], + 'account.file.languages' => [ + ...$dropdowns['user.file.languages'], + 'pattern' => '(account)/files/(:any)/languages', + ] +]; diff --git a/public/kirby/config/areas/account/views.php b/public/kirby/config/areas/account/views.php new file mode 100644 index 0000000..33625a5 --- /dev/null +++ b/public/kirby/config/areas/account/views.php @@ -0,0 +1,35 @@ + [ + 'pattern' => 'account', + 'action' => fn () => [ + 'component' => 'k-account-view', + 'props' => App::instance()->user()->panel()->props(), + ], + ], + 'account.file' => [ + 'pattern' => 'account/files/(:any)', + 'action' => function (string $filename) { + return Find::file('account', $filename)->panel()->view(); + } + ], + 'account.password' => [ + 'pattern' => 'reset-password', + 'action' => fn () => [ + 'component' => 'k-reset-password-view', + 'breadcrumb' => [ + [ + 'label' => I18n::translate('view.resetPassword') + ] + ], + 'props' => [ + 'requirePassword' => App::instance()->session()->get('kirby.resetPassword') !== true + ] + ] + ] +]; diff --git a/public/kirby/config/areas/fields/dialogs.php b/public/kirby/config/areas/fields/dialogs.php new file mode 100644 index 0000000..ca14e08 --- /dev/null +++ b/public/kirby/config/areas/fields/dialogs.php @@ -0,0 +1,61 @@ + [ + 'load' => function ( + string $modelPath, + string $fieldName, + string|null $path = null + ) { + return Field::dialog( + model: Find::parent($modelPath), + fieldName: $fieldName, + path: $path, + method: 'GET' + ); + }, + 'submit' => function ( + string $modelPath, + string $fieldName, + string|null $path = null + ) { + return Field::dialog( + model: Find::parent($modelPath), + fieldName: $fieldName, + path: $path, + method: 'POST' + ); + } + ], + 'file' => [ + 'load' => function ( + string $modelPath, + string $filename, + string $fieldName, + string|null $path = null + ) { + return Field::dialog( + model: Find::file($modelPath, $filename), + fieldName: $fieldName, + path: $path, + method: 'GET' + ); + }, + 'submit' => function ( + string $modelPath, + string $filename, + string $fieldName, + string|null $path = null + ) { + return Field::dialog( + model: Find::file($modelPath, $filename), + fieldName: $fieldName, + path: $path, + method: 'POST' + ); + } + ], +]; diff --git a/public/kirby/config/areas/fields/drawers.php b/public/kirby/config/areas/fields/drawers.php new file mode 100644 index 0000000..1280a68 --- /dev/null +++ b/public/kirby/config/areas/fields/drawers.php @@ -0,0 +1,61 @@ + [ + 'load' => function ( + string $modelPath, + string $fieldName, + string|null $path = null + ) { + return Field::drawer( + model: Find::parent($modelPath), + fieldName: $fieldName, + path: $path, + method: 'GET' + ); + }, + 'submit' => function ( + string $modelPath, + string $fieldName, + string|null $path = null + ) { + return Field::drawer( + model: Find::parent($modelPath), + fieldName: $fieldName, + path: $path, + method: 'POST' + ); + } + ], + 'file' => [ + 'load' => function ( + string $modelPath, + string $filename, + string $fieldName, + string|null $path = null + ) { + return Field::drawer( + model: Find::file($modelPath, $filename), + fieldName: $fieldName, + path: $path, + method: 'GET' + ); + }, + 'submit' => function ( + string $modelPath, + string $filename, + string $fieldName, + string|null $path = null + ) { + return Field::drawer( + model: Find::file($modelPath, $filename), + fieldName: $fieldName, + path: $path, + method: 'POST' + ); + } + ], +]; 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 new file mode 100644 index 0000000..400d949 --- /dev/null +++ b/public/kirby/config/areas/files/dialogs.php @@ -0,0 +1,165 @@ + [ + 'load' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'name' => [ + 'label' => I18n::translate('name'), + 'type' => 'slug', + 'required' => true, + 'icon' => 'title', + 'allow' => 'a-z0-9@._-', + 'after' => '.' . $file->extension(), + 'preselect' => true + ] + ], + 'submitButton' => I18n::translate('rename'), + 'value' => [ + 'name' => $file->name(), + ] + ] + ]; + }, + 'submit' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + $name = $file->kirby()->request()->get('name'); + $renamed = $file->changeName($name); + $oldUrl = $file->panel()->url(true); + $newUrl = $renamed->panel()->url(true); + $response = [ + 'event' => 'file.changeName' + ]; + + // check for a necessary redirect after the filename has changed + if (Panel::referrer() === $oldUrl && $oldUrl !== $newUrl) { + $response['redirect'] = $newUrl; + } + + return $response; + } + ], + + 'changeSort' => [ + 'load' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'position' => Field::filePosition($file) + ], + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'position' => $file->sort()->isEmpty() ? $file->siblings(false)->count() + 1 : $file->sort()->toInt(), + ] + ] + ]; + }, + 'submit' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + $files = $file->siblings()->sorted(); + $ids = $files->keys(); + $newIndex = (int)($file->kirby()->request()->get('position')) - 1; + $oldIndex = $files->indexOf($file); + + array_splice($ids, $oldIndex, 1); + array_splice($ids, $newIndex, 0, $file->id()); + + $files->changeSort($ids); + + return [ + 'event' => 'file.sort', + ]; + } + ], + + 'changeTemplate' => [ + 'load' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + $blueprints = $file->blueprints(); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'warning' => [ + 'type' => 'info', + 'theme' => 'notice', + 'text' => I18n::translate('file.changeTemplate.notice') + ], + 'template' => Field::template($blueprints, [ + 'required' => true + ]) + ], + 'theme' => 'notice', + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'template' => $file->template() + ] + ] + ]; + }, + 'submit' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + $template = $file->kirby()->request()->get('template'); + + $file->changeTemplate($template); + + return [ + 'event' => 'file.changeTemplate', + ]; + } + ], + + 'delete' => [ + 'load' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::template('file.delete.confirm', [ + 'filename' => Escape::html($file->filename()) + ]), + ] + ]; + }, + 'submit' => function (string $path, string $filename) { + $file = Find::file($path, $filename); + $redirect = false; + $referrer = Panel::referrer(); + $url = $file->panel()->url(true); + + $file->delete(); + + // redirect to the parent model URL + // if the dialog has been opened in the file view + if ($referrer === $url) { + $redirect = $file->parent()->panel()->url(true); + } + + return [ + 'event' => 'file.delete', + 'redirect' => $redirect + ]; + } + ], + +]; diff --git a/public/kirby/config/areas/files/dropdowns.php b/public/kirby/config/areas/files/dropdowns.php new file mode 100644 index 0000000..1b7e653 --- /dev/null +++ b/public/kirby/config/areas/files/dropdowns.php @@ -0,0 +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/installation.php b/public/kirby/config/areas/installation.php new file mode 100644 index 0000000..7f707cb --- /dev/null +++ b/public/kirby/config/areas/installation.php @@ -0,0 +1,40 @@ + 'settings', + 'label' => I18n::translate('view.installation'), + 'views' => [ + 'installation' => [ + 'pattern' => 'installation', + 'auth' => false, + 'action' => function () use ($kirby) { + $system = $kirby->system(); + return [ + 'component' => 'k-installation-view', + 'props' => [ + 'isInstallable' => $system->isInstallable(), + 'isInstalled' => $system->isInstalled(), + 'isOk' => $system->isOk(), + 'requirements' => $system->status(), + 'translations' => $kirby->translations()->values(function ($translation) { + return [ + 'text' => $translation->name(), + 'value' => $translation->code(), + ]; + }), + ] + ]; + } + ], + 'installation.fallback' => [ + 'pattern' => '(:all)', + 'auth' => false, + 'action' => fn () => Panel::go('installation') + ] + ] + ]; +}; diff --git a/public/kirby/config/areas/lab.php b/public/kirby/config/areas/lab.php new file mode 100644 index 0000000..580a9fb --- /dev/null +++ b/public/kirby/config/areas/lab.php @@ -0,0 +1,11 @@ + 'lab', + 'label' => 'Lab', + 'menu' => false, + 'drawers' => require __DIR__ . '/lab/drawers.php', + 'views' => require __DIR__ . '/lab/views.php' + ]; +}; diff --git a/public/kirby/config/areas/lab/drawers.php b/public/kirby/config/areas/lab/drawers.php new file mode 100644 index 0000000..eea790d --- /dev/null +++ b/public/kirby/config/areas/lab/drawers.php @@ -0,0 +1,31 @@ + [ + 'pattern' => 'lab/docs/(:any)', + 'load' => function (string $component) { + if (Docs::isInstalled() === false) { + return [ + 'component' => 'k-text-drawer', + 'props' => [ + 'text' => 'The UI docs are not installed.' + ] + ]; + } + + $doc = Doc::factory($component); + + return [ + 'component' => 'k-lab-docs-drawer', + 'props' => [ + 'icon' => 'book', + 'title' => $component, + 'docs' => $doc->toArray() + ] + ]; + }, + ], +]; diff --git a/public/kirby/config/areas/lab/views.php b/public/kirby/config/areas/lab/views.php new file mode 100644 index 0000000..4a1907a --- /dev/null +++ b/public/kirby/config/areas/lab/views.php @@ -0,0 +1,219 @@ + [ + 'pattern' => 'lab', + 'action' => function () { + return [ + 'component' => 'k-lab-index-view', + 'props' => [ + 'categories' => Category::all(), + 'info' => Category::isInstalled() ? null : 'The default Lab examples are not installed.', + 'tab' => 'examples', + ], + ]; + } + ], + 'lab.docs' => [ + 'pattern' => 'lab/docs', + 'action' => function () { + $view = [ + 'component' => 'k-lab-index-view', + 'title' => 'Docs', + 'breadcrumb' => [ + [ + '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', + ], + ]; + } + ], + 'lab.doc' => [ + 'pattern' => 'lab/docs/(:any)', + 'action' => function (string $component) { + $crumbs = [ + [ + 'label' => 'Docs', + 'link' => 'lab/docs' + ], + [ + 'label' => $component, + 'link' => 'lab/docs/' . $component + ] + ]; + + if (Docs::isInstalled() === false) { + return [ + 'component' => 'k-lab-index-view', + 'title' => $component, + 'breadcrumb' => $crumbs, + 'props' => [ + 'info' => 'The UI docs are not installed.', + 'tab' => 'docs', + ], + ]; + } + + $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' => $doc->toArray(), + 'lab' => $lab + ] + ]; + } + ], + 'lab.vue' => [ + 'pattern' => [ + 'lab/(:any)/(:any)/index.vue', + 'lab/(:any)/(:any)/(:any)/index.vue' + ], + 'action' => function ( + string $category, + string $id, + string|null $tab = null + ) { + return Category::factory($category)->example($id, $tab)->serve(); + } + ], + 'lab.example' => [ + 'pattern' => 'lab/(:any)/(:any)/(:any?)', + 'action' => function ( + string $category, + string $id, + string|null $tab = null + ) { + $category = Category::factory($category); + $example = $category->example($id, $tab); + $props = $example->props(); + $vue = $example->vue(); + $compiler = App::instance()->option('panel.vue.compiler', true); + + if ($doc = $props['docs'] ?? null) { + $doc = Doc::factory($doc); + } + + $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' => [ + [ + 'label' => $category->name(), + ], + [ + 'label' => $example->title(), + 'link' => $example->url() + ] + ], + 'props' => [ + 'buttons' => $buttons, + 'compiler' => $compiler, + 'docs' => $doc?->name, + 'examples' => $vue['examples'], + 'file' => $example->module(), + 'github' => $github, + 'props' => $props, + 'styles' => $vue['style'], + 'tab' => $example->tab(), + 'tabs' => array_values($example->tabs()), + 'template' => $vue['template'], + 'title' => $example->title(), + ], + ]; + } + ] +]; diff --git a/public/kirby/config/areas/languages.php b/public/kirby/config/areas/languages.php new file mode 100644 index 0000000..00a98c2 --- /dev/null +++ b/public/kirby/config/areas/languages.php @@ -0,0 +1,14 @@ + '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 new file mode 100644 index 0000000..5eb3aff --- /dev/null +++ b/public/kirby/config/areas/languages/dialogs.php @@ -0,0 +1,324 @@ + [ + 'counter' => false, + 'label' => I18n::translate('language.name'), + 'type' => 'text', + 'required' => true, + 'icon' => 'title' + ], + 'code' => [ + 'label' => I18n::translate('language.code'), + 'type' => 'text', + 'required' => true, + 'counter' => false, + 'icon' => 'translate', + 'width' => '1/2' + ], + 'direction' => [ + 'label' => I18n::translate('language.direction'), + 'type' => 'select', + 'required' => true, + 'empty' => false, + 'options' => [ + ['value' => 'ltr', 'text' => I18n::translate('language.direction.ltr')], + ['value' => 'rtl', 'text' => I18n::translate('language.direction.rtl')] + ], + 'width' => '1/2' + ], + 'locale' => [ + 'counter' => false, + 'label' => I18n::translate('language.locale'), + 'type' => 'text', + ], +]; + +$translationDialogFields = [ + 'key' => [ + 'counter' => false, + 'icon' => null, + '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', + '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', + 'load' => function () use ($languageDialogFields) { + return [ + 'component' => 'k-language-dialog', + 'props' => [ + 'fields' => $languageDialogFields, + 'submitButton' => I18n::translate('language.create'), + 'value' => [ + 'code' => '', + 'direction' => 'ltr', + 'locale' => '', + 'name' => '', + ] + ] + ]; + }, + 'submit' => function () { + $kirby = App::instance(); + + $data = $kirby->request()->get([ + 'code', + 'direction', + 'locale', + 'name' + ]); + $kirby->languages()->create($data); + + return [ + 'event' => 'language.create' + ]; + } + ], + + // delete language + 'language.delete' => [ + 'pattern' => 'languages/(:any)/delete', + 'load' => function (string $id) { + $language = Find::language($id); + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::template('language.delete.confirm', [ + 'name' => Escape::html($language->name()) + ]) + ] + ]; + }, + 'submit' => function (string $id) { + Find::language($id)->delete(); + + return [ + 'event' => 'language.delete', + 'redirect' => 'languages' + ]; + } + ], + + // update language + 'language.update' => [ + 'pattern' => 'languages/(:any)/update', + 'load' => function (string $id) use ($languageDialogFields) { + $language = Find::language($id); + $fields = $languageDialogFields; + $locale = $language->locale(); + + // use the first locale key if there's only one + if (count($locale) === 1) { + $locale = A::first($locale); + } + + // the code of an existing language cannot be changed + $fields['code']['disabled'] = true; + + // if the locale settings is more complex than just a + // single string, the text field won't do it anymore. + // Changes can only be made in the language file and + // we display a warning box instead. + if (is_array($locale) === true) { + $fields['locale'] = [ + 'label' => $fields['locale']['label'], + 'type' => 'info', + 'text' => I18n::translate('language.locale.warning') + ]; + } + + return [ + 'component' => 'k-language-dialog', + 'props' => [ + 'fields' => $fields, + 'submitButton' => I18n::translate('save'), + 'value' => [ + 'code' => $language->code(), + 'direction' => $language->direction(), + 'locale' => $locale, + 'name' => $language->name(), + 'rules' => $language->rules(), + ] + ] + ]; + }, + 'submit' => function (string $id) { + $kirby = App::instance(); + + $data = $kirby->request()->get(['direction', 'locale', 'name']); + $language = Find::language($id)->update($data); + + return [ + 'event' => 'language.update' + ]; + } + ], + + 'language.translation.create' => [ + 'pattern' => 'languages/(:any)/translations/create', + 'load' => function (string $languageCode) use ($translationDialogFields) { + // find the language to make sure it exists + Find::language($languageCode); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => $translationDialogFields, + 'size' => 'large', + 'value' => [ + 'multiple' => false, + ] + ], + ]; + }, + 'submit' => function (string $languageCode) { + $request = App::instance()->request(); + $language = Find::language($languageCode); + + $key = $request->get('key', ''); + $multiple = $request->get('multiple', false); + + $value = match ($multiple) { + true => $request->get('entries', []), + default => $request->get('value', '') + }; + + LanguageVariable::create($key, $value); + + if ($language->isDefault() === false) { + $language->variable($key)->update($value); + } + + return true; + } + ], + 'language.translation.delete' => [ + 'pattern' => 'languages/(:any)/translations/(:any)/delete', + 'load' => function (string $languageCode, string $translationKey) { + $variable = Find::language($languageCode)->variable($translationKey, true); + + if ($variable->exists() === false) { + throw new NotFoundException( + key: 'language.variable.notFound' + ); + } + + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::template('language.variable.delete.confirm', [ + 'key' => Escape::html($variable->key()) + ]) + ], + ]; + }, + 'submit' => function (string $languageCode, string $translationKey) { + return Find::language($languageCode)->variable($translationKey, true)->delete(); + } + ], + 'language.translation.update' => [ + 'pattern' => 'languages/(:any)/translations/(:any)/update', + 'load' => function (string $languageCode, string $translationKey) use ($translationDialogFields) { + $language = Find::language($languageCode); + $variable = $language->variable($translationKey, true); + + if ($variable->exists() === false) { + throw new NotFoundException( + key: 'language.variable.notFound' + ); + } + + $fields = $translationDialogFields; + + // 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['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' => [ + 'fields' => $fields, + 'size' => 'large', + 'value' => $value + ] + ]; + }, + 'submit' => function (string $languageCode, string $translationKey) { + $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 new file mode 100644 index 0000000..955ca5b --- /dev/null +++ b/public/kirby/config/areas/languages/views.php @@ -0,0 +1,137 @@ + [ + 'pattern' => 'languages/(:any)', + 'when' => function (): bool { + return App::instance()->option('languages.variables', true) !== false; + }, + 'action' => function (string $code) { + $kirby = App::instance(); + $language = Find::language($code); + $link = '/languages/' . $language->code(); + $strings = []; + $foundation = $kirby->defaultLanguage()->translations(); + $translations = $language->translations(); + + // 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); + + foreach ($foundation as $key => $value) { + $strings[] = [ + 'key' => $key, + 'value' => $translations[$key] ?? null, + 'options' => [ + [ + 'click' => 'update', + 'disabled' => $canUpdate === false, + 'icon' => 'edit', + 'text' => I18n::translate('edit'), + ], + [ + 'click' => 'delete', + 'disabled' => $canUpdate === false || $language->isDefault() === false, + 'icon' => 'trash', + 'text' => I18n::translate('delete'), + ] + ] + ]; + } + + $next = function () use ($language) { + if ($next = $language->next()) { + return [ + 'link' => '/languages/' . $next->code(), + 'title' => $next->name(), + ]; + } + }; + + $prev = function () use ($language) { + if ($prev = $language->prev()) { + return [ + 'link' => '/languages/' . $prev->code(), + 'title' => $prev->name(), + ]; + } + }; + + return [ + 'component' => 'k-language-view', + 'breadcrumb' => [ + [ + 'label' => $name = $language->name(), + 'link' => $link, + ] + ], + 'props' => [ + 'buttons' => fn () => + ViewButtons::view('language', model: $language) + ->defaults('open', 'settings', 'delete') + ->render(), + 'deletable' => $language->isDeletable(), + 'code' => Escape::html($language->code()), + 'default' => $language->isDefault(), + 'direction' => $language->direction(), + 'id' => $language->code(), + 'info' => [ + [ + 'label' => 'Status', + 'value' => I18n::translate('language.' . ($language->isDefault() ? 'default' : 'secondary')), + ], + [ + 'label' => I18n::translate('language.code'), + 'value' => $language->code(), + ], + [ + 'label' => I18n::translate('language.locale'), + 'value' => $language->locale(LC_ALL) + ], + [ + 'label' => I18n::translate('language.direction'), + 'value' => I18n::translate('language.direction.' . $language->direction()), + ], + ], + 'name' => $name, + 'next' => $next, + 'prev' => $prev, + 'translations' => $strings, + 'url' => $language->url(), + ] + ]; + } + ], + 'languages' => [ + 'pattern' => 'languages', + 'action' => function () { + $kirby = App::instance(); + + 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(), + 'id' => $language->code(), + 'info' => Escape::html($language->code()), + 'text' => Escape::html($language->name()), + ]), + 'variables' => $kirby->option('languages.variables', true) + ] + ]; + } + ] +]; diff --git a/public/kirby/config/areas/login.php b/public/kirby/config/areas/login.php new file mode 100644 index 0000000..a72e4c8 --- /dev/null +++ b/public/kirby/config/areas/login.php @@ -0,0 +1,44 @@ + 'user', + 'label' => I18n::translate('login'), + 'views' => [ + 'login' => [ + 'pattern' => 'login', + 'auth' => false, + 'action' => function () use ($kirby) { + $system = $kirby->system(); + $status = $kirby->auth()->status(); + return [ + 'component' => 'k-login-view', + 'props' => [ + 'methods' => array_keys($system->loginMethods()), + 'pending' => [ + 'email' => $status->email(), + 'challenge' => $status->challenge() + ] + ], + ]; + } + ], + 'login.fallback' => [ + 'pattern' => '(:all)', + 'auth' => false, + 'action' => function ($path) use ($kirby) { + /** + * Store the current path in the session + * Once the user is logged in, the path will + * be used to redirect to that view again + */ + $kirby->session()->set('panel.path', $path); + Panel::go(url: 'login', refresh: 0); + } + ] + ] + ]; +}; diff --git a/public/kirby/config/areas/logout.php b/public/kirby/config/areas/logout.php new file mode 100644 index 0000000..5dc4a50 --- /dev/null +++ b/public/kirby/config/areas/logout.php @@ -0,0 +1,21 @@ + 'user', + 'label' => I18n::translate('logout'), + 'views' => [ + 'logout' => [ + 'pattern' => 'logout', + 'auth' => false, + 'action' => function () use ($kirby) { + $kirby->auth()->logout(); + Panel::go('login'); + }, + ] + ] + ]; +}; diff --git a/public/kirby/config/areas/search.php b/public/kirby/config/areas/search.php new file mode 100644 index 0000000..2f7474e --- /dev/null +++ b/public/kirby/config/areas/search.php @@ -0,0 +1,11 @@ + 'search', + 'label' => I18n::translate('search'), + 'views' => require __DIR__ . '/search/views.php' + ]; +}; diff --git a/public/kirby/config/areas/search/views.php b/public/kirby/config/areas/search/views.php new file mode 100644 index 0000000..365e834 --- /dev/null +++ b/public/kirby/config/areas/search/views.php @@ -0,0 +1,17 @@ + [ + 'pattern' => 'search', + 'action' => function () { + return [ + 'component' => 'k-search-view', + 'props' => [ + 'type' => App::instance()->request()->get('type'), + ] + ]; + } + ], +]; diff --git a/public/kirby/config/areas/site.php b/public/kirby/config/areas/site.php new file mode 100644 index 0000000..57a2dde --- /dev/null +++ b/public/kirby/config/areas/site.php @@ -0,0 +1,23 @@ +site()->blueprint(); + + return [ + 'breadcrumbLabel' => function () use ($kirby) { + return $kirby->site()->title()->or(I18n::translate('view.site'))->toString(); + }, + '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', + 'requests' => require __DIR__ . '/site/requests.php', + 'searches' => require __DIR__ . '/site/searches.php', + 'views' => require __DIR__ . '/site/views.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 new file mode 100644 index 0000000..c9033c9 --- /dev/null +++ b/public/kirby/config/areas/site/dialogs.php @@ -0,0 +1,592 @@ + [ + 'pattern' => 'pages/(:any)/changeSort', + 'load' => function (string $id) { + $page = Find::page($id); + + if ($page->blueprint()->num() !== 'default') { + throw new PermissionException( + key: 'page.sort.permission', + data: ['slug' => $page->slug()] + ); + } + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'position' => Field::pagePosition($page), + ], + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'position' => $page->panel()->position() + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + Find::page($id)->changeStatus( + 'listed', + $request->get('position') + ); + + return [ + 'event' => 'page.sort', + ]; + } + ], + + 'page.changeStatus' => [ + 'pattern' => 'pages/(:any)/changeStatus', + 'load' => function (string $id) { + $page = Find::page($id); + $blueprint = $page->blueprint(); + $status = $page->status(); + $states = []; + $position = null; + + foreach ($blueprint->status() as $key => $state) { + $states[] = [ + 'value' => $key, + 'text' => $state['label'], + 'info' => $state['text'], + ]; + } + + if ($status === 'draft') { + $errors = $page->errors(); + + // switch to the error dialog if there are + // errors and the draft cannot be published + if (count($errors) > 0) { + return [ + 'component' => 'k-error-dialog', + 'props' => [ + 'message' => I18n::translate('error.page.changeStatus.incomplete'), + 'details' => $errors, + ] + ]; + } + } + + $fields = [ + 'status' => [ + 'label' => I18n::translate('page.changeStatus.select'), + 'type' => 'radio', + 'required' => true, + 'options' => $states + ] + ]; + + if ($blueprint->num() === 'default') { + $fields['position'] = Field::pagePosition($page, [ + 'when' => [ + 'status' => 'listed' + ] + ]); + + $position = $page->panel()->position(); + } + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => $fields, + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'status' => $status, + 'position' => $position + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + Find::page($id)->changeStatus( + $request->get('status'), + $request->get('position') + ); + + return [ + 'event' => 'page.changeStatus', + ]; + } + ], + + 'page.changeTemplate' => [ + 'pattern' => 'pages/(:any)/changeTemplate', + 'load' => function (string $id) { + $page = Find::page($id); + $blueprints = $page->blueprints(); + + if (count($blueprints) <= 1) { + throw new Exception( + key: 'page.changeTemplate.invalid', + data: ['slug' => $id] + ); + } + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'notice' => [ + 'type' => 'info', + 'theme' => 'notice', + 'text' => I18n::translate('page.changeTemplate.notice') + ], + 'template' => Field::template($blueprints, [ + 'required' => true + ]) + ], + 'theme' => 'notice', + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'template' => $page->intendedTemplate()->name() + ] + ] + ]; + }, + 'submit' => function (string $id) { + $page = Find::page($id); + $template = App::instance()->request()->get('template'); + + $page->changeTemplate($template); + + return [ + 'event' => 'page.changeTemplate', + ]; + } + ], + + 'page.changeTitle' => [ + 'pattern' => 'pages/(:any)/changeTitle', + 'load' => function (string $id) { + $kirby = App::instance(); + $request = $kirby->request(); + + $page = Find::page($id); + $permissions = $page->permissions(); + $select = $request->get('select', 'title'); + + // build the path prefix + $path = match ($kirby->multilang()) { + true => Str::after($kirby->site()->url(), $kirby->url()) . '/', + false => '/' + }; + + if ($parent = $page->parent()) { + $path .= $parent->uri() . '/'; + } + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'title' => Field::title([ + 'required' => true, + 'preselect' => $select === 'title', + 'disabled' => $permissions->can('changeTitle') === false + ]), + 'slug' => Field::slug([ + 'required' => true, + 'preselect' => $select === 'slug', + 'path' => $path, + 'disabled' => $permissions->can('changeSlug') === false, + 'wizard' => [ + 'text' => I18n::translate('page.changeSlug.fromTitle'), + 'field' => 'title' + ] + ]) + ], + 'autofocus' => false, + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'title' => $page->title()->value(), + 'slug' => $page->slug(), + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + $page = Find::page($id); + $title = trim($request->get('title', '')); + $slug = trim($request->get('slug', '')); + + // basic input validation before we move on + PageRules::validateTitleLength($title); + PageRules::validateSlugLength($slug); + + // nothing changed + if ($page->title()->value() === $title && $page->slug() === $slug) { + return true; + } + + // prepare the response + $response = [ + 'event' => [] + ]; + + // the page title changed + if ($page->title()->value() !== $title) { + $page = $page->changeTitle($title); + $response['event'][] = 'page.changeTitle'; + } + + // the slug changed + if ($page->slug() !== $slug) { + $response['event'][] = 'page.changeSlug'; + + $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) { + $response['redirect'] = $newUrl; + } + } + + return $response; + } + ], + + 'page.create' => [ + 'pattern' => 'pages/create', + 'load' => function () { + $request = App::instance()->request(); + $dialog = new PageCreateDialog( + parentId: $request->get('parent'), + sectionId: $request->get('section'), + slug: $request->get('slug'), + template: $request->get('template'), + title: $request->get('title'), + uuid: $request->get('uuid'), + viewId: $request->get('view'), + ); + + return $dialog->load(); + }, + 'submit' => function () { + $request = App::instance()->request(); + $dialog = new PageCreateDialog( + parentId: $request->get('parent'), + sectionId: $request->get('section'), + slug: $request->get('slug'), + template: $request->get('template'), + title: $request->get('title'), + uuid: $request->get('uuid'), + viewId: $request->get('view'), + ); + + return $dialog->submit($request->get()); + } + ], + + 'page.delete' => [ + 'pattern' => 'pages/(:any)/delete', + 'load' => function (string $id) { + $page = Find::page($id); + $text = I18n::template('page.delete.confirm', [ + 'title' => Escape::html($page->title()->value()) + ]); + + if ($page->childrenAndDrafts()->count() > 0) { + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'info' => [ + 'type' => 'info', + 'theme' => 'negative', + 'text' => I18n::translate('page.delete.confirm.subpages') + ], + 'check' => [ + 'label' => I18n::translate('page.delete.confirm.title'), + 'type' => 'text', + 'counter' => false + ] + ], + 'size' => 'medium', + 'submitButton' => I18n::translate('delete'), + 'text' => $text, + 'theme' => 'negative', + ] + ]; + } + + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => $text + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + $page = Find::page($id); + $redirect = false; + $referrer = Panel::referrer(); + $url = $page->panel()->url(true); + + if ( + $page->childrenAndDrafts()->count() > 0 && + $request->get('check') !== $page->title()->value() + ) { + throw new InvalidArgumentException( + key: 'page.delete.confirm' + ); + } + + $page->delete(true); + + // redirect to the parent model URL + // if the dialog has been opened in the page view + if ($referrer === $url) { + $redirect = $page->parentModel()->panel()->url(true); + } + + return [ + 'event' => 'page.delete', + 'redirect' => $redirect + ]; + } + ], + + 'page.duplicate' => [ + 'pattern' => 'pages/(:any)/duplicate', + 'load' => function (string $id) { + $page = Find::page($id); + $hasChildren = $page->hasChildren(); + $hasFiles = $page->hasFiles(); + $toggleWidth = '1/' . count(array_filter([$hasChildren, $hasFiles])); + + $fields = [ + 'title' => Field::title([ + 'required' => true + ]), + 'slug' => Field::slug([ + 'required' => true, + 'path' => $page->parent() ? '/' . $page->parent()->id() . '/' : '/', + 'wizard' => [ + 'text' => I18n::translate('page.changeSlug.fromTitle'), + 'field' => 'title' + ] + ]) + ]; + + if ($hasFiles === true) { + $fields['files'] = [ + 'label' => I18n::translate('page.duplicate.files'), + 'type' => 'toggle', + 'width' => $toggleWidth + ]; + } + + if ($hasChildren === true) { + $fields['children'] = [ + 'label' => I18n::translate('page.duplicate.pages'), + 'type' => 'toggle', + 'width' => $toggleWidth + ]; + } + + $slugAppendix = Url::slug(I18n::translate('page.duplicate.appendix')); + $titleAppendix = I18n::translate('page.duplicate.appendix'); + + // if the item to be duplicated already exists + // add a suffix at the end of slug and title + $duplicateSlug = $page->slug() . '-' . $slugAppendix; + $siblingKeys = $page->parentModel()->childrenAndDrafts()->pluck('uid'); + + if (in_array($duplicateSlug, $siblingKeys, true) === true) { + $suffixCounter = 2; + $newSlug = $duplicateSlug . $suffixCounter; + + while (in_array($newSlug, $siblingKeys, true) === true) { + $newSlug = $duplicateSlug . ++$suffixCounter; + } + + $slugAppendix .= $suffixCounter; + $titleAppendix .= ' ' . $suffixCounter; + } + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => $fields, + 'submitButton' => I18n::translate('duplicate'), + 'value' => [ + 'children' => false, + 'files' => false, + 'slug' => $page->slug() . '-' . $slugAppendix, + 'title' => $page->title() . ' ' . $titleAppendix + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + $newPage = Find::page($id)->duplicate($request->get('slug'), [ + 'children' => (bool)$request->get('children'), + 'files' => (bool)$request->get('files'), + 'title' => (string)$request->get('title'), + ]); + + return [ + 'event' => 'page.duplicate', + 'redirect' => $newPage->panel()->url(true) + ]; + } + ], + + 'page.fields' => [ + ...$fields['model'], + 'pattern' => '(pages/[^/]+)/fields/(:any)/(:all?)', + ], + 'page.file.changeName' => [ + ...$files['changeName'], + 'pattern' => '(pages/[^/]+)/files/(:any)/changeName', + ], + 'page.file.changeSort' => [ + ...$files['changeSort'], + 'pattern' => '(pages/[^/]+)/files/(:any)/changeSort', + ], + 'page.file.changeTemplate' => [ + ...$files['changeTemplate'], + 'pattern' => '(pages/[^/]+)/files/(:any)/changeTemplate', + ], + 'page.file.delete' => [ + ...$files['delete'], + 'pattern' => '(pages/[^/]+)/files/(:any)/delete', + ], + 'page.file.fields' => [ + ...$fields['file'], + 'pattern' => '(pages/[^/]+)/files/(:any)/fields/(:any)/(:all?)', + ], + + 'page.move' => [ + 'pattern' => 'pages/(:any)/move', + 'load' => function (string $id) { + $page = Find::page($id); + $parent = $page->parentModel(); + + if (Uuids::enabled() === false) { + $parentId = $parent?->id() ?? '/'; + } else { + $parentId = $parent?->uuid()->toString() ?? 'site://'; + } + + return [ + 'component' => 'k-page-move-dialog', + 'props' => [ + 'value' => [ + 'move' => $page->panel()->url(true), + 'parent' => $parentId + ] + ] + ]; + }, + 'submit' => function (string $id) { + $kirby = App::instance(); + $parentId = $kirby->request()->get('parent'); + $parent = (empty($parentId) === true || $parentId === '/' || $parentId === 'site://') ? $kirby->site() : Find::page($parentId); + $oldPage = Find::page($id); + $newPage = $oldPage->move($parent); + + return [ + 'event' => 'page.move', + 'redirect' => $newPage->panel()->url(true) + ]; + } + ], + + 'site.changeTitle' => [ + 'pattern' => 'site/changeTitle', + 'load' => function () { + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'title' => Field::title([ + 'required' => true, + 'preselect' => true + ]) + ], + 'submitButton' => I18n::translate('rename'), + 'value' => [ + 'title' => App::instance()->site()->title()->value() + ] + ] + ]; + }, + 'submit' => function () { + $kirby = App::instance(); + $kirby->site()->changeTitle($kirby->request()->get('title')); + + return [ + 'event' => 'site.changeTitle', + ]; + } + ], + + 'site.fields' => [ + ...$fields['model'], + 'pattern' => '(site)/fields/(:any)/(:all?)', + ], + 'site.file.changeName' => [ + ...$files['changeName'], + 'pattern' => '(site)/files/(:any)/changeName', + ], + 'site.file.changeSort' => [ + ...$files['changeSort'], + 'pattern' => '(site)/files/(:any)/changeSort', + ], + 'site.file.changeTemplate' => [ + ...$files['changeTemplate'], + 'pattern' => '(site)/files/(:any)/changeTemplate', + ], + 'site.file.delete' => [ + ...$files['delete'], + 'pattern' => '(site)/files/(:any)/delete', + ], + 'site.file.fields' => [ + ...$fields['file'], + 'pattern' => '(site)/files/(:any)/fields/(:any)/(:all?)', + ], + + 'changes' => [ + 'pattern' => 'changes', + 'load' => function () { + return (new ChangesDialog())->load(); + }, + ], +]; diff --git a/public/kirby/config/areas/site/drawers.php b/public/kirby/config/areas/site/drawers.php new file mode 100644 index 0000000..86d2162 --- /dev/null +++ b/public/kirby/config/areas/site/drawers.php @@ -0,0 +1,22 @@ + [ + ...$fields['model'], + 'pattern' => '(pages/[^/]+)/fields/(:any)/(:all?)', + ], + 'page.file.fields' => [ + ...$fields['file'], + 'pattern' => '(pages/[^/]+)/files/(:any)/fields/(:any)/(:all?)', + ], + 'site.fields' => [ + ...$fields['model'], + 'pattern' => '(site)/fields/(:any)/(:all?)', + ], + 'site.file.fields' => [ + ...$fields['file'], + 'pattern' => '(site)/files/(:any)/fields/(:any)/(:all?)', + ], +]; diff --git a/public/kirby/config/areas/site/dropdowns.php b/public/kirby/config/areas/site/dropdowns.php new file mode 100644 index 0000000..363b4b0 --- /dev/null +++ b/public/kirby/config/areas/site/dropdowns.php @@ -0,0 +1,46 @@ + [ + 'pattern' => 'pages/(:any)', + 'options' => function (string $path) { + return Find::page($path)->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 new file mode 100644 index 0000000..360df93 --- /dev/null +++ b/public/kirby/config/areas/site/requests.php @@ -0,0 +1,25 @@ + [ + 'pattern' => 'site/tree', + 'action' => function () { + return (new PageTree())->children( + parent: App::instance()->request()->get('parent'), + moving: App::instance()->request()->get('move') + ); + } + ], + 'tree.parents' => [ + 'pattern' => 'site/tree/parents', + 'action' => function () { + return (new PageTree())->parents( + page: App::instance()->request()->get('page'), + includeSite: App::instance()->request()->get('root') === 'true', + ); + } + ] +]; diff --git a/public/kirby/config/areas/site/searches.php b/public/kirby/config/areas/site/searches.php new file mode 100644 index 0000000..7f20214 --- /dev/null +++ b/public/kirby/config/areas/site/searches.php @@ -0,0 +1,17 @@ + [ + 'label' => I18n::translate('pages'), + 'icon' => 'page', + 'query' => fn (string|null $query, int $limit, int $page) => Search::pages($query, $limit, $page) + ], + 'files' => [ + 'label' => I18n::translate('files'), + 'icon' => 'image', + '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 new file mode 100644 index 0000000..d7f9d03 --- /dev/null +++ b/public/kirby/config/areas/site/views.php @@ -0,0 +1,98 @@ + [ + 'pattern' => 'pages/(:any)', + 'action' => fn (string $path) => Find::page($path)->panel()->view() + ], + 'page.file' => [ + 'pattern' => 'pages/(:any)/files/(:any)', + 'action' => function (string $id, string $filename) { + 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() + ], + 'site.file' => [ + 'pattern' => 'site/files/(:any)', + 'action' => function (string $filename) { + 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.php b/public/kirby/config/areas/system.php new file mode 100644 index 0000000..9f3075a --- /dev/null +++ b/public/kirby/config/areas/system.php @@ -0,0 +1,13 @@ + 'settings', + 'label' => I18n::translate('view.system'), + 'menu' => true, + 'dialogs' => require __DIR__ . '/system/dialogs.php', + 'views' => require __DIR__ . '/system/views.php' + ]; +}; diff --git a/public/kirby/config/areas/system/dialogs.php b/public/kirby/config/areas/system/dialogs.php new file mode 100644 index 0000000..8421a5f --- /dev/null +++ b/public/kirby/config/areas/system/dialogs.php @@ -0,0 +1,135 @@ + [ + 'load' => function () { + $kirby = App::instance(); + $license = $kirby->system()->license(); + $obfuscated = $kirby->user()->isAdmin() === false; + $status = $license->status(); + $renewable = $status->renewable(); + + return [ + 'component' => 'k-license-dialog', + 'props' => [ + 'license' => [ + 'code' => $license->code($obfuscated), + 'icon' => $status->icon(), + 'info' => $status->info($license->renewal('Y-m-d', 'date')), + 'theme' => $status->theme(), + 'type' => $license->label(), + ], + 'cancelButton' => $renewable, + 'submitButton' => $renewable ? [ + 'icon' => 'refresh', + 'text' => I18n::translate('renew'), + 'theme' => 'love', + ] : false, + ] + ]; + }, + 'submit' => function () { + // @codeCoverageIgnoreStart + $response = App::instance()->system()->license()->upgrade(); + + // the upgrade is still needed + if ($response['status'] === 'upgrade') { + return [ + 'redirect' => $response['url'] + ]; + } + + // the upgrade has already been completed + if ($response['status'] === 'complete') { + return [ + 'event' => 'system.renew', + 'message' => I18n::translate('license.success') + ]; + } + + throw new LogicException(message: 'The upgrade failed'); + // @codeCoverageIgnoreEnd + } + ], + 'license/remove' => [ + 'load' => function () { + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::translate('license.remove.text'), + 'size' => 'medium', + 'submitButton' => [ + 'icon' => 'trash', + 'text' => I18n::translate('remove'), + 'theme' => 'negative', + ], + ] + ]; + }, + 'submit' => function () { + // @codeCoverageIgnoreStart + App::instance()->system()->license()->delete(); + return true; + // @codeCoverageIgnoreEnd + } + ], + // license registration + 'registration' => [ + 'load' => function () { + $system = App::instance()->system(); + $local = $system->isLocal(); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'domain' => [ + 'label' => I18n::translate('license.activate.label'), + 'type' => 'info', + 'theme' => $local ? 'warning' : 'info', + 'text' => I18n::template('license.activate.' . ($local ? 'local' : 'domain'), ['host' => $system->indexUrl()]) + ], + 'license' => [ + 'label' => I18n::translate('license.code.label'), + 'type' => 'text', + 'required' => true, + 'counter' => false, + 'placeholder' => 'K-', + 'help' => I18n::translate('license.code.help') . ' ' . '' . I18n::translate('license.buy') . ' →' + ], + 'email' => Field::email(['required' => true]) + ], + 'submitButton' => [ + 'icon' => 'key', + 'text' => I18n::translate('activate'), + 'theme' => 'love', + ], + 'value' => [ + 'license' => null, + 'email' => null + ] + ] + ]; + }, + 'submit' => function () { + // @codeCoverageIgnoreStart + $kirby = App::instance(); + $kirby->system()->register( + $kirby->request()->get('license'), + $kirby->request()->get('email') + ); + + return [ + 'event' => 'system.register', + 'message' => I18n::translate('license.success') + ]; + // @codeCoverageIgnoreEnd + } + ], +]; diff --git a/public/kirby/config/areas/system/views.php b/public/kirby/config/areas/system/views.php new file mode 100644 index 0000000..13626eb --- /dev/null +++ b/public/kirby/config/areas/system/views.php @@ -0,0 +1,139 @@ + [ + 'pattern' => 'system', + 'action' => function () { + $kirby = App::instance(); + $system = $kirby->system(); + $updateStatus = $system->updateStatus(); + $license = $system->license(); + $debugMode = $kirby->option('debug', false) === true; + $isLocal = $system->isLocal(); + + $environment = [ + [ + 'label' => $license->status()->label(), + 'value' => $license->label(), + 'theme' => $license->status()->theme(), + 'icon' => $license->status()->icon(), + 'dialog' => $license->status()->dialog() + ], + [ + 'label' => $updateStatus?->label() ?? I18n::translate('version'), + 'value' => $kirby->version(), + 'link' => $updateStatus?->url() ?? + 'https://github.com/getkirby/kirby/releases/tag/' . $kirby->version(), + 'theme' => $updateStatus?->theme(), + 'icon' => $updateStatus?->icon() ?? 'info' + ], + [ + 'label' => 'PHP', + 'value' => phpversion(), + 'icon' => 'code' + ], + [ + 'label' => I18n::translate('server'), + 'value' => $system->serverSoftwareShort() ?? '?', + 'icon' => 'server' + ] + ]; + + $exceptions = $updateStatus?->exceptionMessages() ?? []; + + $plugins = $system->plugins()->values(function ($plugin) use (&$exceptions) { + $authors = $plugin->authorsNames(); + $updateStatus = $plugin->updateStatus(); + $version = $updateStatus?->toArray(); + $version ??= $plugin->version() ?? '–'; + + if ($updateStatus !== null) { + $exceptions = [ + ...$exceptions, + ...$updateStatus->exceptionMessages() + ]; + } + + return [ + 'author' => empty($authors) ? '–' : $authors, + 'license' => $plugin->license()->toArray(), + 'name' => [ + 'text' => $plugin->name() ?? '–', + 'href' => $plugin->link(), + ], + 'status' => $plugin->license()->status()->toArray(), + 'version' => $version, + ]; + }); + + $security = $updateStatus?->messages() ?? []; + + if ($isLocal === true) { + $security[] = [ + 'id' => 'local', + 'icon' => 'info', + 'theme' => 'info', + 'text' => I18n::translate('system.issues.local') + ]; + } + + if ($debugMode === true) { + $security[] = [ + 'id' => 'debug', + 'icon' => $isLocal ? 'info' : 'alert', + 'theme' => $isLocal ? 'info' : 'negative', + 'text' => I18n::translate('system.issues.debug'), + 'link' => 'https://getkirby.com/security/debug' + ]; + } + + if ( + $isLocal === false && + $kirby->environment()->https() !== true + ) { + $security[] = [ + 'id' => 'https', + 'text' => I18n::translate('system.issues.https'), + 'link' => 'https://getkirby.com/security/https' + ]; + } + + if ($kirby->option('panel.vue.compiler', null) === null) { + $security[] = [ + 'id' => 'vue-compiler', + 'link' => 'https://getkirby.com/security/vue-compiler', + 'text' => I18n::translate('system.issues.vue.compiler'), + 'theme' => 'notice' + ]; + } + + // sensitive URLs + if ($isLocal === false) { + $sensitive = [ + 'content' => $system->exposedFileUrl('content'), + 'git' => $system->exposedFileUrl('git'), + 'kirby' => $system->exposedFileUrl('kirby'), + 'site' => $system->exposedFileUrl('site') + ]; + } + + 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 ?? [] + ] + ]; + } + ], +]; diff --git a/public/kirby/config/areas/users.php b/public/kirby/config/areas/users.php new file mode 100644 index 0000000..05bdad7 --- /dev/null +++ b/public/kirby/config/areas/users.php @@ -0,0 +1,18 @@ + 'users', + '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', + 'searches' => require __DIR__ . '/users/searches.php', + 'views' => require __DIR__ . '/users/views.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 new file mode 100644 index 0000000..00abd00 --- /dev/null +++ b/public/kirby/config/areas/users/dialogs.php @@ -0,0 +1,360 @@ + [ + 'pattern' => 'users/create', + 'load' => function () { + $kirby = App::instance(); + $roles = $kirby->roles()->canBeCreated(); + + // get default value for role + if ($role = $kirby->request()->get('role')) { + $role = $roles->find($role)?->id(); + } + + // get role field definition, incl. available role options + $roles = Field::role( + roles: $roles, + props: ['required' => true] + ); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'name' => Field::username(), + 'email' => Field::email([ + 'link' => false, + 'required' => true + ]), + 'password' => Field::password([ + 'autocomplete' => 'new-password' + ]), + 'translation' => Field::translation([ + 'required' => true + ]), + 'role' => $roles + ], + 'submitButton' => I18n::translate('create'), + 'value' => [ + 'name' => '', + 'email' => '', + 'password' => '', + 'translation' => $kirby->panelLanguage(), + 'role' => $role ?: $roles['options'][0]['value'] ?? null + ] + ] + ]; + }, + 'submit' => function () { + $kirby = App::instance(); + + $kirby->users()->create([ + 'name' => $kirby->request()->get('name'), + 'email' => $kirby->request()->get('email'), + 'password' => $kirby->request()->get('password'), + 'language' => $kirby->request()->get('translation'), + 'role' => $kirby->request()->get('role') + ]); + + return [ + 'event' => 'user.create' + ]; + } + ], + + 'user.changeEmail' => [ + 'pattern' => 'users/(:any)/changeEmail', + 'load' => function (string $id) { + $user = Find::user($id); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'email' => [ + 'label' => I18n::translate('email'), + 'required' => true, + 'type' => 'email', + 'preselect' => true + ] + ], + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'email' => $user->email() + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + Find::user($id)->changeEmail($request->get('email')); + + return [ + 'event' => 'user.changeEmail' + ]; + } + ], + + 'user.changeLanguage' => [ + 'pattern' => 'users/(:any)/changeLanguage', + 'load' => function (string $id) { + $user = Find::user($id); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'translation' => Field::translation(['required' => true]) + ], + 'submitButton' => I18n::translate('change'), + 'value' => [ + 'translation' => $user->language() + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + Find::user($id)->changeLanguage($request->get('translation')); + + return [ + 'event' => 'user.changeLanguage', + 'reload' => [ + 'globals' => '$translation' + ] + ]; + } + ], + + 'user.changeName' => [ + 'pattern' => 'users/(:any)/changeName', + 'load' => function (string $id) { + $user = Find::user($id); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'name' => Field::username([ + 'preselect' => true + ]) + ], + 'submitButton' => I18n::translate('rename'), + 'value' => [ + 'name' => $user->name()->value() + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + Find::user($id)->changeName($request->get('name')); + + return [ + 'event' => 'user.changeName' + ]; + } + ], + + 'user.changePassword' => [ + 'pattern' => 'users/(:any)/changePassword', + 'load' => function (string $id) { + $kirby = App::instance(); + $user = Find::user($id); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'currentPassword' => Field::password([ + 'label' => I18n::translate('user.changePassword.' . ($kirby->user()->is($user) ? 'current' : 'own')), + 'autocomplete' => 'current-password', + 'help' => I18n::translate('account') . ': ' . App::instance()->user()->email(), + ]), + 'line' => [ + 'type' => 'line', + ], + 'password' => Field::password([ + 'label' => I18n::translate('user.changePassword.new'), + 'autocomplete' => 'new-password', + 'help' => I18n::translate('account') . ': ' . $user->email(), + ]), + 'passwordConfirmation' => Field::password([ + 'label' => I18n::translate('user.changePassword.new.confirm'), + 'autocomplete' => 'new-password' + ]) + ], + 'submitButton' => I18n::translate('change'), + ] + ]; + }, + 'submit' => function (string $id) { + $kirby = App::instance(); + $request = $kirby->request(); + + $user = Find::user($id); + $currentPassword = $request->get('currentPassword'); + $password = $request->get('password'); + $passwordConfirmation = $request->get('passwordConfirmation'); + + // validate the current password of the acting user + try { + $kirby->user()->validatePassword($currentPassword); + } catch (Exception) { + // catching and re-throwing exception to avoid automatic + // sign-out of current user from the Panel + throw new InvalidArgumentException([ + 'key' => 'user.password.wrong' + ]); + } + + // validate the new password + UserRules::validPassword($user, $password ?? ''); + + // compare passwords + if ($password !== $passwordConfirmation) { + throw new InvalidArgumentException( + key: 'user.password.notSame' + ); + } + + // change password if everything's fine + $user->changePassword($password); + + return [ + 'event' => 'user.changePassword' + ]; + } + ], + + 'user.changeRole' => [ + 'pattern' => 'users/(:any)/changeRole', + 'load' => function (string $id) { + $user = Find::user($id); + + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'role' => Field::role( + roles: $user->roles(), + props: [ + 'label' => I18n::translate('user.changeRole.select'), + 'required' => true, + ] + ) + ], + 'submitButton' => I18n::translate('user.changeRole'), + 'value' => [ + 'role' => $user->role()->name() + ] + ] + ]; + }, + 'submit' => function (string $id) { + $request = App::instance()->request(); + + $user = Find::user($id)->changeRole($request->get('role')); + + return [ + 'event' => 'user.changeRole', + 'user' => $user->toArray() + ]; + } + ], + + 'user.delete' => [ + 'pattern' => 'users/(:any)/delete', + 'load' => function (string $id) { + $user = Find::user($id); + $i18nPrefix = $user->isLoggedIn() ? 'account' : 'user'; + + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::template($i18nPrefix . '.delete.confirm', [ + 'email' => Escape::html($user->email()) + ]) + ] + ]; + }, + 'submit' => function (string $id) { + $user = Find::user($id); + $redirect = false; + $referrer = Panel::referrer(); + $url = $user->panel()->url(true); + + $user->delete(); + + // redirect to the users view + // if the dialog has been opened in the user view + if ($referrer === $url) { + $redirect = '/users'; + } + + // logout the user if they deleted themselves + if ($user->isLoggedIn()) { + $redirect = '/logout'; + } + + return [ + 'event' => 'user.delete', + 'redirect' => $redirect + ]; + } + ], + + 'user.fields' => [ + ...$fields['model'], + 'pattern' => '(users/[^/]+)/fields/(:any)/(:all?)', + ], + + 'user.file.changeName' => [ + ...$files['changeName'], + 'pattern' => '(users/[^/]+)/files/(:any)/changeName', + ], + + 'user.file.changeSort' => [ + ...$files['changeSort'], + 'pattern' => '(users/[^/]+)/files/(:any)/changeSort', + ], + + 'user.file.changeTemplate' => [ + ...$files['changeTemplate'], + 'pattern' => '(users/[^/]+)/files/(:any)/changeTemplate', + ], + + 'user.file.delete' => [ + ...$files['delete'], + 'pattern' => '(users/[^/]+)/files/(:any)/delete', + ], + + 'user.file.fields' => [ + ...$fields['file'], + 'pattern' => '(users/[^/]+)/files/(:any)/fields/(:any)/(:all?)', + ], + + 'user.totp.disable' => [ + 'pattern' => 'users/(:any)/totp/disable', + 'load' => fn (string $id) => (new UserTotpDisableDialog($id))->load(), + 'submit' => fn (string $id) => (new UserTotpDisableDialog($id))->submit() + ], +]; diff --git a/public/kirby/config/areas/users/drawers.php b/public/kirby/config/areas/users/drawers.php new file mode 100644 index 0000000..de1f9e8 --- /dev/null +++ b/public/kirby/config/areas/users/drawers.php @@ -0,0 +1,14 @@ + [ + ...$fields['model'], + 'pattern' => '(users/[^/]+)/fields/(:any)/(:all?)', + ], + 'user.file.fields' => [ + ...$fields['file'], + 'pattern' => '(users/[^/]+)/files/(:any)/fields/(:any)/(:all?)', + ], +]; diff --git a/public/kirby/config/areas/users/dropdowns.php b/public/kirby/config/areas/users/dropdowns.php new file mode 100644 index 0000000..6ab5556 --- /dev/null +++ b/public/kirby/config/areas/users/dropdowns.php @@ -0,0 +1,29 @@ + [ + 'pattern' => 'users/(:any)', + 'options' => fn (string $id) => + Find::user($id)->panel()->dropdown() + ], + 'user.languages' => [ + 'pattern' => 'users/(:any)/languages', + 'options' => function (string $id) { + $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 new file mode 100644 index 0000000..2e6431e --- /dev/null +++ b/public/kirby/config/areas/users/searches.php @@ -0,0 +1,12 @@ + [ + 'label' => I18n::translate('users'), + 'icon' => 'users', + '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 new file mode 100644 index 0000000..1f99eda --- /dev/null +++ b/public/kirby/config/areas/users/views.php @@ -0,0 +1,65 @@ + [ + 'pattern' => 'users', + 'action' => function () { + $kirby = App::instance(); + $role = $kirby->request()->get('role'); + $roles = $kirby->roles()->toArray(fn ($role) => [ + 'id' => $role->id(), + 'title' => $role->title(), + ]); + + return [ + 'component' => 'k-users-view', + 'props' => [ + 'buttons' => fn () => + ViewButtons::view('users') + ->defaults('create') + ->bind(['role' => $role]) + ->render(), + 'role' => function () use ($roles, $role) { + if ($role) { + return $roles[$role] ?? null; + } + }, + 'roles' => array_values($roles), + 'users' => function () use ($kirby, $role) { + $collector = new UsersCollector( + limit: 20, + page: $kirby->request()->get('page', 1), + role: $role, + sortBy: 'username asc', + ); + + $users = $collector->models(paginated: true); + + return [ + 'data' => $users->values(fn ($user) => (new UserItem(user: $user))->props()), + 'pagination' => $users->pagination()->toArray() + ]; + }, + ] + ]; + } + ], + 'user' => [ + 'pattern' => 'users/(:any)', + 'action' => function (string $id) { + return Find::user($id)->panel()->view(); + } + ], + 'user.file' => [ + 'pattern' => 'users/(:any)/files/(:any)', + 'action' => function (string $id, string $filename) { + return Find::file('users/' . $id, $filename)->panel()->view(); + } + ], +]; diff --git a/public/kirby/config/blocks/code/code.php b/public/kirby/config/blocks/code/code.php new file mode 100644 index 0000000..a7f88ff --- /dev/null +++ b/public/kirby/config/blocks/code/code.php @@ -0,0 +1,2 @@ + +
code()->html(false) ?>
diff --git a/public/kirby/config/blocks/code/code.yml b/public/kirby/config/blocks/code/code.yml new file mode 100644 index 0000000..b697784 --- /dev/null +++ b/public/kirby/config/blocks/code/code.yml @@ -0,0 +1,59 @@ +name: field.blocks.code.name +icon: code +wysiwyg: true +preview: code +fields: + code: + label: field.blocks.code.name + type: textarea + placeholder: field.blocks.code.placeholder + buttons: false + font: monospace + language: + label: field.blocks.code.language + type: select + default: text + options: + bash: Bash + basic: BASIC + c: C + clojure: Clojure + cpp: C++ + csharp: C# + css: CSS + diff: Diff + elixir: Elixir + elm: Elm + erlang: Erlang + go: Go + graphql: GraphQL + haskell: Haskell + html: HTML + java: Java + js: JavaScript + json: JSON + latext: LaTeX + less: Less + lisp: Lisp + lua: Lua + makefile: Makefile + markdown: Markdown + markup: Markup + objectivec: Objective-C + pascal: Pascal + perl: Perl + php: PHP + text: Plain Text + python: Python + r: R + ruby: Ruby + rust: Rust + sass: Sass + scss: SCSS + shell: Shell + sql: SQL + swift: Swift + typescript: TypeScript + vbnet: VB.net + xml: XML + yaml: YAML diff --git a/public/kirby/config/blocks/gallery/gallery.php b/public/kirby/config/blocks/gallery/gallery.php new file mode 100644 index 0000000..033575b --- /dev/null +++ b/public/kirby/config/blocks/gallery/gallery.php @@ -0,0 +1,20 @@ +caption(); +$crop = $block->crop()->isTrue(); +$ratio = $block->ratio()->or('auto'); +?> + $ratio, 'data-crop' => $crop], null, ' ') ?>> +
    + images()->toFiles() as $image): ?> +
  • + +
  • + +
+ isNotEmpty()): ?> +
+ +
+ + diff --git a/public/kirby/config/blocks/gallery/gallery.yml b/public/kirby/config/blocks/gallery/gallery.yml new file mode 100644 index 0000000..3c6aad9 --- /dev/null +++ b/public/kirby/config/blocks/gallery/gallery.yml @@ -0,0 +1,40 @@ +name: field.blocks.gallery.name +icon: dashboard +preview: gallery +fields: + images: + label: field.blocks.gallery.images.label + type: files + query: model.images + multiple: true + layout: cards + size: small + empty: field.blocks.gallery.images.empty + uploads: + template: blocks/image + image: + ratio: 1/1 + caption: + label: field.blocks.image.caption + type: writer + icon: text + inline: true + ratio: + label: field.blocks.image.ratio + type: select + placeholder: Auto + width: 1/2 + options: + 1/1: "1:1" + 16/9: "16:9" + 10/8: "10:8" + 21/9: "21:9" + 7/5: "7:5" + 4/3: "4:3" + 5/3: "5:3" + 3/2: "3:2" + 3/1: "3:1" + crop: + label: field.blocks.image.crop + type: toggle + width: 1/2 diff --git a/public/kirby/config/blocks/heading/heading.php b/public/kirby/config/blocks/heading/heading.php new file mode 100644 index 0000000..e864bbf --- /dev/null +++ b/public/kirby/config/blocks/heading/heading.php @@ -0,0 +1,2 @@ + +<level()->or('h2') ?>>text() ?>> diff --git a/public/kirby/config/blocks/heading/heading.yml b/public/kirby/config/blocks/heading/heading.yml new file mode 100644 index 0000000..f34fe53 --- /dev/null +++ b/public/kirby/config/blocks/heading/heading.yml @@ -0,0 +1,35 @@ +name: field.blocks.heading.name +icon: title +wysiwyg: true +preview: heading +fields: + level: + label: field.blocks.heading.level + type: toggles + empty: false + default: "h2" + labels: false + options: + - value: h1 + icon: h1 + text: H1 + - value: h2 + icon: h2 + text: H2 + - value: h3 + icon: h3 + text: H3 + - value: h4 + icon: h4 + text: H4 + - value: h5 + icon: h5 + text: H5 + - value: h6 + icon: h6 + text: H6 + text: + label: field.blocks.heading.text + type: writer + inline: true + placeholder: field.blocks.heading.placeholder diff --git a/public/kirby/config/blocks/image/image.php b/public/kirby/config/blocks/image/image.php new file mode 100644 index 0000000..1638744 --- /dev/null +++ b/public/kirby/config/blocks/image/image.php @@ -0,0 +1,35 @@ +alt(); +$caption = $block->caption(); +$crop = $block->crop()->isTrue(); +$link = $block->link(); +$ratio = $block->ratio()->or('auto'); +$src = null; + +if ($block->location() == 'web') { + $src = $block->src()->esc(); +} elseif ($image = $block->image()->toFile()) { + $alt = $alt->or($image->alt()); + $src = $image->url(); +} + +?> + + $ratio, 'data-crop' => $crop], null, ' ') ?>> + isNotEmpty()): ?> + + <?= $alt->esc() ?> + + + <?= $alt->esc() ?> + + + isNotEmpty()): ?> +
+ +
+ + + diff --git a/public/kirby/config/blocks/image/image.yml b/public/kirby/config/blocks/image/image.yml new file mode 100644 index 0000000..dc348a5 --- /dev/null +++ b/public/kirby/config/blocks/image/image.yml @@ -0,0 +1,61 @@ +name: field.blocks.image.name +icon: image +preview: image +fields: + location: + label: field.blocks.image.location + type: radio + columns: 2 + default: "kirby" + required: true + options: + kirby: "{{ t('field.blocks.image.location.internal') }}" + web: "{{ t('field.blocks.image.location.external') }}" + image: + label: field.blocks.image.name + type: files + query: model.images + multiple: false + image: + back: black + uploads: + template: blocks/image + when: + location: kirby + src: + label: field.blocks.image.url + type: url + when: + location: web + alt: + label: field.blocks.image.alt + type: text + icon: title + caption: + label: field.blocks.image.caption + type: writer + icon: text + inline: true + link: + label: field.blocks.image.link + type: text + icon: url + ratio: + label: field.blocks.image.ratio + type: select + placeholder: Auto + width: 1/2 + options: + 1/1: "1:1" + 16/9: "16:9" + 10/8: "10:8" + 21/9: "21:9" + 7/5: "7:5" + 4/3: "4:3" + 5/3: "5:3" + 3/2: "3:2" + 3/1: "3:1" + crop: + label: field.blocks.image.crop + type: toggle + width: 1/2 diff --git a/public/kirby/config/blocks/line/line.php b/public/kirby/config/blocks/line/line.php new file mode 100644 index 0000000..09d5649 --- /dev/null +++ b/public/kirby/config/blocks/line/line.php @@ -0,0 +1 @@ +
diff --git a/public/kirby/config/blocks/line/line.yml b/public/kirby/config/blocks/line/line.yml new file mode 100644 index 0000000..dcff956 --- /dev/null +++ b/public/kirby/config/blocks/line/line.yml @@ -0,0 +1,4 @@ +name: field.blocks.line.name +icon: divider +preview: line +wysiwyg: true diff --git a/public/kirby/config/blocks/list/list.php b/public/kirby/config/blocks/list/list.php new file mode 100644 index 0000000..012a156 --- /dev/null +++ b/public/kirby/config/blocks/list/list.php @@ -0,0 +1,2 @@ + +text(); diff --git a/public/kirby/config/blocks/list/list.yml b/public/kirby/config/blocks/list/list.yml new file mode 100644 index 0000000..ded7519 --- /dev/null +++ b/public/kirby/config/blocks/list/list.yml @@ -0,0 +1,8 @@ +name: field.blocks.list.name +icon: list-bullet +wysiwyg: true +preview: list +fields: + text: + label: field.blocks.list.name + type: list diff --git a/public/kirby/config/blocks/markdown/markdown.php b/public/kirby/config/blocks/markdown/markdown.php new file mode 100644 index 0000000..7ab685c --- /dev/null +++ b/public/kirby/config/blocks/markdown/markdown.php @@ -0,0 +1,2 @@ + +text()->kt(); diff --git a/public/kirby/config/blocks/markdown/markdown.yml b/public/kirby/config/blocks/markdown/markdown.yml new file mode 100644 index 0000000..cecafe4 --- /dev/null +++ b/public/kirby/config/blocks/markdown/markdown.yml @@ -0,0 +1,11 @@ +name: field.blocks.markdown.name +icon: markdown +preview: markdown +wysiwyg: true +fields: + text: + label: field.blocks.markdown.label + placeholder: field.blocks.markdown.placeholder + type: textarea + buttons: false + font: monospace diff --git a/public/kirby/config/blocks/quote/quote.php b/public/kirby/config/blocks/quote/quote.php new file mode 100644 index 0000000..6ec1290 --- /dev/null +++ b/public/kirby/config/blocks/quote/quote.php @@ -0,0 +1,9 @@ + +
+ text() ?> + citation()->isNotEmpty()): ?> +
+ citation() ?> +
+ +
diff --git a/public/kirby/config/blocks/quote/quote.yml b/public/kirby/config/blocks/quote/quote.yml new file mode 100644 index 0000000..b14e126 --- /dev/null +++ b/public/kirby/config/blocks/quote/quote.yml @@ -0,0 +1,17 @@ +name: field.blocks.quote.name +icon: quote +wysiwyg: true +preview: quote +fields: + text: + label: field.blocks.quote.text.label + placeholder: field.blocks.quote.text.placeholder + type: writer + inline: true + icon: quote + citation: + label: field.blocks.quote.citation.label + placeholder: field.blocks.quote.citation.placeholder + type: writer + inline: true + icon: user diff --git a/public/kirby/config/blocks/table/table.yml b/public/kirby/config/blocks/table/table.yml new file mode 100644 index 0000000..8e0d0b2 --- /dev/null +++ b/public/kirby/config/blocks/table/table.yml @@ -0,0 +1,3 @@ +name: Table +icon: menu +preview: table diff --git a/public/kirby/config/blocks/text/text.php b/public/kirby/config/blocks/text/text.php new file mode 100644 index 0000000..012a156 --- /dev/null +++ b/public/kirby/config/blocks/text/text.php @@ -0,0 +1,2 @@ + +text(); diff --git a/public/kirby/config/blocks/text/text.yml b/public/kirby/config/blocks/text/text.yml new file mode 100644 index 0000000..90117a5 --- /dev/null +++ b/public/kirby/config/blocks/text/text.yml @@ -0,0 +1,9 @@ +name: field.blocks.text.name +icon: text +wysiwyg: true +preview: text +fields: + text: + type: writer + nodes: false + placeholder: field.blocks.text.placeholder diff --git a/public/kirby/config/blocks/video/video.php b/public/kirby/config/blocks/video/video.php new file mode 100644 index 0000000..3e7ed65 --- /dev/null +++ b/public/kirby/config/blocks/video/video.php @@ -0,0 +1,32 @@ +caption(); + +if ( + $block->location() == 'kirby' && + $video = $block->video()->toFile() +) { + $url = $video->url(); + $attrs = array_filter([ + 'autoplay' => $block->autoplay()->toBool(), + 'controls' => $block->controls()->toBool(), + 'loop' => $block->loop()->toBool(), + 'muted' => $block->muted()->toBool() || $block->autoplay()->toBool(), + 'playsinline' => $block->autoplay()->toBool(), + 'poster' => $block->poster()->toFile()?->url(), + 'preload' => $block->preload()->value(), + ]); +} else { + $url = $block->url(); +} +?> + +
+ + isNotEmpty()): ?> +
+ +
+ diff --git a/public/kirby/config/blocks/video/video.yml b/public/kirby/config/blocks/video/video.yml new file mode 100644 index 0000000..b5fc104 --- /dev/null +++ b/public/kirby/config/blocks/video/video.yml @@ -0,0 +1,78 @@ +name: field.blocks.video.name +icon: video +preview: video +fields: + location: + label: field.blocks.video.location + type: radio + columns: 2 + default: "web" + options: + kirby: "{{ t('field.blocks.image.location.internal') }}" + web: "{{ t('field.blocks.image.location.external') }}" + url: + label: field.blocks.video.url.label + type: url + placeholder: field.blocks.video.url.placeholder + when: + location: web + video: + label: field.blocks.video.name + type: files + query: model.videos + multiple: false + # you might want to add a template for videos here + when: + location: kirby + poster: + label: field.blocks.video.poster + type: files + query: model.images + multiple: false + image: + back: black + uploads: + template: blocks/image + when: + location: kirby + caption: + label: field.blocks.video.caption + type: writer + inline: true + autoplay: + label: field.blocks.video.autoplay + type: toggle + width: 1/3 + when: + location: kirby + muted: + label: field.blocks.video.muted + type: toggle + width: 1/3 + default: true + when: + location: kirby + loop: + label: field.blocks.video.loop + type: toggle + width: 1/3 + when: + location: kirby + controls: + label: field.blocks.video.controls + type: toggle + width: 1/3 + default: true + when: + location: kirby + preload: + label: field.blocks.video.preload + type: select + width: 2/3 + default: auto + options: + - auto + - metadata + - none + when: + location: kirby diff --git a/public/kirby/config/components.php b/public/kirby/config/components.php new file mode 100644 index 0000000..012e084 --- /dev/null +++ b/public/kirby/config/components.php @@ -0,0 +1,446 @@ + fn (App $kirby, string $url, $options = null): string => $url, + + /** + * Add your own email provider + */ + 'email' => function ( + App $kirby, + array $props = [], + bool $debug = false + ) { + return new Emailer($props, $debug); + }, + + /** + * Modify URLs for file objects + * + * @param \Kirby\Cms\File $file The original file object + */ + 'file::url' => function ( + App $kirby, + File $file + ): string { + return $file->mediaUrl(); + }, + + /** + * Adapt file characteristics + * + * @param array $options All thumb options (width, height, crop, blur, grayscale) + */ + 'file::version' => function ( + App $kirby, + 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 = $file->mediaDir(); + $template = $mediaRoot . '/{{ name }}{{ attributes }}.{{ extension }}'; + $thumbRoot = (new Filename($file->root(), $template, $options))->toString(); + $thumbName = basename($thumbRoot); + + // check if the thumb already exists + if (file_exists($thumbRoot) === false) { + // if not, create job file + $job = $mediaRoot . '/.jobs/' . $thumbName . '.json'; + + try { + Data::write( + $job, + [...$options, 'filename' => $file->filename()] + ); + } catch (Throwable) { + // if thumb doesn't exist yet and job file cannot + // be created, return + return $file; + } + } + + return new FileVersion([ + 'modifications' => $options, + 'original' => $file, + 'root' => $thumbRoot, + 'url' => $file->mediaUrl($thumbName), + ]); + }, + + /** + * Used by the `js()` helper + * + * @param string $url Relative or absolute URL + * @param string|array $options An array of attributes for the link tag or a media attribute string + */ + 'js' => fn (App $kirby, string $url, $options = null): string => $url, + + /** + * Add your own Markdown parser + * + * @param string $text Text to parse + * @param array $options Markdown options + */ + 'markdown' => function ( + App $kirby, + string|null $text = null, + array $options = [] + ): string { + static $markdown; + static $config; + + // if the config options have changed or the component is called for the first time, + // (re-)initialize the parser object + if ($config !== $options) { + $markdown = new Markdown($options); + $config = $options; + } + + return $markdown->parse($text, $options['inline'] ?? false); + }, + + /** + * Add your own search engine + * + * @param \Kirby\Cms\Collection $collection Collection of searchable models + */ + 'search' => function ( + App $kirby, + Collection $collection, + string|null $query = null, + string|array $params = [] + ): Collection { + if (is_string($params) === true) { + $params = ['fields' => Str::split($params, '|')]; + } + + $collection = clone $collection; + $query = trim($query ?? ''); + $options = [ + 'fields' => [], + 'minlength' => 2, + 'score' => [], + 'words' => false, + ...$params + ]; + + // empty or too short search query + if (Str::length($query) < $options['minlength']) { + return $collection->limit(0); + } + + $words = preg_replace('/(\s)/u', ',', $query); + $words = Str::split($words, ',', $options['minlength']); + + if (empty($options['stopwords']) === false) { + $words = array_diff($words, $options['stopwords']); + } + + // returns an empty collection if there is no search word + if (empty($words) === true) { + return $collection->limit(0); + } + + $words = A::map( + $words, + fn ($value) => Str::wrap(preg_quote($value), $options['words'] ? '\b' : '') + ); + + $exact = preg_quote($query); + + if ($options['words']) { + $exact = '(\b' . $exact . '\b)'; + } + + $query = Str::lower($query); + $preg = '!(' . implode('|', $words) . ')!iu'; + $scores = []; + + $results = $collection->filter(function ($item) use ($query, $exact, $preg, $options, &$scores) { + $data = $item->content()->toArray(); + $keys = array_keys($data); + $keys[] = 'id'; + + if ($item instanceof User) { + $keys[] = 'name'; + $keys[] = 'email'; + $keys[] = 'role'; + } elseif ($item instanceof Page) { + // apply the default score for pages + $options['score'] = [ + 'id' => 64, + 'title' => 64, + ...$options['score'] + ]; + } + + if (empty($options['fields']) === false) { + $fields = array_map('strtolower', $options['fields']); + $keys = array_intersect($keys, $fields); + } + + $scoring = [ + 'hits' => 0, + 'score' => 0 + ]; + + foreach ($keys as $key) { + $score = $options['score'][$key] ?? 1; + $value = $data[$key] ?? (string)$item->$key(); + + $lowerValue = Str::lower($value); + + // check for exact matches + if ($query == $lowerValue) { + $scoring['score'] += 16 * $score; + $scoring['hits'] += 1; + + // check for exact beginning matches + } elseif ( + $options['words'] === false && + Str::startsWith($lowerValue, $query) === true + ) { + $scoring['score'] += 8 * $score; + $scoring['hits'] += 1; + + // check for exact query matches + } elseif ($matches = preg_match_all('!' . $exact . '!ui', $value, $r)) { + $scoring['score'] += 2 * $score; + $scoring['hits'] += $matches; + } + + // check for any match + if ($matches = preg_match_all($preg, $value, $r)) { + $scoring['score'] += $matches * $score; + $scoring['hits'] += $matches; + } + } + + $scores[$item->id()] = $scoring; + + return $scoring['hits'] > 0; + }); + + return $results->sort( + fn ($item) => $scores[$item->id()]['score'], + 'desc' + ); + }, + + /** + * Add your own session store + */ + 'session::store' => function (App $kirby): string|SessionStore { + return $kirby->root('sessions'); + }, + + /** + * Add your own SmartyPants parser + * + * @param string $text Text to parse + * @param array $options SmartyPants options + */ + 'smartypants' => function ( + App $kirby, + string|null $text = null, + array $options = [] + ): string { + static $smartypants; + static $config; + + // if the config options have changed or the component is called for the first time, + // (re-)initialize the parser object + if ($config !== $options) { + $smartypants = new Smartypants($options); + $config = $options; + } + + return $smartypants->parse($text); + }, + + /** + * Add your own snippet loader + * + * @param string|array $name Snippet name + * @param array $data Data array for the snippet + */ + 'snippet' => function ( + App $kirby, + string|array|null $name, + array $data = [], + bool $slots = false + ): Snippet|string { + 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 + * + * @param string $name Template name + * @param string $type Extension type + * @param string $defaultType Default extension type + * @return \Kirby\Template\Template + */ + 'template' => function ( + App $kirby, + string $name, + string $type = 'html', + string $defaultType = 'html' + ) { + return new Template($name, $type, $defaultType); + }, + + /** + * Add your own thumb generator + * + * @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` + */ + 'thumb' => function ( + App $kirby, + string $src, + string $dst, + array $options + ): string { + $darkroom = Darkroom::factory( + $kirby->option('thumbs.driver', 'gd'), + $kirby->option('thumbs', []) + ); + $options = $darkroom->preprocess($src, $options); + $root = (new Filename($src, $dst, $options))->toString(); + + F::copy($src, $root, true); + $darkroom->process($root, $options); + + return $root; + }, + + /** + * Modify all URLs + * + * @param string|null $path URL path + * @param array|string|null $options Array of options for the Uri class + * @throws \Kirby\Exception\NotFoundException If an invalid UUID was passed + */ + 'url' => function ( + App $kirby, + string|null $path = null, + $options = null + ): string { + $language = null; + + // get language from simple string option + if (is_string($options) === true) { + $language = $options; + $options = null; + } + + // get language from array + if (is_array($options) === true && isset($options['language']) === true) { + $language = $options['language']; + unset($options['language']); + } + + // get a language url for the linked page, if the page can be found + if ($kirby->multilang() === true) { + $parts = Str::split($path, '#'); + + if ($parts[0] ?? null) { + $page = $kirby->site()->find($parts[0]); + } else { + $page = $kirby->site()->page(); + } + + if ($page) { + $path = $page->url($language); + + if (isset($parts[1]) === true) { + $path .= '#' . $parts[1]; + } + } + } + + // keep relative urls + if ( + $path !== null && + (str_starts_with($path, './') || str_starts_with($path, '../')) + ) { + return $path; + } + + // support UUIDs + if ( + $path !== null && + Uuid::is($path, ['page', 'file']) === true + ) { + $model = Uuid::for($path)->model(); + + if ($model === null) { + throw new NotFoundException( + message: 'The model could not be found for "' . $path . '" uuid' + ); + } + + $path = $model->url(); + } + + $url = Url::makeAbsolute($path, $kirby->url()); + + if ($options === null) { + return $url; + } + + return (new Uri($url, $options))->toString(); + }, + +]; diff --git a/public/kirby/config/fields/checkboxes.php b/public/kirby/config/fields/checkboxes.php new file mode 100644 index 0000000..00a94b8 --- /dev/null +++ b/public/kirby/config/fields/checkboxes.php @@ -0,0 +1,61 @@ + ['min', 'options'], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Arranges the checkboxes in the given number of columns + */ + 'columns' => function (int $columns = 1) { + return $columns; + }, + /** + * Default value for the field, which will be used when a page/file/user is created + */ + 'default' => function ($default = null) { + return Str::split($default, ','); + }, + /** + * Maximum number of checked boxes + */ + 'max' => function (int|null $max = null) { + return $max; + }, + /** + * Minimum number of checked boxes + */ + 'min' => function (int|null $min = null) { + return $min; + }, + 'value' => function ($value = null) { + return Str::split($value, ','); + }, + ], + 'computed' => [ + 'default' => function () { + return $this->sanitizeOptions($this->default); + }, + 'value' => function () { + return $this->sanitizeOptions($this->value); + }, + ], + 'save' => function ($value): string { + return A::join($value, ', '); + }, + 'validations' => [ + 'options', + 'max', + 'min' + ] +]; diff --git a/public/kirby/config/fields/color.php b/public/kirby/config/fields/color.php new file mode 100644 index 0000000..7daa027 --- /dev/null +++ b/public/kirby/config/fields/color.php @@ -0,0 +1,153 @@ + [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + + /** + * Whether to allow alpha transparency in the color + */ + 'alpha' => function (bool $alpha = false) { + return $alpha; + }, + /** + * The CSS format (hex, rgb, hsl) to display and store the value + */ + 'format' => function (string $format = 'hex'): string { + if (in_array($format, ['hex', 'hsl', 'rgb'], true) === false) { + throw new InvalidArgumentException( + message: 'Unsupported format for color field (supported: hex, rgb, hsl)' + ); + } + + return $format; + }, + /** + * Change mode to disable the color picker (`input`) or to only + * show the `options` as toggles + */ + 'mode' => function (string $mode = 'picker'): string { + if (in_array($mode, ['picker', 'input', 'options'], true) === false) { + throw new InvalidArgumentException( + message: 'Unsupported mode for color field (supported: picker, input, options)' + ); + } + + return $mode; + }, + /** + * List of colors that will be shown as buttons + * to directly select them + */ + 'options' => function (array $options = []): array { + return $options; + } + ], + 'computed' => [ + 'default' => function (): string { + return Str::lower($this->default); + }, + 'options' => function (): array { + // resolve options to support manual arrays + // alongside api and query options + $props = FieldOptions::polyfill($this->props); + $options = FieldOptions::factory([ + 'text' => '{{ item.value }}', + 'value' => '{{ item.key }}', + ...$props['options'] + ]); + + $options = $options->render($this->model()); + + if (empty($options) === true) { + return []; + } + + if ( + is_numeric($options[0]['value']) || + $options[0]['value'] === $options[0]['text'] + ) { + // simple array of values + // or value=text (from Options class) + $options = A::map($options, fn ($option) => [ + 'value' => $option['text'] + ]); + + } elseif ($this->isColor($options[0]['text'])) { + // @deprecated 4.0.0 + // TODO: Remove in Kirby 6 + + 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; + } + ], + 'methods' => [ + 'isColor' => function (string $value): bool { + return + $this->isHex($value) || + $this->isRgb($value) || + $this->isHsl($value); + }, + 'isHex' => function (string $value): bool { + return preg_match('/^#([\da-f]{3,4}){1,2}$/i', $value) === 1; + }, + 'isHsl' => function (string $value): bool { + return preg_match('/^hsla?\(\s*(\d{1,3}\.?\d*)(deg|rad|grad|turn)?(?:,|\s)+(\d{1,3})%(?:,|\s)+(\d{1,3})%(?:,|\s|\/)*(\d*(?:\.\d+)?)(%?)\s*\)?$/i', $value) === 1; + }, + 'isRgb' => function (string $value): bool { + return preg_match('/^rgba?\(\s*(\d{1,3})(%?)(?:,|\s)+(\d{1,3})(%?)(?:,|\s)+(\d{1,3})(%?)(?:,|\s|\/)*(\d*(?:\.\d+)?)(%?)\s*\)?$/i', $value) === 1; + }, + ], + 'validations' => [ + 'color' => function ($value) { + if (empty($value) === true) { + return true; + } + + if ($this->format === 'hex' && $this->isHex($value) === false) { + 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'] + ); + } + + if ($this->format === 'hsl' && $this->isHsl($value) === false) { + 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 new file mode 100644 index 0000000..845b7cc --- /dev/null +++ b/public/kirby/config/fields/date.php @@ -0,0 +1,154 @@ + ['datetime'], + 'props' => [ + /** + * Unset inherited props + */ + 'placeholder' => null, + + /** + * Activate/deactivate the dropdown calendar + */ + 'calendar' => function (bool $calendar = true) { + return $calendar; + }, + + /** + * Default date when a new page/file/user gets created + */ + 'default' => function (string|null $default = null): string { + return $this->toDatetime($default) ?? ''; + }, + + /** + * Custom format (dayjs tokens: `DD`, `MM`, `YYYY`) that is + * used to display the field in the Panel + */ + 'display' => function ($display = 'YYYY-MM-DD') { + return I18n::translate($display, $display); + }, + + /** + * Changes the calendar icon to something custom + */ + 'icon' => function (string $icon = 'calendar') { + return $icon; + }, + + /** + * Latest date, which can be selected/saved (Y-m-d) + */ + 'max' => function (string|null $max = null): string|null { + return Date::optional($max); + }, + /** + * Earliest date, which can be selected/saved (Y-m-d) + */ + 'min' => function (string|null $min = null): string|null { + return Date::optional($min); + }, + + /** + * Round to the nearest: sub-options for `unit` (day) and `size` (1) + */ + 'step' => function ($step = null) { + return $step; + }, + + /** + * Pass `true` or an array of time field options to show the time selector. + */ + 'time' => function ($time = false) { + return $time; + }, + /** + * Must be a parseable date string + */ + 'value' => function ($value = null) { + return $value; + } + ], + 'computed' => [ + 'display' => function () { + if ($this->display) { + return Str::upper($this->display); + } + }, + 'format' => function () { + return $this->props['format'] ?? ($this->time === false ? 'Y-m-d' : 'Y-m-d H:i:s'); + }, + 'time' => function () { + if ($this->time === false) { + return false; + } + + $props = is_array($this->time) ? $this->time : []; + $props['model'] = $this->model(); + $field = new Field('time', $props); + return $field->toArray(); + }, + 'step' => function () { + if ($this->time === false || empty($this->time['step']) === true) { + return Date::stepConfig($this->step, [ + 'size' => 1, + 'unit' => 'day' + ]); + } + + return Date::stepConfig($this->time['step'], [ + 'size' => 5, + 'unit' => 'minute' + ]); + }, + 'value' => function (): string { + return $this->toDatetime($this->value) ?? ''; + }, + ], + 'validations' => [ + 'date', + 'minMax' => function ($value) { + if (!$value = Date::optional($value)) { + return true; + } + + $min = Date::optional($this->min); + $max = Date::optional($this->max); + + $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: [ + 'min' => $min->format($format), + 'max' => $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/email.php b/public/kirby/config/fields/email.php new file mode 100644 index 0000000..5c4630f --- /dev/null +++ b/public/kirby/config/fields/email.php @@ -0,0 +1,40 @@ + 'text', + 'props' => [ + /** + * Unset inherited props + */ + 'converter' => null, + 'counter' => null, + + /** + * Sets the HTML5 autocomplete mode for the input + */ + 'autocomplete' => function (string $autocomplete = 'email') { + return $autocomplete; + }, + + /** + * Changes the email icon to something custom + */ + 'icon' => function (string $icon = 'email') { + return $icon; + }, + + /** + * Custom placeholder text, when the field is empty. + */ + 'placeholder' => function ($value = null) { + return I18n::translate($value, $value) ?? I18n::translate('email.placeholder'); + } + ], + 'validations' => [ + 'minlength', + 'maxlength', + 'email' + ] +]; diff --git a/public/kirby/config/fields/files.php b/public/kirby/config/fields/files.php new file mode 100644 index 0000000..4f37765 --- /dev/null +++ b/public/kirby/config/fields/files.php @@ -0,0 +1,141 @@ + [ + 'filepicker', + 'layout', + 'min', + 'picker', + 'upload' + ], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'autofocus' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Sets the file(s), which are selected by default when a new page is created + */ + 'default' => function ($default = null) { + return $default; + }, + + 'value' => function ($value = null) { + return $value; + } + ], + 'computed' => [ + 'parentModel' => function () { + if ( + is_string($this->parent) === true && + $model = $this->model()->query( + $this->parent, + ModelWithContent::class + ) + ) { + return $model; + } + + return $this->model(); + }, + 'parent' => function () { + return $this->parentModel->apiUrl(true); + }, + 'query' => function () { + return $this->query ?? $this->parentModel::CLASS_ALIAS . '.files'; + }, + 'default' => function () { + return $this->toFiles($this->default); + }, + 'value' => function () { + return $this->toFiles($this->value); + }, + ], + 'methods' => [ + 'fileResponse' => function ($file) { + return $file->panel()->pickerData([ + 'image' => $this->image, + 'info' => $this->info ?? false, + 'layout' => $this->layout, + 'model' => $this->model(), + 'text' => $this->text, + ]); + }, + 'toFiles' => function ($value = null) { + $files = []; + + foreach (Data::decode($value, 'yaml') as $id) { + if (is_array($id) === true) { + $id = $id['uuid'] ?? $id['id'] ?? null; + } + + if ( + $id !== null && + ($file = $this->kirby()->file($id, $this->model())) + ) { + $files[] = $this->fileResponse($file); + } + } + + return $files; + } + ], + 'api' => function () { + return [ + [ + 'pattern' => '/', + 'action' => function () { + $field = $this->field(); + + return $field->filepicker([ + 'image' => $field->image(), + 'info' => $field->info(), + 'layout' => $field->layout(), + 'limit' => $field->limit(), + 'page' => $this->requestQuery('page'), + 'query' => $field->query(), + 'search' => $this->requestQuery('search'), + 'text' => $field->text() + ]); + } + ], + [ + 'pattern' => 'upload', + 'method' => 'POST', + 'action' => function () { + $field = $this->field(); + $uploads = $field->uploads(); + + // move_uploaded_file() not working with unit test + // @codeCoverageIgnoreStart + return $field->upload($this, $uploads, function ($file, $parent) use ($field) { + return $file->panel()->pickerData([ + 'image' => $field->image(), + 'info' => $field->info(), + 'layout' => $field->layout(), + 'model' => $field->model(), + 'text' => $field->text(), + ]); + }); + // @codeCoverageIgnoreEnd + } + ] + ]; + }, + 'save' => function ($value = null) { + return A::pluck($value, $this->store); + }, + 'validations' => [ + 'max', + 'min' + ] +]; diff --git a/public/kirby/config/fields/gap.php b/public/kirby/config/fields/gap.php new file mode 100644 index 0000000..b2dbd70 --- /dev/null +++ b/public/kirby/config/fields/gap.php @@ -0,0 +1,5 @@ + false +]; diff --git a/public/kirby/config/fields/headline.php b/public/kirby/config/fields/headline.php new file mode 100644 index 0000000..3a4509e --- /dev/null +++ b/public/kirby/config/fields/headline.php @@ -0,0 +1,19 @@ + false, + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'autofocus' => null, + 'before' => null, + 'default' => null, + 'disabled' => null, + 'icon' => null, + 'placeholder' => null, + 'required' => null, + 'translate' => null + ] +]; diff --git a/public/kirby/config/fields/hidden.php b/public/kirby/config/fields/hidden.php new file mode 100644 index 0000000..4b40df5 --- /dev/null +++ b/public/kirby/config/fields/hidden.php @@ -0,0 +1,5 @@ + true +]; diff --git a/public/kirby/config/fields/info.php b/public/kirby/config/fields/info.php new file mode 100644 index 0000000..57907a2 --- /dev/null +++ b/public/kirby/config/fields/info.php @@ -0,0 +1,43 @@ + [ + /** + * Unset inherited props + */ + 'after' => null, + 'autofocus' => null, + 'before' => null, + 'default' => null, + 'disabled' => null, + 'placeholder' => null, + 'required' => null, + 'translate' => null, + + /** + * Text to be displayed + */ + 'text' => function ($value = null) { + return I18n::translate($value, $value); + }, + + /** + * Change the design of the info box + */ + 'theme' => function (string|null $theme = null) { + return $theme; + } + ], + 'computed' => [ + 'text' => function () { + if ($text = $this->text) { + $text = $this->model()->toSafeString($text); + $text = $this->kirby()->kirbytext($text); + return $text; + } + } + ], + 'save' => false, +]; diff --git a/public/kirby/config/fields/line.php b/public/kirby/config/fields/line.php new file mode 100644 index 0000000..b2dbd70 --- /dev/null +++ b/public/kirby/config/fields/line.php @@ -0,0 +1,5 @@ + false +]; diff --git a/public/kirby/config/fields/link.php b/public/kirby/config/fields/link.php new file mode 100644 index 0000000..7cab839 --- /dev/null +++ b/public/kirby/config/fields/link.php @@ -0,0 +1,172 @@ + [ + 'after' => null, + 'before' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * @values 'anchor', 'url, 'page, 'file', 'email', 'tel', 'custom' + */ + 'options' => function (array|null $options = null): array { + // default options + if ($options === null) { + return [ + 'url', + 'page', + 'file', + 'email', + 'tel', + 'anchor' + ]; + } + + // validate options + $available = array_keys($this->availableTypes()); + + if ($unavailable = array_diff($options, $available)) { + throw new InvalidArgumentException([ + 'key' => 'field.link.options', + 'data' => ['options' => implode(', ', $unavailable)] + ]); + } + + return $options; + }, + 'value' => function (string|null $value = null) { + return $value ?? ''; + } + ], + 'methods' => [ + 'activeTypes' => function () { + return array_filter( + $this->availableTypes(), + fn (string $type) => in_array($type, $this->props['options'], true), + ARRAY_FILTER_USE_KEY + ); + }, + 'availableTypes' => function () { + return [ + 'anchor' => [ + 'detect' => function (string $value): bool { + return Str::startsWith($value, '#') === true; + }, + 'link' => function (string $value): string { + return $value; + }, + 'validate' => function (string $value): bool { + return Str::startsWith($value, '#') === true; + }, + ], + 'email' => [ + 'detect' => function (string $value): bool { + return Str::startsWith($value, 'mailto:') === true; + }, + 'link' => function (string $value): string { + return str_replace('mailto:', '', $value); + }, + 'validate' => function (string $value): bool { + return V::email($value); + }, + ], + 'file' => [ + 'detect' => function (string $value): bool { + return Str::startsWith($value, 'file://') === true; + }, + 'link' => function (string $value): string { + return $value; + }, + 'validate' => function (string $value): bool { + return V::uuid($value, 'file'); + }, + ], + 'page' => [ + 'detect' => function (string $value): bool { + return Str::startsWith($value, 'page://') === true; + }, + 'link' => function (string $value): string { + return $value; + }, + 'validate' => function (string $value): bool { + return V::uuid($value, 'page'); + }, + ], + 'tel' => [ + 'detect' => function (string $value): bool { + return Str::startsWith($value, 'tel:') === true; + }, + 'link' => function (string $value): string { + return str_replace('tel:', '', $value); + }, + 'validate' => function (string $value): bool { + return V::tel($value); + }, + ], + 'url' => [ + 'detect' => function (string $value): bool { + return Str::startsWith($value, 'http://') === true || Str::startsWith($value, 'https://') === true; + }, + 'link' => function (string $value): string { + return $value; + }, + 'validate' => function (string $value): bool { + return V::url($value); + }, + ], + + // needs to come last + 'custom' => [ + 'detect' => function (string $value): bool { + return true; + }, + 'link' => function (string $value): string { + return $value; + }, + 'validate' => function (): bool { + return true; + }, + ] + ]; + }, + ], + 'validations' => [ + 'value' => function (string|null $value) { + if (empty($value) === true) { + return true; + } + + $detected = false; + + foreach ($this->activeTypes() as $type => $options) { + if ($options['detect']($value) !== true) { + continue; + } + + $link = $options['link']($value); + $detected = true; + + if ($options['validate']($link) === false) { + throw new InvalidArgumentException( + key: 'validation.' . $type + ); + } + } + + // none of the configured types has been detected + if ($detected === false) { + throw new InvalidArgumentException( + key: 'validation.linkType' + ); + } + + return true; + }, + ] +]; diff --git a/public/kirby/config/fields/list.php b/public/kirby/config/fields/list.php new file mode 100644 index 0000000..d1917f2 --- /dev/null +++ b/public/kirby/config/fields/list.php @@ -0,0 +1,23 @@ + [ + /** + * Sets the allowed HTML formats. Available formats: `bold`, `italic`, `underline`, `strike`, `code`, `link`. Activate them all by passing `true`. Deactivate them all by passing `false` + */ + 'marks' => function ($marks = true) { + return $marks; + }, + /** + * Sets the allowed nodes. Available nodes: `bulletList`, `orderedList` + */ + 'nodes' => function ($nodes = null) { + return $nodes; + } + ], + 'computed' => [ + 'value' => function () { + return trim($this->value ?? ''); + } + ] +]; diff --git a/public/kirby/config/fields/mixins/datetime.php b/public/kirby/config/fields/mixins/datetime.php new file mode 100644 index 0000000..8d43d2a --- /dev/null +++ b/public/kirby/config/fields/mixins/datetime.php @@ -0,0 +1,35 @@ + [ + /** + * Defines a custom format that is used when the field is saved + */ + 'format' => function (string|null $format = null) { + return $format; + } + ], + 'methods' => [ + 'toDatetime' => function ($value, string $format = 'Y-m-d H:i:s') { + if ($date = Date::optional($value)) { + if ($this->step) { + $step = Date::stepConfig($this->step); + $date->round($step['unit'], $step['size']); + } + + return $date->format($format); + } + + return null; + } + ], + 'save' => function ($value) { + if ($date = Date::optional($value)) { + return $date->format($this->format); + } + + return ''; + }, +]; diff --git a/public/kirby/config/fields/mixins/filepicker.php b/public/kirby/config/fields/mixins/filepicker.php new file mode 100644 index 0000000..092adc9 --- /dev/null +++ b/public/kirby/config/fields/mixins/filepicker.php @@ -0,0 +1,14 @@ + [ + 'filepicker' => function (array $params = []) { + // fetch the parent model + $params['model'] = $this->model(); + + return (new FilePicker($params))->toArray(); + } + ] +]; diff --git a/public/kirby/config/fields/mixins/layout.php b/public/kirby/config/fields/mixins/layout.php new file mode 100644 index 0000000..a3ee027 --- /dev/null +++ b/public/kirby/config/fields/mixins/layout.php @@ -0,0 +1,24 @@ + [ + /** + * Changes the layout of the selected entries. + * Available layouts: `list`, `cardlets`, `cards` + */ + 'layout' => function (string $layout = 'list') { + return match ($layout) { + 'cards' => 'cards', + 'cardlets' => 'cardlets', + default => 'list' + }; + }, + + /** + * Layout size for cards: `tiny`, `small`, `medium`, `large`, `huge`, `full` + */ + 'size' => function (string $size = 'auto') { + return $size; + }, + ] +]; diff --git a/public/kirby/config/fields/mixins/min.php b/public/kirby/config/fields/mixins/min.php new file mode 100644 index 0000000..f5262ea --- /dev/null +++ b/public/kirby/config/fields/mixins/min.php @@ -0,0 +1,22 @@ + [ + 'min' => function () { + // set min to at least 1, if required + if ($this->required === true) { + return $this->min ?? 1; + } + + return $this->min; + }, + 'required' => function () { + // set required to true if min is set + if ($this->min) { + return true; + } + + return $this->required; + } + ] +]; diff --git a/public/kirby/config/fields/mixins/options.php b/public/kirby/config/fields/mixins/options.php new file mode 100644 index 0000000..0f99ea4 --- /dev/null +++ b/public/kirby/config/fields/mixins/options.php @@ -0,0 +1,47 @@ + [ + /** + * API settings for options requests. This will only take affect when `options` is set to `api`. + */ + 'api' => function ($api = null) { + return $api; + }, + /** + * An array with options + */ + 'options' => function ($options = []) { + return $options; + }, + /** + * Query settings for options queries. This will only take affect when `options` is set to `query`. + */ + 'query' => function ($query = null) { + return $query; + }, + ], + 'computed' => [ + 'options' => function (): array { + return $this->getOptions(); + } + ], + 'methods' => [ + 'getOptions' => function () { + $props = FieldOptions::polyfill($this->props); + $options = FieldOptions::factory($props['options']); + return $options->render($this->model()); + }, + 'sanitizeOption' => function ($value) { + $options = array_column($this->options(), 'value'); + return in_array($value, $options) ? $value : null; + }, + 'sanitizeOptions' => function ($values) { + $options = array_column($this->options(), 'value'); + $options = array_intersect($values, $options); + return array_values($options); + }, + ] +]; diff --git a/public/kirby/config/fields/mixins/pagepicker.php b/public/kirby/config/fields/mixins/pagepicker.php new file mode 100644 index 0000000..276d8c7 --- /dev/null +++ b/public/kirby/config/fields/mixins/pagepicker.php @@ -0,0 +1,14 @@ + [ + 'pagepicker' => function (array $params = []) { + // inject the current model + $params['model'] = $this->model(); + + return (new PagePicker($params))->toArray(); + } + ] +]; diff --git a/public/kirby/config/fields/mixins/picker.php b/public/kirby/config/fields/mixins/picker.php new file mode 100644 index 0000000..0e4c5b8 --- /dev/null +++ b/public/kirby/config/fields/mixins/picker.php @@ -0,0 +1,93 @@ + [ + /** + * The placeholder text if none have been selected yet + */ + 'empty' => function ($empty = null) { + return I18n::translate($empty, $empty); + }, + + /** + * Image settings for each item + */ + 'image' => function ($image = null) { + return $image; + }, + + /** + * Info text for each item + */ + 'info' => function (string|null $info = null) { + return $info; + }, + + /** + * Whether each item should be clickable + */ + 'link' => function (bool $link = true) { + return $link; + }, + + /** + * The minimum number of required selected + */ + 'min' => function (int|null $min = null) { + return $min; + }, + + /** + * The maximum number of allowed selected + */ + 'max' => function (int|null $max = null) { + return $max; + }, + + /** + * If `false`, only a single one can be selected + */ + 'multiple' => function (bool $multiple = true) { + return $multiple; + }, + + /** + * Query for the items to be included in the picker + */ + 'query' => function (string|null $query = null) { + return $query; + }, + + /** + * Enable/disable the search field in the picker + */ + 'search' => function (bool $search = true) { + return $search; + }, + + /** + * Whether to store UUID or ID in the + * content file of the model + * + * @param string $store 'uuid'|'id' + */ + 'store' => function (string $store = 'uuid') { + // fall back to ID, if UUIDs globally disabled + return match (Uuids::enabled()) { + false => 'id', + default => Str::lower($store) + }; + }, + + /** + * Main text for each item + */ + 'text' => function (string|null $text = null) { + return $text; + }, + ], +]; diff --git a/public/kirby/config/fields/mixins/upload.php b/public/kirby/config/fields/mixins/upload.php new file mode 100644 index 0000000..7cfcf5d --- /dev/null +++ b/public/kirby/config/fields/mixins/upload.php @@ -0,0 +1,97 @@ + [ + /** + * Sets the upload options for linked files (since 3.2.0) + */ + 'uploads' => function ($uploads = []) { + if ($uploads === false) { + return false; + } + + if (is_string($uploads) === true) { + $uploads = ['template' => $uploads]; + } + + if (is_array($uploads) === false) { + $uploads = []; + } + + $uploads['accept'] = '*'; + + if ($preview = $this->image) { + $uploads['preview'] = $preview; + } + + if ($template = $uploads['template'] ?? null) { + // get parent object for upload target + $parent = $this->uploadParent($uploads['parent'] ?? null); + + if ($parent === null) { + throw new InvalidArgumentException( + message: '"' . $uploads['parent'] . '" could not be resolved as a valid parent for the upload' + ); + } + + $file = new File([ + 'filename' => 'tmp', + 'parent' => $parent, + 'template' => $template + ]); + + $uploads['accept'] = $file->blueprint()->acceptAttribute(); + } + + return $uploads; + }, + ], + 'methods' => [ + 'upload' => function (Api $api, $params, Closure $map) { + if ($params === false) { + throw new Exception( + message: 'Uploads are disabled for this field' + ); + } + + $parent = $this->uploadParent($params['parent'] ?? null); + + return $api->upload(function ($source, $filename) use ($parent, $params, $map) { + $props = [ + 'source' => $source, + 'template' => $params['template'] ?? null, + 'filename' => $filename, + ]; + + // move the source file from the temp dir + $file = $parent->createFile($props, true); + + if ($file instanceof File === false) { + throw new Exception( + message: 'The file could not be uploaded' + ); + } + + return $map($file, $parent); + }); + }, + 'uploadParent' => function (string|null $parentQuery = null) { + $parent = $this->model(); + + if ($parentQuery) { + $parent = $parent->query($parentQuery); + } + + if ($parent instanceof File) { + $parent = $parent->parent(); + } + + return $parent; + } + ] +]; diff --git a/public/kirby/config/fields/mixins/userpicker.php b/public/kirby/config/fields/mixins/userpicker.php new file mode 100644 index 0000000..4f8556c --- /dev/null +++ b/public/kirby/config/fields/mixins/userpicker.php @@ -0,0 +1,13 @@ + [ + 'userpicker' => function (array $params = []) { + $params['model'] = $this->model(); + + return (new UserPicker($params))->toArray(); + } + ] +]; diff --git a/public/kirby/config/fields/multiselect.php b/public/kirby/config/fields/multiselect.php new file mode 100644 index 0000000..6633ee3 --- /dev/null +++ b/public/kirby/config/fields/multiselect.php @@ -0,0 +1,35 @@ + 'tags', + 'props' => [ + /** + * If set to `all`, any type of input is accepted. If set to `options` only the predefined options are accepted as input. + */ + 'accept' => function ($value = 'options') { + return V::in($value, ['all', 'options']) ? $value : 'all'; + }, + /** + * Custom icon to replace the arrow down. + */ + 'icon' => function (string $icon = 'checklist') { + return $icon; + }, + ], + 'methods' => [ + 'toValues' => function ($value) { + if (is_null($value) === true) { + return []; + } + + if (is_array($value) === false) { + $value = Str::split($value, $this->separator()); + } + + return $this->sanitizeOptions($value); + } + ], +]; diff --git a/public/kirby/config/fields/number.php b/public/kirby/config/fields/number.php new file mode 100644 index 0000000..128c733 --- /dev/null +++ b/public/kirby/config/fields/number.php @@ -0,0 +1,52 @@ + [ + /** + * Default number that will be saved when a new page/user/file is created + */ + 'default' => function ($default = null) { + return $this->toNumber($default) ?? ''; + }, + /** + * The lowest allowed number + */ + 'min' => function (float|null $min = null) { + return $min; + }, + /** + * The highest allowed number + */ + 'max' => function (float|null $max = null) { + return $max; + }, + /** + * Allowed incremental steps between numbers (i.e `0.5`) + * Use `any` to allow any decimal value. + */ + '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): float|null { + if ($this->isEmptyValue($value) === true) { + return null; + } + + return is_float($value) === true ? $value : (float)Str::float($value); + } + ], + 'validations' => [ + 'min', + 'max' + ] +]; diff --git a/public/kirby/config/fields/object.php b/public/kirby/config/fields/object.php new file mode 100644 index 0000000..f10516f --- /dev/null +++ b/public/kirby/config/fields/object.php @@ -0,0 +1,104 @@ + [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'autofocus' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Set the default values for the object + */ + 'default' => function ($default = null) { + return $default; + }, + + /** + * The placeholder text if no information has been added yet + */ + 'empty' => function ($empty = null) { + return I18n::translate($empty, $empty); + }, + + /** + * Fields setup for the object form. Works just like fields in regular forms. + */ + 'fields' => function (array $fields = []) { + return $fields; + } + ], + 'computed' => [ + 'default' => function () { + if (empty($this->default) === true) { + return ''; + } + + return $this->form($this->default)->values(); + }, + 'fields' => function () { + if (empty($this->fields) === true) { + return []; + } + + return $this->form()->fields()->toProps(); + }, + 'value' => function () { + $data = Data::decode($this->value, 'yaml'); + + if (empty($data) === true) { + return ''; + } + + return $this->form($data)->values(); + } + ], + 'methods' => [ + 'form' => function (array $values = []) { + return new Form([ + 'fields' => $this->attrs['fields'], + 'values' => $values, + 'model' => $this->model + ]); + }, + ], + 'save' => function ($value) { + if (empty($value) === true) { + return ''; + } + + return $this->form($value)->content(); + }, + 'validations' => [ + 'object' => function ($value) { + if (empty($value) === true) { + return true; + } + + $errors = $this->form($value)->errors(); + + if (empty($errors) === false) { + // use the first error for details + $name = array_key_first($errors); + $error = $errors[$name]; + + throw new InvalidArgumentException( + key: 'object.validation', + data: [ + 'label' => $error['label'] ?? $name, + 'message' => implode("\n", $error['message']) + ] + ); + } + } + ] +]; diff --git a/public/kirby/config/fields/pages.php b/public/kirby/config/fields/pages.php new file mode 100644 index 0000000..3d9d56d --- /dev/null +++ b/public/kirby/config/fields/pages.php @@ -0,0 +1,111 @@ + [ + 'layout', + 'min', + 'pagepicker', + 'picker', + ], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'autofocus' => null, + 'before' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Default selected page(s) when a new page/file/user is created + */ + 'default' => function ($default = null) { + return $this->toPages($default); + }, + + /** + * Optional query to select a specific set of pages + */ + 'query' => function (string|null $query = null) { + return $query; + }, + + /** + * Optionally include subpages of pages + */ + 'subpages' => function (bool $subpages = true) { + return $subpages; + }, + + 'value' => function ($value = null) { + return $this->toPages($value); + }, + ], + 'computed' => [ + /** + * Unset inherited computed + */ + 'default' => null + ], + 'methods' => [ + 'pageResponse' => function ($page) { + return $page->panel()->pickerData([ + 'image' => $this->image, + 'info' => $this->info, + 'layout' => $this->layout, + 'text' => $this->text, + ]); + }, + 'toPages' => function ($value = null) { + $pages = []; + $kirby = App::instance(); + + foreach (Data::decode($value, 'yaml') as $id) { + if (is_array($id) === true) { + $id = $id['uuid'] ?? $id['id'] ?? null; + } + + if ($id !== null && ($page = $kirby->page($id))) { + $pages[] = $this->pageResponse($page); + } + } + + return $pages; + } + ], + 'api' => function () { + return [ + [ + 'pattern' => '/', + 'action' => function () { + $field = $this->field(); + + return $field->pagepicker([ + 'image' => $field->image(), + 'info' => $field->info(), + 'layout' => $field->layout(), + 'limit' => $field->limit(), + 'page' => $this->requestQuery('page'), + 'parent' => $this->requestQuery('parent'), + 'query' => $field->query(), + 'search' => $this->requestQuery('search'), + 'subpages' => $field->subpages(), + 'text' => $field->text() + ]); + } + ] + ]; + }, + 'save' => function ($value = null) { + return A::pluck($value, $this->store); + }, + 'validations' => [ + 'max', + 'min' + ] +]; diff --git a/public/kirby/config/fields/radio.php b/public/kirby/config/fields/radio.php new file mode 100644 index 0000000..9ec7c43 --- /dev/null +++ b/public/kirby/config/fields/radio.php @@ -0,0 +1,30 @@ + ['options'], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Arranges the radio buttons in the given number of columns + */ + 'columns' => function (int $columns = 1) { + return $columns; + }, + ], + 'computed' => [ + 'default' => function () { + $default = $this->model()->toString($this->default); + return $this->sanitizeOption($default); + }, + 'value' => function () { + return $this->sanitizeOption($this->value) ?? ''; + } + ] +]; diff --git a/public/kirby/config/fields/range.php b/public/kirby/config/fields/range.php new file mode 100644 index 0000000..d203a7e --- /dev/null +++ b/public/kirby/config/fields/range.php @@ -0,0 +1,33 @@ + 'number', + 'props' => [ + /** + * Unset inherited props + */ + 'placeholder' => null, + + /** + * The maximum value on the slider + */ + 'max' => function (float $max = 100) { + return $max; + }, + /** + * Enables/disables the tooltip and set the before and after values + */ + 'tooltip' => function ($tooltip = true) { + if (is_array($tooltip) === true) { + $after = $tooltip['after'] ?? null; + $before = $tooltip['before'] ?? null; + $tooltip['after'] = I18n::translate($after, $after); + $tooltip['before'] = I18n::translate($before, $before); + } + + return $tooltip; + }, + ] +]; diff --git a/public/kirby/config/fields/select.php b/public/kirby/config/fields/select.php new file mode 100644 index 0000000..05a9c55 --- /dev/null +++ b/public/kirby/config/fields/select.php @@ -0,0 +1,38 @@ + 'radio', + 'props' => [ + /** + * Unset inherited props + */ + 'columns' => null, + + /** + * Custom icon to replace the arrow down. + */ + 'icon' => function (string|null $icon = null) { + return $icon; + }, + /** + * Text shown when no option is selected yet + */ + 'placeholder' => function (string|array $placeholder = '—') { + return I18n::translate($placeholder, $placeholder); + }, + ], + 'methods' => [ + 'getOptions' => function () { + $props = FieldOptions::polyfill($this->props); + + // disable safe mode as the select field does not + // render HTML for the option text + $options = FieldOptions::factory($props['options'], false); + + return $options->render($this->model()); + } + ] +]; diff --git a/public/kirby/config/fields/slug.php b/public/kirby/config/fields/slug.php new file mode 100644 index 0000000..15c6839 --- /dev/null +++ b/public/kirby/config/fields/slug.php @@ -0,0 +1,55 @@ + 'text', + 'props' => [ + /** + * Unset inherited props + */ + 'converter' => null, + 'counter' => null, + 'spellcheck' => null, + + /** + * Set of characters allowed in the slug + */ + 'allow' => function (string $allow = '') { + return $allow; + }, + + /** + * Changes the link icon + */ + 'icon' => function (string $icon = 'url') { + return $icon; + }, + + /** + * Set prefix for the help text + */ + 'path' => function (string|null $path = null) { + return $path; + }, + + /** + * Name of another field that should be used to + * automatically update this field's value + */ + 'sync' => function (string|null $sync = null) { + return $sync; + }, + + /** + * Set to object with keys `field` and `text` to add + * button to generate from another field + */ + 'wizard' => function ($wizard = false) { + return $wizard; + } + ], + 'validations' => [ + 'minlength', + 'maxlength' + ], +]; diff --git a/public/kirby/config/fields/structure.php b/public/kirby/config/fields/structure.php new file mode 100644 index 0000000..1b6ede0 --- /dev/null +++ b/public/kirby/config/fields/structure.php @@ -0,0 +1,246 @@ + ['min'], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'autofocus' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Whether to enable batch editing + */ + 'batch' => function (bool $batch = false) { + return $batch; + }, + + /** + * Optional columns definition to only show selected fields in the structure table. + */ + 'columns' => function (array $columns = []) { + // lower case all keys, because field names will + // be lowercase as well. + return array_change_key_case($columns); + }, + + /** + * Toggles duplicating rows for the structure + */ + 'duplicate' => function (bool $duplicate = true) { + return $duplicate; + }, + + /** + * The placeholder text if no items have been added yet + */ + 'empty' => function ($empty = null) { + return I18n::translate($empty, $empty); + }, + + /** + * Set the default rows for the structure + */ + 'default' => function (array|null $default = null) { + return $default; + }, + + /** + * Fields setup for the structure form. Works just like fields in regular forms. + */ + 'fields' => function (array $fields = []) { + return $fields; + }, + /** + * The number of entries that will be displayed on a single page. Afterwards pagination kicks in. + */ + 'limit' => function (int|null $limit = null) { + return $limit; + }, + /** + * Maximum allowed entries in the structure. Afterwards the "Add" button will be switched off. + */ + 'max' => function (int|null $max = null) { + return $max; + }, + /** + * Minimum required entries in the structure + */ + 'min' => function (int|null $min = null) { + return $min; + }, + /** + * Toggles adding to the top or bottom of the list + */ + 'prepend' => function (bool|null $prepend = null) { + return $prepend; + }, + /** + * Toggles drag & drop sorting + */ + 'sortable' => function (bool|null $sortable = null) { + return $sortable; + }, + /** + * Sorts the entries by the given field and order (i.e. `title desc`) + * Drag & drop is disabled in this case + */ + 'sortBy' => function (string|null $sort = null) { + return $sort; + } + ], + 'computed' => [ + 'default' => function () { + return $this->rows($this->default); + }, + 'value' => function () { + return $this->rows($this->value); + }, + 'fields' => function () { + if (empty($this->fields) === true) { + return []; + } + + return $this->form()->fields()->toProps(); + }, + 'columns' => function () { + $columns = []; + $blueprint = $this->columns; + + // if no custom columns have been defined, + // gather all fields as columns + if (empty($blueprint) === true) { + // skip hidden fields + $fields = array_filter( + $this->fields, + fn ($field) => + $field['type'] !== 'hidden' && $field['hidden'] !== true + ); + $fields = array_column($fields, 'name'); + $blueprint = array_fill_keys($fields, true); + } + + foreach ($blueprint as $name => $column) { + $field = $this->fields[$name] ?? null; + + // Skip empty and unsaveable fields + // They should never be included as column + if ( + empty($field) === true || + $field['saveable'] === false + ) { + continue; + } + + if (is_array($column) === false) { + $column = []; + } + + $column['type'] ??= $field['type']; + $column['label'] ??= $field['label'] ?? $name; + $column['label'] = I18n::translate($column['label'], $column['label']); + + $columns[$name] = $column; + } + + // make the first column visible on mobile + // if no other mobile columns are defined + if (in_array(true, array_column($columns, 'mobile'), true) === false) { + $columns[array_key_first($columns)]['mobile'] = true; + } + + return $columns; + } + ], + 'methods' => [ + 'rows' => function ($value) { + $rows = Data::decode($value, 'yaml'); + $value = []; + + foreach ($rows as $index => $row) { + if (is_array($row) === false) { + continue; + } + + $value[] = $this->form()->fill(input: $row, passthrough: true)->toFormValues(); + } + + return $value; + }, + 'form' => function () { + $this->form ??= new Form( + fields: $this->attrs['fields'] ?? [], + model: $this->model, + language: 'current' + ); + + return $this->form->reset(); + } + ], + 'save' => function ($value) { + $data = []; + $form = $this->form(); + $defaults = $form->defaults(); + + foreach ($value as $index => $row) { + $row = $form + ->reset() + ->fill( + input: $defaults, + ) + ->submit( + input: $row, + passthrough: true + ) + ->toStoredValues(); + + // remove frontend helper id + unset($row['_id']); + + $data[] = $row; + } + + return $data; + }, + 'validations' => [ + 'min', + 'max', + 'structure' => function ($value) { + if (empty($value) === true) { + return true; + } + + $values = A::wrap($value); + + foreach ($values as $index => $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: [ + 'field' => $field->label() ?? Str::ucfirst($field->name()), + 'index' => $index + 1 + ] + ); + } + } + } + } + ] +]; diff --git a/public/kirby/config/fields/tags.php b/public/kirby/config/fields/tags.php new file mode 100644 index 0000000..90020bc --- /dev/null +++ b/public/kirby/config/fields/tags.php @@ -0,0 +1,106 @@ + ['min', 'options'], + 'props' => [ + + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'placeholder' => null, + + /** + * If set to `all`, any type of input is accepted. If set to `options` only the predefined options are accepted as input. + */ + 'accept' => function ($value = 'all') { + return V::in($value, ['all', 'options']) ? $value : 'all'; + }, + /** + * Changes the tag icon + */ + 'icon' => function ($icon = 'tag') { + return $icon; + }, + /** + * Set to `list` to display each tag with 100% width, + * otherwise the tags are displayed inline + */ + 'layout' => function (string|null $layout = null) { + return $layout; + }, + /** + * Minimum number of required entries/tags + */ + 'min' => function (int|null $min = null) { + return $min; + }, + /** + * Maximum number of allowed entries/tags + */ + 'max' => function (int|null $max = null) { + return $max; + }, + /** + * Enable/disable the search in the dropdown + * Also limit displayed items (display: 20) + * and set minimum number of characters to search (min: 3) + */ + 'search' => function (bool|array $search = true) { + return $search; + }, + /** + * Custom tags separator, which will be used to store tags in the content file + */ + 'separator' => function (string $separator = ',') { + return $separator; + }, + /** + * If `true`, selected entries will be sorted + * according to their position in the dropdown + */ + 'sort' => function (bool $sort = false) { + return $sort; + }, + ], + 'computed' => [ + 'default' => function (): array { + return $this->toValues($this->default); + }, + 'value' => function (): array { + return $this->toValues($this->value); + } + ], + 'methods' => [ + 'toValues' => function ($value) { + if (is_null($value) === true) { + return []; + } + + if (is_array($value) === false) { + $value = Str::split($value, $this->separator()); + } + + if ($this->accept === 'options') { + $value = $this->sanitizeOptions($value); + } + + return $value; + } + ], + 'save' => function (array|null $value = null): string { + return A::join( + $value, + $this->separator() . ' ' + ); + }, + 'validations' => [ + 'min', + 'max' + ] +]; diff --git a/public/kirby/config/fields/tel.php b/public/kirby/config/fields/tel.php new file mode 100644 index 0000000..715d587 --- /dev/null +++ b/public/kirby/config/fields/tel.php @@ -0,0 +1,27 @@ + 'text', + 'props' => [ + /** + * Unset inherited props + */ + 'converter' => null, + 'counter' => null, + 'spellcheck' => null, + + /** + * Sets the HTML5 autocomplete attribute + */ + 'autocomplete' => function (string $autocomplete = 'tel') { + return $autocomplete; + }, + + /** + * Changes the phone icon + */ + 'icon' => function (string $icon = 'phone') { + return $icon; + } + ] +]; diff --git a/public/kirby/config/fields/text.php b/public/kirby/config/fields/text.php new file mode 100644 index 0000000..04f8c28 --- /dev/null +++ b/public/kirby/config/fields/text.php @@ -0,0 +1,112 @@ + [ + + /** + * 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] + ); + } + + return $value; + }, + + /** + * Shows or hides the character counter in the top right corner + */ + 'counter' => function (bool $counter = true) { + return $counter; + }, + + /** + * Sets the font family (sans or monospace) + */ + 'font' => function (string|null $font = null) { + return $font === 'monospace' ? 'monospace' : 'sans-serif'; + }, + + /** + * Maximum number of allowed characters + */ + 'maxlength' => function (int|null $maxlength = null) { + return $maxlength; + }, + + /** + * Minimum number of required characters + */ + 'minlength' => function (int|null $minlength = null) { + return $minlength; + }, + + /** + * A regular expression, which will be used to validate the input + */ + 'pattern' => function (string|null $pattern = null) { + return $pattern; + }, + + /** + * If `false`, spellcheck will be switched off + */ + 'spellcheck' => function (bool $spellcheck = false) { + return $spellcheck; + }, + ], + 'computed' => [ + 'default' => function () { + return $this->convert($this->default); + }, + 'value' => function () { + return (string)$this->convert($this->value); + } + ], + 'methods' => [ + 'convert' => function ($value) { + if ($this->converter() === null) { + return $value; + } + + $converter = $this->converters()[$this->converter()]; + + if (is_array($value) === true) { + return array_map($converter, $value); + } + + return call_user_func($converter, trim($value ?? '')); + }, + 'converters' => function (): array { + return [ + 'lower' => function ($value) { + return Str::lower($value); + }, + 'slug' => function ($value) { + return Str::slug($value); + }, + 'ucfirst' => function ($value) { + return Str::ucfirst($value); + }, + 'upper' => function ($value) { + return Str::upper($value); + }, + ]; + }, + ], + 'validations' => [ + 'minlength', + 'maxlength', + 'pattern' + ] +]; diff --git a/public/kirby/config/fields/textarea.php b/public/kirby/config/fields/textarea.php new file mode 100644 index 0000000..f977fa1 --- /dev/null +++ b/public/kirby/config/fields/textarea.php @@ -0,0 +1,120 @@ + ['filepicker', 'upload'], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + + /** + * Enables/disables the format buttons. Can either be `true`/`false` or a list of allowed buttons. Available buttons: `headlines`, `italic`, `bold`, `link`, `email`, `file`, `code`, `ul`, `ol` (as well as `|` for a divider) + */ + 'buttons' => function ($buttons = true) { + return $buttons; + }, + + /** + * Enables/disables the character counter in the top right corner + */ + 'counter' => function (bool $counter = true) { + return $counter; + }, + + /** + * Sets the default text when a new page/file/user is created + */ + 'default' => function (string|null $default = null) { + return trim($default ?? ''); + }, + + /** + * Sets the options for the files picker + */ + 'files' => function ($files = []) { + if (is_string($files) === true) { + return ['query' => $files]; + } + + if (is_array($files) === false) { + $files = []; + } + + return $files; + }, + + /** + * Sets the font family (sans or monospace) + */ + 'font' => function (string|null $font = null) { + return $font === 'monospace' ? 'monospace' : 'sans-serif'; + }, + + /** + * Maximum number of allowed characters + */ + 'maxlength' => function (int|null $maxlength = null) { + return $maxlength; + }, + + /** + * Minimum number of required characters + */ + 'minlength' => function (int|null $minlength = null) { + return $minlength; + }, + + /** + * Changes the size of the textarea. Available sizes: `small`, `medium`, `large`, `huge` + */ + 'size' => function (string|null $size = null) { + return $size; + }, + + /** + * If `false`, spellcheck will be switched off + */ + 'spellcheck' => function (bool $spellcheck = true) { + return $spellcheck; + }, + + 'value' => function (string|null $value = null) { + return trim($value ?? ''); + } + ], + 'api' => function () { + return [ + [ + 'pattern' => 'files', + 'action' => function () { + return $this->field()->filepicker([ + ...$this->field()->files(), + 'page' => $this->requestQuery('page'), + 'search' => $this->requestQuery('search') + ]); + } + ], + [ + 'pattern' => 'upload', + 'method' => 'POST', + 'action' => function () { + $field = $this->field(); + $uploads = $field->uploads(); + + return $this->field()->upload($this, $uploads, fn ($file, $parent) => [ + 'filename' => $file->filename(), + 'dragText' => $file->panel()->dragText( + absolute: $field->model()->is($parent) === false + ), + ]); + } + ] + ]; + }, + 'validations' => [ + 'minlength', + 'maxlength' + ] +]; diff --git a/public/kirby/config/fields/time.php b/public/kirby/config/fields/time.php new file mode 100644 index 0000000..5ad7388 --- /dev/null +++ b/public/kirby/config/fields/time.php @@ -0,0 +1,126 @@ + ['datetime'], + 'props' => [ + /** + * Unset inherited props + */ + 'placeholder' => null, + + /** + * Sets the default time when a new page/file/user is created + */ + 'default' => function ($default = null): string|null { + return $default; + }, + + /** + * Custom format (dayjs tokens: `HH`, `hh`, `mm`, `ss`, `a`) that is + * used to display the field in the Panel + */ + 'display' => function ($display = null) { + return I18n::translate($display, $display); + }, + + /** + * Changes the clock icon + */ + 'icon' => function (string $icon = 'clock') { + return $icon; + }, + /** + * Latest time, which can be selected/saved (H:i or H:i:s) + */ + 'max' => function (string|null $max = null): string|null { + return Date::optional($max); + }, + /** + * Earliest time, which can be selected/saved (H:i or H:i:s) + */ + 'min' => function (string|null $min = null): string|null { + return Date::optional($min); + }, + + /** + * `12` or `24` hour notation. If `12`, an AM/PM selector will be shown. + * If `display` is defined, that option will take priority. + */ + 'notation' => function (int $value = 24) { + return $value === 24 ? 24 : 12; + }, + /** + * Round to the nearest: sub-options for `unit` (minute) and `size` (5) + */ + 'step' => function ($step = null) { + return Date::stepConfig($step, [ + 'size' => 5, + 'unit' => 'minute', + ]); + }, + 'value' => function ($value = null): string|null { + return $value; + } + ], + 'computed' => [ + 'display' => function () { + if ($this->display) { + return $this->display; + } + + return $this->notation === 24 ? 'HH:mm' : 'hh:mm a'; + }, + 'default' => function (): string { + return $this->toDatetime($this->default, 'H:i:s') ?? ''; + }, + 'format' => function () { + return $this->props['format'] ?? 'H:i:s'; + }, + 'value' => function (): string|null { + return $this->toDatetime($this->value, 'H:i:s') ?? ''; + } + ], + 'validations' => [ + 'time', + 'minMax' => function ($value) { + if (!$value = Date::optional($value)) { + return true; + } + + $min = Date::optional($this->min); + $max = Date::optional($this->max); + + $format = 'H:i:s'; + + if ($min && $max && $value->isBetween($min, $max) === false) { + throw new InvalidArgumentException( + key: 'validation.time.between', + data: [ + 'min' => $min->format($format), + 'max' => $min->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 new file mode 100644 index 0000000..c3b89d0 --- /dev/null +++ b/public/kirby/config/fields/toggle.php @@ -0,0 +1,78 @@ + [ + /** + * Unset inherited props + */ + 'placeholder' => null, + + /** + * Default value which will be saved when a new page/user/file is created + */ + 'default' => function ($default = null) { + return $this->default = $default; + }, + /** + * Sets the text next to the toggle. The text can be a string or an array of two options. The first one is the negative text and the second one the positive. The text will automatically switch when the toggle is triggered. + */ + 'text' => function ($value = null) { + $model = $this->model(); + + if (is_array($value) === true) { + if (A::isAssociative($value) === true) { + return $model->toSafeString(I18n::translate($value, $value)); + } + + foreach ($value as $key => $val) { + $value[$key] = $model->toSafeString(I18n::translate($val, $val)); + } + + return $value; + } + + if (empty($value) === false) { + return $model->toSafeString(I18n::translate($value, $value)); + } + + return $value; + }, + ], + 'computed' => [ + 'default' => function () { + return $this->toBool($this->default); + }, + 'value' => function () { + if ($this->props['value'] === null) { + return $this->default(); + } + + return $this->toBool($this->props['value']); + } + ], + 'methods' => [ + 'toBool' => function ($value) { + return in_array($value, [true, 'true', 1, '1', 'on'], true) === true; + } + ], + 'save' => function (): string { + return $this->value() === true ? 'true' : 'false'; + }, + 'validations' => [ + 'boolean', + 'required' => function ($value) { + if ( + $this->isRequired() && + ($value === false || $this->isEmptyValue($value)) + ) { + throw new InvalidArgumentException( + message: I18n::translate('field.required') + ); + } + }, + ] +]; diff --git a/public/kirby/config/fields/toggles.php b/public/kirby/config/fields/toggles.php new file mode 100644 index 0000000..c922c2b --- /dev/null +++ b/public/kirby/config/fields/toggles.php @@ -0,0 +1,41 @@ + ['options'], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'before' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Toggles will automatically span the full width of the field. With the grow option, you can disable this behaviour for a more compact layout. + */ + 'grow' => function (bool $grow = true) { + return $grow; + }, + /** + * If `false` all labels will be hidden for icon-only toggles. + */ + 'labels' => function (bool $labels = true) { + return $labels; + }, + /** + * A toggle can be deactivated on click. If reset is `false` deactivating a toggle is no longer possible. + */ + 'reset' => function (bool $reset = true) { + return $reset; + } + ], + 'computed' => [ + 'default' => function () { + return $this->sanitizeOption($this->default); + }, + 'value' => function () { + return $this->sanitizeOption($this->value) ?? ''; + }, + ] +]; diff --git a/public/kirby/config/fields/url.php b/public/kirby/config/fields/url.php new file mode 100644 index 0000000..1ecab71 --- /dev/null +++ b/public/kirby/config/fields/url.php @@ -0,0 +1,42 @@ + 'text', + 'props' => [ + /** + * Unset inherited props + */ + 'converter' => null, + 'counter' => null, + 'pattern' => null, + 'spellcheck' => null, + + /** + * Sets the HTML5 autocomplete attribute + */ + 'autocomplete' => function (string $autocomplete = 'url') { + return $autocomplete; + }, + + /** + * Changes the link icon + */ + 'icon' => function (string $icon = 'url') { + return $icon; + }, + + /** + * Sets custom placeholder text, when the field is empty + */ + 'placeholder' => function ($value = null) { + return I18n::translate($value, $value) ?? 'https://example.com'; + } + ], + 'validations' => [ + 'minlength', + 'maxlength', + 'url' + ], +]; diff --git a/public/kirby/config/fields/users.php b/public/kirby/config/fields/users.php new file mode 100644 index 0000000..4533bba --- /dev/null +++ b/public/kirby/config/fields/users.php @@ -0,0 +1,107 @@ + [ + 'layout', + 'min', + 'picker', + 'userpicker' + ], + 'props' => [ + /** + * Unset inherited props + */ + 'after' => null, + 'autofocus' => null, + 'before' => null, + 'icon' => null, + 'placeholder' => null, + + /** + * Default selected user(s) when a new page/file/user is created + */ + 'default' => function (string|array|bool|null $default = null) { + return $default; + }, + + 'value' => function ($value = null) { + return $this->toUsers($value); + }, + ], + 'computed' => [ + 'default' => function (): array { + if ($this->default === false) { + return []; + } + + if ( + $this->default === true && + $user = $this->kirby()->user() + ) { + return [ + $this->userResponse($user) + ]; + } + + return $this->toUsers($this->default); + } + ], + 'methods' => [ + 'userResponse' => function ($user) { + return $user->panel()->pickerData([ + 'info' => $this->info, + 'image' => $this->image, + 'layout' => $this->layout, + 'text' => $this->text, + ]); + }, + 'toUsers' => function ($value = null): array { + $users = []; + $kirby = App::instance(); + + foreach (Data::decode($value, 'yaml') as $id) { + if (is_array($id) === true) { + $id = $id['uuid'] ?? $id['id'] ?? $id['email'] ?? null; + } + + if ($id !== null && ($user = $kirby->user($id))) { + $users[] = $this->userResponse($user); + } + } + + return $users; + } + ], + 'api' => function () { + return [ + [ + 'pattern' => '/', + 'action' => function () { + $field = $this->field(); + + return $field->userpicker([ + 'image' => $field->image(), + 'info' => $field->info(), + 'layout' => $field->layout(), + 'limit' => $field->limit(), + 'page' => $this->requestQuery('page'), + 'query' => $field->query(), + 'search' => $this->requestQuery('search'), + 'text' => $field->text() + ]); + } + ] + ]; + }, + 'save' => function ($value = null) { + return A::pluck($value, $this->store); + }, + 'validations' => [ + 'max', + 'min' + ] +]; diff --git a/public/kirby/config/fields/writer.php b/public/kirby/config/fields/writer.php new file mode 100644 index 0000000..43fdbcd --- /dev/null +++ b/public/kirby/config/fields/writer.php @@ -0,0 +1,100 @@ + [ + /** + * Enables/disables the character counter in the top right corner + */ + 'counter' => function (bool $counter = true) { + return $counter; + }, + /** + * Available heading levels + */ + 'headings' => function (array|null $headings = null) { + return array_intersect($headings ?? range(1, 6), range(1, 6)); + }, + /** + * Enables inline mode, which will not wrap new lines in paragraphs and creates hard breaks instead. + * + * @param bool $inline + */ + 'inline' => function (bool $inline = false) { + return $inline; + }, + /** + * Sets the allowed HTML formats. Available formats: `bold`, `italic`, `underline`, `strike`, `code`, `link`, `email`. Activate/deactivate them all by passing `true`/`false`. Default marks are `bold`, `italic`, `underline`, `strike`, `link`, `email` + * @param array|bool $marks + */ + 'marks' => function ($marks = null) { + return $marks; + }, + /** + * Maximum number of allowed characters + */ + 'maxlength' => function (int|null $maxlength = null) { + return $maxlength; + }, + + /** + * Minimum number of required characters + */ + 'minlength' => function (int|null $minlength = null) { + return $minlength; + }, + /** + * Sets the allowed nodes. Available nodes: `paragraph`, `heading`, `bulletList`, `orderedList`, `quote`. Activate/deactivate them all by passing `true`/`false`. Default nodes are `paragraph`, `heading`, `bulletList`, `orderedList`. + * @param array|bool|null $nodes + */ + 'nodes' => function ($nodes = null) { + return $nodes; + }, + /** + * Toolbar options, incl. `marks` (to narrow down which marks should have toolbar buttons), `nodes` (to narrow down which nodes should have toolbar dropdown entries) and `inline` to set the position of the toolbar (false = sticking on top of the field) + */ + 'toolbar' => function ($toolbar = null) { + return $toolbar; + } + ], + 'computed' => [ + 'value' => function () { + $value = trim($this->value ?? ''); + $value = Sane::sanitize($value, 'html'); + + // convert non-breaking spaces to HTML entity + // as that's how ProseMirror handles it internally; + // will allow comparing saved and current content + $value = str_replace(' ', ' ', $value); + + return $value; + } + ], + 'validations' => [ + 'minlength' => function ($value) { + if ( + $this->minlength && + V::minLength(strip_tags($value), $this->minlength) === false + ) { + throw new InvalidArgumentException( + key: 'validation.minlength', + data: ['min' => $this->minlength] + ); + } + }, + 'maxlength' => function ($value) { + if ( + $this->maxlength && + V::maxLength(strip_tags($value), $this->maxlength) === false + ) { + throw new InvalidArgumentException( + key: 'validation.maxlength', + data: ['max' => $this->maxlength] + ); + } + }, + ] +]; diff --git a/public/kirby/config/helpers.php b/public/kirby/config/helpers.php new file mode 100644 index 0000000..082b35d --- /dev/null +++ b/public/kirby/config/helpers.php @@ -0,0 +1,692 @@ +collection($name, $options); + } +} + +if (Helpers::hasOverride('csrf') === false) { // @codeCoverageIgnore + /** + * Checks / returns a CSRF token + * + * @param string|null $check Pass a token here to compare it to the one in the session + * @return string|bool Either the token or a boolean check result + */ + function csrf(string|null $check = null): string|bool + { + // check explicitly if there have been no arguments at all; + // checking for null introduces a security issue because null could come + // from user input or bugs in the calling code! + if (func_num_args() === 0) { + return App::instance()->csrf(); + } + + return App::instance()->csrf($check); + } +} + +if (Helpers::hasOverride('css') === false) { // @codeCoverageIgnore + /** + * Creates one or multiple CSS link tags + * + * @param string|array $url Relative or absolute URLs, an array of URLs or `@auto` for automatic template css loading + * @param string|array|null $options Pass an array of attributes for the link tag or a media attribute string + */ + function css( + string|array|Plugin|PluginAssets $url, + string|array|null $options = null + ): string|null { + return Html::css($url, $options); + } +} + +if (Helpers::hasOverride('deprecated') === false) { // @codeCoverageIgnore + /** + * Triggers a deprecation warning if debug mode is active + * @since 3.3.0 + * + * @return bool Whether the warning was triggered + */ + function deprecated(string $message): bool + { + return Helpers::deprecated($message); + } +} + +if (Helpers::hasOverride('dump') === false && function_exists('dump') === false) { // @codeCoverageIgnore + /** + * Simple object and variable dumper + * to help with debugging. + */ + function dump(mixed $variable, bool $echo = true): string + { + return Helpers::dump($variable, $echo); + } +} + +if (Helpers::hasOverride('e') === false) { // @codeCoverageIgnore + /** + * Smart version of echo with an if condition as first argument + * + * @param mixed $value The string to be echoed if the condition is true + * @param mixed $alternative An alternative string which should be echoed when the condition is false + */ + function e(mixed $condition, mixed $value, mixed $alternative = null): void + { + echo $condition ? $value : $alternative; + } +} + +if (Helpers::hasOverride('endslot') === false) { // @codeCoverageIgnore + /** + * Ends the last started template slot + */ + function endslot(): void + { + Slot::end(); + } +} + +if (Helpers::hasOverride('endsnippet') === false) { // @codeCoverageIgnore + /** + * Renders the currently active snippet with slots + */ + function endsnippet(): void + { + Snippet::end(); + } +} + +if (Helpers::hasOverride('esc') === false) { // @codeCoverageIgnore + /** + * Escape context specific output + * + * @param string $string Untrusted data + * @param string $context Location of output (`html`, `attr`, `js`, `css`, `url` or `xml`) + * @return string Escaped data + */ + function esc(string $string, string $context = 'html'): string + { + return Str::esc($string, $context); + } +} + +if (Helpers::hasOverride('get') === false) { // @codeCoverageIgnore + /** + * Shortcut for $kirby->request()->get() + * + * @param mixed $key The key to look for. Pass false or null to return the entire request array. + * @param mixed $default Optional default value, which should be returned if no element has been found + */ + function get(mixed $key = null, mixed $default = null): mixed + { + return App::instance()->request()->get($key, $default); + } +} + +if (Helpers::hasOverride('gist') === false) { // @codeCoverageIgnore + /** + * Embeds a Github Gist + */ + function gist(string $url, string|null $file = null): string + { + return App::instance()->kirbytag([ + 'gist' => $url, + 'file' => $file, + ]); + } +} + +if (Helpers::hasOverride('go') === false) { // @codeCoverageIgnore + /** + * Redirects to the given Urls + * Urls can be relative or absolute. + */ + function go(string $url = '/', int $code = 302): never + { + Response::go($url, $code); + } +} + +if (Helpers::hasOverride('h') === false) { // @codeCoverageIgnore + /** + * Shortcut for html() + * + * @param string|null $string unencoded text + */ + function h(string|null $string, bool $keepTags = false): string + { + return Html::encode($string, $keepTags); + } +} + +if (Helpers::hasOverride('html') === false) { // @codeCoverageIgnore + /** + * Creates safe html by encoding special characters + * + * @param string|null $string unencoded text + */ + function html(string|null $string, bool $keepTags = false): string + { + return Html::encode($string, $keepTags); + } +} + +if (Helpers::hasOverride('image') === false) { // @codeCoverageIgnore + /** + * Return an image from any page + * specified by the path + * + * Example: + * + */ + function image(string|null $path = null): File|null + { + return App::instance()->image($path); + } +} + +if (Helpers::hasOverride('invalid') === false) { // @codeCoverageIgnore + /** + * Runs a number of validators on a set of data and checks if the data is invalid + */ + function invalid( + array $data = [], + array $rules = [], + array $messages = [] + ): array { + return V::invalid($data, $rules, $messages); + } +} + +if (Helpers::hasOverride('js') === false) { // @codeCoverageIgnore + /** + * Creates a script tag to load a javascript file + */ + function js( + string|array|Plugin|PluginAssets $url, + string|array|bool|null $options = null + ): string|null { + return Html::js($url, $options); + } +} + +if (Helpers::hasOverride('kirby') === false) { // @codeCoverageIgnore + /** + * Returns the Kirby object in any situation + */ + function kirby(): App + { + return App::instance(); + } +} + +if (Helpers::hasOverride('kirbytag') === false) { // @codeCoverageIgnore + /** + * Makes it possible to use any defined Kirbytag as standalone function + */ + function kirbytag( + string|array $type, + string|null $value = null, + array $attr = [], + array $data = [] + ): string { + return App::instance()->kirbytag($type, $value, $attr, $data); + } +} + +if (Helpers::hasOverride('kirbytags') === false) { // @codeCoverageIgnore + /** + * Parses KirbyTags in the given string. Shortcut + * for `$kirby->kirbytags($text, $data)` + */ + function kirbytags(string|null $text = null, array $data = []): string + { + return App::instance()->kirbytags($text, $data); + } +} + +if (Helpers::hasOverride('kirbytext') === false) { // @codeCoverageIgnore + /** + * Parses KirbyTags and Markdown in the + * given string. Shortcut for `$kirby->kirbytext()` + */ + function kirbytext(string|null $text = null, array $data = []): string + { + return App::instance()->kirbytext($text, $data); + } +} + +if (Helpers::hasOverride('kirbytextinline') === false) { // @codeCoverageIgnore + /** + * Parses KirbyTags and inline Markdown in the + * given string. + * @since 3.1.0 + */ + function kirbytextinline(string|null $text = null, array $options = []): string + { + $options['markdown']['inline'] = true; + return App::instance()->kirbytext($text, $options); + } +} + +if (Helpers::hasOverride('kt') === false) { // @codeCoverageIgnore + /** + * Shortcut for `kirbytext()` helper + */ + function kt(string|null $text = null, array $data = []): string + { + return App::instance()->kirbytext($text, $data); + } +} + +if (Helpers::hasOverride('kti') === false) { // @codeCoverageIgnore + /** + * Shortcut for `kirbytextinline()` helper + * @since 3.1.0 + */ + function kti(string|null $text = null, array $options = []): string + { + $options['markdown']['inline'] = true; + return App::instance()->kirbytext($text, $options); + } +} + +if (Helpers::hasOverride('load') === false) { // @codeCoverageIgnore + /** + * A super simple class autoloader + */ + function load(array $classmap, string|null $base = null): void + { + F::loadClasses($classmap, $base); + } +} + +if (Helpers::hasOverride('markdown') === false) { // @codeCoverageIgnore + /** + * Parses markdown in the given string. Shortcut for + * `$kirby->markdown($text)` + */ + function markdown(string|null $text = null, array $options = []): string + { + return App::instance()->markdown($text, $options); + } +} + +if (Helpers::hasOverride('option') === false) { // @codeCoverageIgnore + /** + * Shortcut for `$kirby->option($key, $default)` + */ + function option(string $key, mixed $default = null): mixed + { + return App::instance()->option($key, $default); + } +} + +if (Helpers::hasOverride('page') === false) { // @codeCoverageIgnore + /** + * Fetches a single page by id or + * the current page when no id is specified + */ + function page(string|null $id = null): Page|null + { + if (empty($id) === true) { + return App::instance()->site()->page(); + } + + return App::instance()->site()->find($id); + } +} + +if (Helpers::hasOverride('pages') === false) { // @codeCoverageIgnore + /** + * Helper to build pages collection + */ + function pages(string|array ...$id): Pages|null + { + // ensure that a list of string arguments and an array + // as the first argument are treated the same + if (count($id) === 1 && is_array($id[0]) === true) { + $id = $id[0]; + } + + // always passes $id an array; ensures we get a + // collection even if only one ID is passed + return App::instance()->site()->find($id); + } +} + +if (Helpers::hasOverride('param') === false) { // @codeCoverageIgnore + /** + * Returns a single param from the URL + * + * @psalm-return ($fallback is string ? string : string|null) + */ + function param(string $key, string|null $fallback = null): string|null + { + return App::instance()->request()->url()->params()->$key ?? $fallback; + } +} + +if (Helpers::hasOverride('params') === false) { // @codeCoverageIgnore + /** + * Returns all params from the current Url + */ + function params(): array + { + return App::instance()->request()->url()->params()->toArray(); + } +} + +if (Helpers::hasOverride('qr') === false) { // @codeCoverageIgnore + /** + * Creates a QR code object + */ + function qr(string|ModelWithContent $data): QrCode + { + if ($data instanceof ModelWithContent) { + $data = $data->url(); + } + + return new QrCode($data); + } +} + +if (Helpers::hasOverride('r') === false) { // @codeCoverageIgnore + /** + * Smart version of return with an if condition as first argument + * + * @param mixed $value The string to be returned if the condition is true + * @param mixed $alternative An alternative string which should be returned when the condition is false + */ + function r(mixed $condition, mixed $value, mixed $alternative = null): mixed + { + return $condition ? $value : $alternative; + } +} + +if (Helpers::hasOverride('router') === false) { // @codeCoverageIgnore + /** + * Creates a micro-router and executes + * the routing action immediately + * @since 3.6.0 + */ + function router( + string|null $path = null, + string $method = 'GET', + array $routes = [], + Closure|null $callback = null + ): mixed { + return Router::execute($path, $method, $routes, $callback); + } +} + +if (Helpers::hasOverride('site') === false) { // @codeCoverageIgnore + /** + * Returns the current site object + */ + function site(): Site + { + return App::instance()->site(); + } +} + +if (Helpers::hasOverride('size') === false) { // @codeCoverageIgnore + /** + * Determines the size/length of numbers, strings, arrays and countable objects + */ + function size(mixed $value): int + { + return Helpers::size($value); + } +} + +if (Helpers::hasOverride('slot') === false) { // @codeCoverageIgnore + /** + * Starts a new template slot + */ + function slot(string $name = 'default'): void + { + Slot::begin($name); + } +} + +if (Helpers::hasOverride('smartypants') === false) { // @codeCoverageIgnore + /** + * Enhances the given string with + * smartypants. Shortcut for `$kirby->smartypants($text)` + */ + function smartypants(string|null $text = null): string + { + return App::instance()->smartypants($text); + } +} + +if (Helpers::hasOverride('snippet') === false) { // @codeCoverageIgnore + /** + * Embeds a snippet from the snippet folder + */ + function snippet( + $name, + $data = [], + bool $return = false, + bool $slots = false + ): Snippet|string|null { + return App::instance()->snippet($name, $data, $return, $slots); + } +} + +if (Helpers::hasOverride('svg') === false) { // @codeCoverageIgnore + /** + * Includes an SVG file by absolute or + * relative file path. + */ + function svg(string|File $file): string|false + { + return Html::svg($file); + } +} + +if (Helpers::hasOverride('t') === false) { // @codeCoverageIgnore + /** + * Returns translate string for key from translation file + */ + function t( + string|array $key, + string|null $fallback = null, + string|null $locale = null + ): string|array|Closure|null { + return I18n::translate($key, $fallback, $locale); + } +} + +if (Helpers::hasOverride('tc') === false) { // @codeCoverageIgnore + /** + * Translates a count + * + * @param bool $formatNumber If set to `false`, the count is not formatted + */ + function tc( + string $key, + int $count, + string|null $locale = null, + bool $formatNumber = true + ): mixed { + return I18n::translateCount($key, $count, $locale, $formatNumber); + } +} + +if (Helpers::hasOverride('timestamp') === false) { // @codeCoverageIgnore + /** + * Rounds the minutes of the given date + * by the defined step + * + * @param int|array|null $step array of `unit` and `size` to round to nearest + */ + function timestamp( + string|null $date = null, + int|array|null $step = null + ): int|null { + return Date::roundedTimestamp($date, $step); + } +} + +if (Helpers::hasOverride('tt') === false) { // @codeCoverageIgnore + /** + * Translate by key and then replace + * placeholders in the text + */ + function tt( + string $key, + string|array|null $fallback = null, + array|null $replace = null, + string|null $locale = null + ): string { + return I18n::template($key, $fallback, $replace, $locale); + } +} + +if (Helpers::hasOverride('u') === false) { // @codeCoverageIgnore + /** + * Shortcut for url() + */ + function u( + string|null $path = null, + array|string|null $options = null + ): string { + return Url::to($path, $options); + } +} + +if (Helpers::hasOverride('url') === false) { // @codeCoverageIgnore + /** + * Builds an absolute URL for a given path + */ + function url( + string|null $path = null, + array|string|null $options = null + ): string { + return Url::to($path, $options); + } +} + +if (Helpers::hasOverride('uuid') === false) { // @codeCoverageIgnore + /** + * Creates a compliant v4 UUID + */ + function uuid(): string + { + return Str::uuid(); + } +} + +if (Helpers::hasOverride('video') === false) { // @codeCoverageIgnore + /** + * Creates a video embed via iframe for YouTube or Vimeo + * videos. The embed Urls are automatically detected from + * the given Url. + */ + function video( + string $url, + array $options = [], + array $attr = [] + ): string|null { + return Html::video($url, $options, $attr); + } +} + +if (Helpers::hasOverride('vimeo') === false) { // @codeCoverageIgnore + /** + * Embeds a Vimeo video by URL in an iframe + */ + function vimeo( + string $url, + array $options = [], + array $attr = [] + ): string|null { + return Html::vimeo($url, $options, $attr); + } +} + +if (Helpers::hasOverride('widont') === false) { // @codeCoverageIgnore + /** + * The widont function makes sure that there are no + * typographical widows at the end of a paragraph – + * that's a single word in the last line + */ + function widont(string|null $string = null): string + { + return Str::widont($string); + } +} + +if (Helpers::hasOverride('youtube') === false) { // @codeCoverageIgnore + /** + * Embeds a YouTube video by URL in an iframe + */ + function youtube( + string $url, + array $options = [], + array $attr = [] + ): string|null { + return Html::youtube($url, $options, $attr); + } +} diff --git a/public/kirby/config/methods.php b/public/kirby/config/methods.php new file mode 100644 index 0000000..bf8bccb --- /dev/null +++ b/public/kirby/config/methods.php @@ -0,0 +1,635 @@ + function (Field $field): bool { + return $field->toBool() === false; + }, + + /** + * Converts the field value into a proper boolean + */ + 'isTrue' => function (Field $field): bool { + return $field->toBool() === true; + }, + + /** + * Validates the field content with the given validator and parameters + * + * @param mixed ...$arguments A list of optional validator arguments + */ + 'isValid' => function ( + Field $field, + string $validator, + ...$arguments + ): bool { + return V::$validator($field->value, ...$arguments); + }, + + // converters + /** + * Converts a yaml or json field to a Blocks object + */ + 'toBlocks' => function (Field $field): Blocks { + try { + $blocks = Blocks::parse($field->value()); + $blocks = Blocks::factory($blocks, [ + 'parent' => $field->parent(), + 'field' => $field, + ]); + return $blocks->filter('isHidden', false); + } catch (Throwable) { + $message = 'Invalid blocks data for "' . $field->key() . '" field'; + + if ($parent = $field->parent()) { + $message .= ' on parent "' . $parent->title() . '"'; + } + + throw new InvalidArgumentException( + message: $message + ); + } + }, + + /** + * Converts the field value into a proper boolean + * + * @param bool $default Default value if the field is empty + */ + 'toBool' => function (Field $field, bool $default = false): bool { + $value = $field->isEmpty() ? $default : $field->value; + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }, + + /** + * Parses the field value with the given method + * + * @param string $method [',', 'yaml', 'json'] + */ + 'toData' => function (Field $field, string $method = ','): array { + return match ($method) { + 'yaml', 'json' => Data::decode($field->value, $method), + default => $field->split($method) + }; + }, + + /** + * Converts the field value to a timestamp or a formatted date + * + * @param string|\IntlDateFormatter|null $format PHP date formatting string + * @param string|null $fallback Fallback string for `strtotime` + */ + 'toDate' => function ( + Field $field, + string|IntlDateFormatter|null $format = null, + string|null $fallback = null + ) use ($app): string|int|null { + if (empty($field->value) === true && $fallback === null) { + return null; + } + + if (empty($field->value) === false) { + $time = $field->toTimestamp(); + } else { + $time = strtotime($fallback); + } + + 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 + */ + 'toFile' => function (Field $field): File|null { + return $field->toFiles()->first(); + }, + + /** + * Returns a file collection from a yaml list of filenames in the field + */ + 'toFiles' => function ( + Field $field, + string $separator = 'yaml' + ): Files { + $parent = $field->parent(); + $files = new Files([]); + + foreach ($field->toData($separator) as $id) { + if (is_string($id) === true && $file = $parent->kirby()->file($id, $parent)) { + $files->add($file); + } + } + + return $files; + }, + + /** + * Converts the field value into a proper float + * + * @param float $default Default value if the field is empty + */ + 'toFloat' => function (Field $field, float $default = 0): float { + $value = $field->isEmpty() ? $default : $field->value; + return (float)$value; + }, + + /** + * Converts the field value into a proper integer + * + * @param int $default Default value if the field is empty + */ + 'toInt' => function (Field $field, int $default = 0): int { + $value = $field->isEmpty() ? $default : $field->value; + return (int)$value; + }, + + /** + * Parse layouts and turn them into Layout objects + */ + 'toLayouts' => function (Field $field): Layouts { + return Layouts::factory(Layouts::parse($field->value()), [ + 'parent' => $field->parent(), + 'field' => $field, + ]); + }, + + /** + * Wraps a link tag around the field value. The field value is used as the link text + * + * @param mixed $attr1 Can be an optional Url. If no Url is set, the Url of the Page, File or Site will be used. Can also be an array of link attributes + * @param mixed $attr2 If `$attr1` is used to set the Url, you can use `$attr2` to pass an array of additional attributes. + */ + 'toLink' => function ( + Field $field, + string|array|null $attr1 = null, + array|null $attr2 = null + ): string { + if (is_string($attr1) === true) { + $href = $attr1; + $attr = $attr2; + } else { + $href = $field->parent()->url(); + $attr = $attr1; + } + + if ($field->parent()->isActive()) { + $attr['aria-current'] = 'page'; + } + + return Html::a($href, $field->value, $attr ?? []); + }, + + /** + * Parse yaml data and convert it to a + * content object + */ + 'toObject' => function (Field $field): Content { + return new Content($field->yaml(), $field->parent(), true); + }, + + /** + * Returns a page object from a page id in the field + */ + 'toPage' => function (Field $field): Page|null { + return $field->toPages()->first(); + }, + + /** + * Returns a pages collection from a yaml list of page ids in the field + * + * @param string $separator Can be any other separator to split the field value by + */ + 'toPages' => function ( + Field $field, + string $separator = 'yaml' + ) use ($app): Pages { + return $app->site()->find( + false, + false, + ...$field->toData($separator) + ); + }, + + /** + * Turns the field value into an QR code object + */ + 'toQrCode' => function (Field $field): QrCode|null { + return $field->isNotEmpty() ? new QrCode($field->value) : null; + }, + + /** + * Converts a yaml field to a Structure object + */ + 'toStructure' => function (Field $field): Structure { + try { + return Structure::factory( + Data::decode($field->value, 'yaml'), + ['parent' => $field->parent(), 'field' => $field] + ); + } catch (Exception) { + $message = 'Invalid structure data for "' . $field->key() . '" field'; + + if ($parent = $field->parent()) { + $message .= ' on parent "' . $parent->id() . '"'; + } + + throw new InvalidArgumentException( + message: $message + ); + } + }, + + /** + * Converts the field value to a Unix timestamp + */ + 'toTimestamp' => function (Field $field): int|false { + return strtotime($field->value ?? ''); + }, + + /** + * Turns the field value into an absolute Url + */ + 'toUrl' => function (Field $field): string|null { + try { + return $field->isNotEmpty() ? Url::to($field->value) : null; + } catch (NotFoundException) { + return null; + } + }, + + /** + * Converts a user email address to a user object + */ + 'toUser' => function (Field $field): User|null { + return $field->toUsers()->first(); + }, + + /** + * Returns a users collection from a yaml list + * of user email addresses in the field + */ + 'toUsers' => function ( + Field $field, + string $separator = 'yaml' + ) use ($app): Users { + return $app->users()->find( + false, + false, + ...$field->toData($separator) + ); + }, + + // inspectors + + /** + * Returns the length of the field content + */ + 'length' => function (Field $field): int { + return Str::length($field->value); + }, + + /** + * Returns the number of words in the text + */ + 'words' => function (Field $field): int { + return str_word_count(strip_tags($field->value ?? '')); + }, + + // manipulators + + /** + * Applies the callback function to the field + * @since 3.4.0 + */ + 'callback' => function (Field $field, Closure $callback): mixed { + return $callback($field); + }, + + /** + * Escapes the field value to be safely used in HTML + * templates without the risk of XSS attacks + * + * @param string $context Location of output (`html`, `attr`, `js`, `css`, `url` or `xml`) + */ + 'escape' => function (Field $field, string $context = 'html'): Field { + $field->value = Str::esc($field->value ?? '', $context); + return $field; + }, + + /** + * Creates an excerpt of the field value without html + * or any other formatting. + */ + 'excerpt' => function ( + Field $field, + int $chars = 0, + bool $strip = true, + string $rep = ' …' + ): Field { + $field->value = Str::excerpt( + $field->kirbytext()->value(), + $chars, + $strip, + $rep + ); + return $field; + }, + + /** + * Converts the field content to valid HTML + */ + 'html' => function (Field $field): Field { + $field->value = Html::encode($field->value); + return $field; + }, + + /** + * Strips all block-level HTML elements from the field value, + * it can be safely placed inside of other inline elements + * without the risk of breaking the HTML structure. + * @since 3.3.0 + */ + 'inline' => function (Field $field): Field { + // List of valid inline elements taken from: https://developer.mozilla.org/de/docs/Web/HTML/Inline_elemente + // Obsolete elements, script tags, image maps and form elements have + // been excluded for safety reasons and as they are most likely not + // needed in most cases. + $field->value = strip_tags($field->value ?? '', Html::$inlineList); + return $field; + }, + + /** + * Converts the field content from Markdown/Kirbytext to valid HTML + */ + 'kirbytext' => function ( + Field $field, + array $options = [] + ) use ($app): Field { + $field->value = $app->kirbytext($field->value, A::merge($options, [ + 'parent' => $field->parent(), + 'field' => $field + ])); + + return $field; + }, + + /** + * Converts the field content from inline Markdown/Kirbytext + * to valid HTML + * @since 3.1.0 + */ + 'kirbytextinline' => function ( + Field $field, + array $options = [] + ) use ($app): Field { + $field->value = $app->kirbytext($field->value, A::merge($options, [ + 'parent' => $field->parent(), + 'field' => $field, + 'markdown' => [ + 'inline' => true + ] + ])); + + return $field; + }, + + /** + * Parses all KirbyTags without also parsing Markdown + */ + 'kirbytags' => function (Field $field) use ($app): Field { + $field->value = $app->kirbytags($field->value, [ + 'parent' => $field->parent(), + 'field' => $field + ]); + + return $field; + }, + + /** + * Converts the field content to lowercase + */ + 'lower' => function (Field $field): Field { + $field->value = Str::lower($field->value); + return $field; + }, + + /** + * Converts markdown to valid HTML + */ + 'markdown' => function ( + Field $field, + array $options = [] + ) use ($app): Field { + $field->value = $app->markdown($field->value, $options); + return $field; + }, + + /** + * Converts all line breaks in the field content to `
` tags. + * @since 3.3.0 + */ + 'nl2br' => function (Field $field): Field { + $field->value = nl2br($field->value ?? '', false); + return $field; + }, + + /** + * Parses the field value as DOM and replaces + * any permalinks in href/src attributes with + * the regular url + * + * This method is still experimental! You can use + * it to solve potential problems with permalinks + * already, but it might change in the future. + */ + 'permalinksToUrls' => function (Field $field): Field { + if ($field->isNotEmpty() === true) { + $dom = new Dom($field->value); + $attributes = ['href', 'src']; + $elements = $dom->query('//*[' . implode(' | ', A::map($attributes, fn ($attribute) => '@' . $attribute)) . ']'); + + foreach ($elements as $element) { + foreach ($attributes as $attribute) { + if ($element->hasAttribute($attribute) && $uuid = $element->getAttribute($attribute)) { + try { + if ($url = Uuid::for($uuid)?->model()?->url()) { + $element->setAttribute($attribute, $url); + } + } catch (InvalidArgumentException) { + // ignore anything else than permalinks + } + } + } + } + + $field->value = $dom->toString(); + } + + return $field; + }, + + /** + * Uses the field value as Kirby query + */ + 'query' => function ( + Field $field, + string|null $expect = null + ) use ($app): mixed { + if ($parent = $field->parent()) { + return $parent->query($field->value, $expect); + } + + return Str::query($field->value, [ + 'kirby' => $app, + 'site' => $app->site(), + 'page' => $app->page() + ]); + }, + + /** + * It parses any queries found in the field value. + * + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced (`null` to keep the original token) + */ + 'replace' => function ( + Field $field, + array $data = [], + string|null $fallback = '' + ) use ($app): Field { + if ($parent = $field->parent()) { + // never pass `null` as the $template to avoid the fallback to the model ID + $field->value = $parent->toString($field->value ?? '', $data, $fallback); + } else { + $field->value = Str::template($field->value, array_replace([ + 'kirby' => $app, + 'site' => $app->site(), + 'page' => $app->page() + ], $data), ['fallback' => $fallback]); + } + + return $field; + }, + + /** + * Cuts the string after the given length and + * adds "…" if it is longer + * + * @param int $length The number of characters in the string + * @param string $appendix An optional replacement for the missing rest + */ + 'short' => function ( + Field $field, + int $length, + string $appendix = '…' + ): Field { + $field->value = Str::short($field->value, $length, $appendix); + return $field; + }, + + /** + * Converts the field content to a slug + */ + 'slug' => function (Field $field): Field { + $field->value = Str::slug($field->value); + return $field; + }, + + /** + * Applies SmartyPants to the field + */ + 'smartypants' => function (Field $field) use ($app): Field { + $field->value = $app->smartypants($field->value); + return $field; + }, + + /** + * Splits the field content into an array + */ + 'split' => function (Field $field, $separator = ','): array { + return Str::split((string)$field->value, $separator); + }, + + /** + * Converts the field content to uppercase + */ + 'upper' => function (Field $field): Field { + $field->value = Str::upper($field->value); + return $field; + }, + + /** + * Avoids typographical widows in strings by replacing + * the last space with ` ` + */ + 'widont' => function (Field $field): Field { + $field->value = Str::widont($field->value); + return $field; + }, + + /** + * Converts the field content to valid XML + */ + 'xml' => function (Field $field): Field { + $field->value = Xml::encode($field->value); + return $field; + }, + + // aliases + + /** + * Parses yaml in the field content and returns an array + */ + 'yaml' => function (Field $field): array { + return $field->toData('yaml'); + }, + + ]; +}; diff --git a/public/kirby/config/presets/files.php b/public/kirby/config/presets/files.php new file mode 100644 index 0000000..aefc535 --- /dev/null +++ b/public/kirby/config/presets/files.php @@ -0,0 +1,27 @@ + [ + 'label' => $props['label'] ?? $props['headline'] ?? I18n::translate('files'), + 'type' => 'files', + 'layout' => $props['layout'] ?? 'cards', + 'template' => $props['template'] ?? null, + 'image' => $props['image'] ?? null, + 'info' => '{{ file.dimensions }}' + ] + ]; + + // remove global options + unset( + $props['headline'], + $props['label'], + $props['layout'], + $props['template'], + $props['image'] + ); + + return $props; +}; diff --git a/public/kirby/config/presets/page.php b/public/kirby/config/presets/page.php new file mode 100644 index 0000000..a2102ef --- /dev/null +++ b/public/kirby/config/presets/page.php @@ -0,0 +1,74 @@ + $props + ]; + } + + return array_replace_recursive($defaults, $props); + }; + + if (empty($props['sidebar']) === false) { + $sidebar = $props['sidebar']; + } else { + $sidebar = []; + + $pages = $props['pages'] ?? []; + $files = $props['files'] ?? []; + + if ($pages !== false) { + $sidebar['pages'] = $section([ + 'label' => I18n::translate('pages'), + 'type' => 'pages', + 'status' => 'all', + 'layout' => 'list', + ], $pages); + } + + if ($files !== false) { + $sidebar['files'] = $section([ + 'label' => I18n::translate('files'), + 'type' => 'files', + 'layout' => 'list' + ], $files); + } + } + + if (empty($sidebar) === true) { + $props['fields'] ??= []; + + unset( + $props['files'], + $props['pages'] + ); + } else { + $props['columns'] = [ + [ + 'width' => '2/3', + 'fields' => $props['fields'] ?? [] + ], + [ + 'width' => '1/3', + 'sections' => $sidebar + ], + ]; + + unset( + $props['fields'], + $props['files'], + $props['pages'], + $props['sidebar'] + ); + } + + return $props; +}; diff --git a/public/kirby/config/presets/pages.php b/public/kirby/config/presets/pages.php new file mode 100644 index 0000000..517f84d --- /dev/null +++ b/public/kirby/config/presets/pages.php @@ -0,0 +1,75 @@ + $label, + 'type' => 'pages', + 'layout' => 'list', + 'status' => $status + ]; + + if ($props === true) { + $props = []; + } + + if (is_string($props) === true) { + $props = [ + 'label' => $props + ]; + } + + // inject the global templates definition + if (empty($templates) === false) { + $props['templates'] ??= $templates; + } + + return array_replace_recursive($defaults, $props); + }; + + $sections = []; + + $drafts = $props['drafts'] ?? []; + $unlisted = $props['unlisted'] ?? false; + $listed = $props['listed'] ?? []; + + + if ($drafts !== false) { + $sections['drafts'] = $section( + I18n::translate('pages.status.draft'), + 'drafts', + $drafts + ); + } + + if ($unlisted !== false) { + $sections['unlisted'] = $section( + I18n::translate('pages.status.unlisted'), + 'unlisted', + $unlisted + ); + } + + if ($listed !== false) { + $sections['listed'] = $section( + I18n::translate('pages.status.listed'), + 'listed', + $listed + ); + } + + // cleaning up + unset( + $props['drafts'], + $props['unlisted'], + $props['listed'], + $props['templates'] + ); + + return [...$props, 'sections' => $sections]; +}; diff --git a/public/kirby/config/routes.php b/public/kirby/config/routes.php new file mode 100644 index 0000000..e03a187 --- /dev/null +++ b/public/kirby/config/routes.php @@ -0,0 +1,195 @@ +option('api.slug', 'api'); + $panel = $kirby->option('panel.slug', 'panel'); + $index = $kirby->url('index'); + $media = $kirby->url('media'); + + if (Str::startsWith($media, $index) === true) { + $media = Str::after($media, $index); + } else { + // media URL is outside of the site, we can't make routing work; + // fall back to the standard media route + $media = 'media'; + } + + /** + * Before routes are running before the + * plugin routes and cannot be overwritten by + * plugins. + */ + $before = [ + [ + 'pattern' => $api . '/(:all)', + 'method' => 'ALL', + 'env' => 'api', + 'action' => function (string|null $path = null) use ($kirby) { + if ($kirby->option('api') === false) { + return null; + } + + $request = $kirby->request(); + + return $kirby->api()->render($path, $this->method(), [ + 'body' => $request->body()->toArray(), + 'files' => $request->files()->toArray(), + 'headers' => $request->headers(), + 'query' => $request->query()->toArray(), + ]); + } + ], + [ + 'pattern' => $media . '/plugins/index.(css|js)', + 'env' => 'media', + 'action' => function (string $type) use ($kirby) { + $plugins = new Plugins(); + + return $kirby + ->response() + ->type($type) + ->body($plugins->read($type)); + } + ], + [ + // TODO: change to '/plugins/(:any)/(:any)/(:any)/(:all)' once + // the hash is made mandatory + 'pattern' => $media . '/plugins/(:any)/(:any)/(?:(:any)/)?(:all)', + 'env' => 'media', + 'action' => function ( + string $provider, + string $pluginName, + string $hash, + string $path + ) { + return Assets::resolve( + $provider . '/' . $pluginName, + $hash, + $path + ); + } + ], + [ + 'pattern' => $media . '/pages/(:all)/(:any)/(:any)', + 'env' => 'media', + 'action' => function ( + string $path, + string $hash, + string $filename + ) use ($kirby) { + return Media::link($kirby->page($path), $hash, $filename); + } + ], + [ + 'pattern' => $media . '/site/(:any)/(:any)', + 'env' => 'media', + 'action' => function ( + string $hash, + string $filename + ) use ($kirby) { + return Media::link($kirby->site(), $hash, $filename); + } + ], + [ + 'pattern' => $media . '/users/(:any)/(:any)/(:any)', + 'env' => 'media', + 'action' => function ( + string $id, + string $hash, + string $filename + ) use ($kirby) { + return Media::link($kirby->user($id), $hash, $filename); + } + ], + [ + 'pattern' => $media . '/assets/(:all)/(:any)/(:any)', + 'env' => 'media', + 'action' => function ( + string $path, + string $hash, + string $filename + ) { + return Media::thumb($path, $hash, $filename); + } + ], + [ + 'pattern' => $panel . '/(:all?)', + 'method' => 'ALL', + 'env' => 'panel', + 'action' => function (string|null $path = null) { + return Panel::router($path); + } + ], + // permalinks for page/file UUIDs + [ + 'pattern' => '@/(page|file)/(:all)', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function (string $type, string $id) use ($kirby) { + // try to resolve to model, but only from UUID cache; + // this ensures that only existing UUIDs can be queried + // and attackers can't force Kirby to go through the whole + // site index with a non-existing UUID + if ($model = Uuid::for($type . '://' . $id)?->model(true)) { + return $kirby + ->response() + ->redirect($model->url()); + } + + // render the error page + return false; + } + ], + ]; + + // Multi-language setup + if ($kirby->multilang() === true) { + $after = LanguageRoutes::create($kirby); + } else { + // Single-language home + $after[] = [ + 'pattern' => '', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function () use ($kirby) { + return $kirby->resolve(); + } + ]; + + // redirect the home page folder to the real homepage + $after[] = [ + 'pattern' => $kirby->option('home', 'home'), + 'method' => 'ALL', + 'env' => 'site', + 'action' => function () use ($kirby) { + return $kirby + ->response() + ->redirect($kirby->site()->url()); + } + ]; + + // Single-language subpages + $after[] = [ + 'pattern' => '(:all)', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function (string $path) use ($kirby) { + return $kirby->resolve($path); + } + ]; + } + + return [ + 'before' => $before, + 'after' => $after + ]; +}; diff --git a/public/kirby/config/sections/fields.php b/public/kirby/config/sections/fields.php new file mode 100644 index 0000000..dd72579 --- /dev/null +++ b/public/kirby/config/sections/fields.php @@ -0,0 +1,34 @@ + [ + 'fields' => function (array $fields = []) { + return $fields; + } + ], + 'computed' => [ + 'form' => function () { + return new Form( + fields: $this->fields, + model: $this->model, + language: 'current' + ); + }, + 'fields' => function () { + return $this->form->fields()->toProps(); + } + ], + 'methods' => [ + 'errors' => function () { + $this->form->fill($this->model->content('current')->toArray()); + return $this->form->errors(); + } + ], + 'toArray' => function () { + return [ + 'fields' => $this->fields, + ]; + } +]; diff --git a/public/kirby/config/sections/files.php b/public/kirby/config/sections/files.php new file mode 100644 index 0000000..b362849 --- /dev/null +++ b/public/kirby/config/sections/files.php @@ -0,0 +1,207 @@ + [ + 'batch', + 'details', + 'empty', + 'headline', + 'help', + 'layout', + 'min', + 'max', + 'pagination', + 'parent', + 'search', + 'sort' + ], + 'props' => [ + /** + * Filters pages by a query. Sorting will be disabled + */ + 'query' => function (string|null $query = null) { + return $query; + }, + /** + * Filters all files by template and also sets the template, which will be used for all uploads + */ + 'template' => function (string|null $template = null) { + return $template; + }, + /** + * Setup for the main text in the list or cards. By default this will display the filename. + */ + 'text' => function ($text = '{{ file.filename }}') { + return I18n::translate($text, $text); + } + ], + 'computed' => [ + 'accept' => function () { + if ($this->template) { + $file = new File([ + 'filename' => 'tmp', + 'parent' => $this->model(), + 'template' => $this->template + ]); + + return $file->blueprint()->acceptAttribute(); + } + + return null; + }, + 'parent' => function () { + return $this->parentModel(); + }, + 'collector' => function () { + return $this->collector ??= new FilesCollector( + flip: $this->flip(), + limit: $this->limit(), + page: $this->page() ?? 1, + parent: $this->parent(), + query: $this->query(), + search: $this->searchterm(), + sortBy: $this->sortBy(), + template: $this->template(), + ); + }, + 'models' => function () { + return $this->collector()->models(); + }, + 'modelsPaginated' => function () { + return $this->collector()->models(paginated: true); + }, + 'files' => function () { + return $this->models; + }, + 'data' => function () { + $data = []; + $dragTextIsAbsolute = $this->model->is($this->parent) === false; + + foreach ($this->modelsPaginated() as $file) { + $item = (new FileItem( + file: $file, + dragTextIsAbsolute: $dragTextIsAbsolute, + image: $this->image, + layout: $this->layout, + info: $this->info, + text: $this->text, + ))->props(); + + if ($this->layout === 'table') { + $item = $this->columnsValues($item, $file); + } + + $data[] = $item; + } + + return $data; + }, + 'total' => function () { + return $this->models()->count(); + }, + 'errors' => function () { + $errors = []; + + if ($this->validateMax() === false) { + $errors['max'] = I18n::template('error.section.files.max.' . I18n::form($this->max), [ + 'max' => $this->max, + 'section' => $this->headline + ]); + } + + if ($this->validateMin() === false) { + $errors['min'] = I18n::template('error.section.files.min.' . I18n::form($this->min), [ + 'min' => $this->min, + 'section' => $this->headline + ]); + } + + if (empty($errors) === true) { + return []; + } + + return [ + $this->name => [ + 'label' => $this->headline, + 'message' => $errors, + ] + ]; + }, + 'pagination' => function () { + return $this->pagination(); + }, + 'upload' => function () { + if ($this->isFull() === true) { + return false; + } + + $settings = new Upload( + api: $this->parent->apiUrl(true) . '/files', + accept: $this->accept, + max: $this->max ? $this->max - $this->total : null, + preview: $this->image, + sort: $this->sortable === true ? $this->total + 1 : null, + template: $this->template, + ); + + return $settings->props(); + } + ], + // @codeCoverageIgnoreStart + 'api' => function () { + return [ + [ + 'pattern' => 'sort', + 'method' => 'PATCH', + 'action' => function () { + $this->section()->model()->files()->changeSort( + $this->requestBody('files'), + $this->requestBody('index') + ); + + return true; + } + ], + [ + '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' => [ + 'accept' => $this->accept, + 'apiUrl' => $this->parent->apiUrl(true) . '/sections/' . $this->name, + 'batch' => $this->batch, + 'columns' => $this->columnsWithTypes(), + 'empty' => $this->empty, + 'headline' => $this->headline, + 'help' => $this->help, + 'layout' => $this->layout, + 'link' => $this->link(), + 'max' => $this->max, + 'min' => $this->min, + 'search' => $this->search, + 'size' => $this->size, + 'sortable' => $this->sortable, + 'upload' => $this->upload + ], + 'pagination' => $this->pagination + ]; + } +]; diff --git a/public/kirby/config/sections/info.php b/public/kirby/config/sections/info.php new file mode 100644 index 0000000..20a288d --- /dev/null +++ b/public/kirby/config/sections/info.php @@ -0,0 +1,37 @@ + [ + 'headline', + ], + 'props' => [ + 'icon' => function (string|null $icon = null) { + return $icon; + }, + 'text' => function ($text = null) { + return I18n::translate($text, $text); + }, + 'theme' => function (string|null $theme = null) { + return $theme; + } + ], + 'computed' => [ + 'text' => function () { + if ($this->text) { + $text = $this->model()->toSafeString($this->text); + $text = $this->kirby()->kirbytext($text); + return $text; + } + }, + ], + 'toArray' => function () { + return [ + 'icon' => $this->icon, + 'label' => $this->headline, + 'text' => $this->text, + 'theme' => $this->theme + ]; + } +]; 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/details.php b/public/kirby/config/sections/mixins/details.php new file mode 100644 index 0000000..3d2c928 --- /dev/null +++ b/public/kirby/config/sections/mixins/details.php @@ -0,0 +1,36 @@ + [ + /** + * Image options to control the source and look of preview + */ + 'image' => function ($image = null) { + return $image ?? []; + }, + /** + * Optional info text setup. Info text is shown on the right (lists, cardlets) or below (cards) the title. + */ + 'info' => function ($info = null) { + return I18n::translate($info, $info); + }, + /** + * Setup for the main text in the list or cards. By default this will display the title. + */ + 'text' => function ($text = '{{ model.title }}') { + return I18n::translate($text, $text); + } + ], + 'methods' => [ + 'link' => function () { + $modelLink = $this->model->panel()->url(true); + $parentLink = $this->parent->panel()->url(true); + + if ($modelLink !== $parentLink) { + return $parentLink; + } + } + ] +]; diff --git a/public/kirby/config/sections/mixins/empty.php b/public/kirby/config/sections/mixins/empty.php new file mode 100644 index 0000000..97c2404 --- /dev/null +++ b/public/kirby/config/sections/mixins/empty.php @@ -0,0 +1,21 @@ + [ + /** + * Sets the text for the empty state box + */ + 'empty' => function ($empty = null) { + return I18n::translate($empty, $empty); + } + ], + 'computed' => [ + 'empty' => function () { + if ($this->empty) { + return $this->model()->toSafeString($this->empty); + } + } + ] +]; diff --git a/public/kirby/config/sections/mixins/headline.php b/public/kirby/config/sections/mixins/headline.php new file mode 100644 index 0000000..ebc2bb7 --- /dev/null +++ b/public/kirby/config/sections/mixins/headline.php @@ -0,0 +1,36 @@ + [ + /** + * The headline for the section. This can be a simple string or a template with additional info from the parent page. + * @deprecated 3.8.0 Use `label` instead + */ + 'headline' => function ($headline = null) { + return I18n::translate($headline, $headline); + }, + /** + * The label for the section. This can be a simple string or + * a template with additional info from the parent page. + * Replaces the `headline` prop. + */ + 'label' => function ($label = null) { + return I18n::translate($label, $label); + } + ], + 'computed' => [ + 'headline' => function () { + if ($this->label) { + return $this->model()->toString($this->label); + } + + if ($this->headline) { + return $this->model()->toString($this->headline); + } + + return ucfirst($this->name); + } + ] +]; diff --git a/public/kirby/config/sections/mixins/help.php b/public/kirby/config/sections/mixins/help.php new file mode 100644 index 0000000..c95db08 --- /dev/null +++ b/public/kirby/config/sections/mixins/help.php @@ -0,0 +1,23 @@ + [ + /** + * Sets the help text + */ + 'help' => function ($help = null) { + return I18n::translate($help, $help); + } + ], + 'computed' => [ + 'help' => function () { + if ($this->help) { + $help = $this->model()->toSafeString($this->help); + $help = $this->kirby()->kirbytext($help); + return $help; + } + } + ] +]; diff --git a/public/kirby/config/sections/mixins/layout.php b/public/kirby/config/sections/mixins/layout.php new file mode 100644 index 0000000..1cd41bf --- /dev/null +++ b/public/kirby/config/sections/mixins/layout.php @@ -0,0 +1,178 @@ + [ + /** + * Columns config for `layout: table` + */ + 'columns' => function (array|null $columns = null) { + return $columns ?? []; + }, + /** + * Section layout. + * Available layout methods: `list`, `cardlets`, `cards`, `table`. + */ + 'layout' => function (string $layout = 'list') { + $layouts = ['list', 'cardlets', 'cards', 'table']; + 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 + * + * @todo remove when Form classes have been refactored + */ + 'rawvalues' => function (bool $rawvalues = false) { + return $rawvalues; + }, + /** + * The size option controls the size of cards. By default cards are auto-sized and the cards grid will always fill the full width. With a size you can disable auto-sizing. Available sizes: `tiny`, `small`, `medium`, `large`, `huge`, `full` + */ + 'size' => function (string $size = 'auto') { + return $size; + }, + ], + 'computed' => [ + 'columns' => function () { + $columns = []; + + if ($this->layout !== 'table') { + return []; + } + + if ($this->image !== false) { + $columns['image'] = [ + 'label' => ' ', + 'mobile' => true, + 'type' => 'image', + 'width' => 'var(--table-row-height)' + ]; + } + + if ($this->text) { + $columns['title'] = [ + 'label' => I18n::translate('title'), + 'mobile' => true, + 'type' => 'url', + ]; + } + + if ($this->info) { + $columns['info'] = [ + 'label' => I18n::translate('info'), + 'type' => 'text', + ]; + } + + foreach ($this->columns as $columnName => $column) { + if ($column === true) { + $column = []; + } + + if ($column === false) { + continue; + } + + // fallback for labels + $column['label'] ??= Str::ucfirst($columnName); + + // make sure to translate labels + $column['label'] = I18n::translate($column['label'], $column['label']); + + // keep the original column name as id + $column['id'] = $columnName; + + // add the custom column to the array + // allowing to extend/overwrite existing columns + $columns[$columnName] = [ + ...$columns[$columnName] ?? [], + ...$column + ]; + } + + if ($this->type === 'pages') { + $columns['flag'] = [ + 'label' => ' ', + 'mobile' => true, + 'type' => 'flag', + 'width' => 'var(--table-row-height)', + ]; + } + + return $columns; + }, + ], + 'methods' => [ + 'columnsWithTypes' => function () { + $columns = $this->columns; + + // add the type to the columns for the table layout + if ($this->layout === 'table') { + $blueprint = $this->models->first()?->blueprint(); + + if ($blueprint === null) { + return $columns; + } + + foreach ($columns as $columnName => $column) { + if ($id = $column['id'] ?? null) { + $columns[$columnName]['type'] ??= $blueprint->field($id)['type'] ?? null; + } + } + } + + return $columns; + }, + 'columnsValues' => function (array $item, ModelWithContent $model) { + $item['title'] = [ + // override toSafeString() coming from `$item` + // because the table cells don't use v-html + 'text' => $model->toString($this->text), + 'href' => $model->panel()->url(true) + ]; + + if ($this->info) { + // override toSafeString() coming from `$item` + // because the table cells don't use v-html + $item['info'] = $model->toString($this->info); + } + + // if forcing raw values, get those directly from content file + // TODO: remove once Form classes have been refactored + // @codeCoverageIgnoreStart + if ($this->rawvalues === true) { + foreach ($this->columns as $columnName => $column) { + $item[$columnName] = match (empty($column['value'])) { + // if column value defined, resolve the query + false => $model->toString($column['value']), + // otherwise use the form value, + // but don't overwrite columns + default => $item[$columnName] ?? $model->content()->get($column['id'] ?? $columnName)->value() + }; + } + + return $item; + } + // @codeCoverageIgnoreEnd + + // Use form to get the proper values for the columns + $form = Form::for($model)->values(); + + foreach ($this->columns as $columnName => $column) { + $item[$columnName] = match (empty($column['value'])) { + // if column value defined, resolve the query + false => $model->toString($column['value']), + // otherwise use the form value, + // but don't overwrite columns + default => $item[$columnName] ?? $form[$column['id'] ?? $columnName] ?? null + }; + } + + return $item; + } + ], +]; diff --git a/public/kirby/config/sections/mixins/max.php b/public/kirby/config/sections/mixins/max.php new file mode 100644 index 0000000..b49c627 --- /dev/null +++ b/public/kirby/config/sections/mixins/max.php @@ -0,0 +1,28 @@ + [ + /** + * Sets the maximum number of allowed entries in the section + */ + 'max' => function (int|null $max = null) { + return $max; + } + ], + 'methods' => [ + 'isFull' => function () { + if ($this->max) { + return $this->total >= $this->max; + } + + return false; + }, + 'validateMax' => function () { + if ($this->max && $this->total > $this->max) { + return false; + } + + return true; + } + ] +]; diff --git a/public/kirby/config/sections/mixins/min.php b/public/kirby/config/sections/mixins/min.php new file mode 100644 index 0000000..40fa82e --- /dev/null +++ b/public/kirby/config/sections/mixins/min.php @@ -0,0 +1,21 @@ + [ + /** + * Sets the minimum number of required entries in the section + */ + 'min' => function (int|null $min = null) { + return $min; + } + ], + 'methods' => [ + 'validateMin' => function () { + if ($this->min && $this->min > $this->total) { + return false; + } + + return true; + } + ] +]; diff --git a/public/kirby/config/sections/mixins/pagination.php b/public/kirby/config/sections/mixins/pagination.php new file mode 100644 index 0000000..39f8d0a --- /dev/null +++ b/public/kirby/config/sections/mixins/pagination.php @@ -0,0 +1,37 @@ + [ + /** + * Sets the number of items per page. If there are more items the pagination navigation will be shown at the bottom of the section. + */ + 'limit' => function (int $limit = 20) { + return $limit; + }, + /** + * Sets the default page for the pagination. + */ + 'page' => function (int|null $page = null) { + return App::instance()->request()->get('page', $page); + }, + ], + 'methods' => [ + 'pagination' => function () { + $pagination = new Pagination([ + 'limit' => $this->limit, + 'page' => $this->page, + 'total' => $this->total + ]); + + return [ + 'limit' => $pagination->limit(), + 'offset' => $pagination->offset(), + 'page' => $pagination->page(), + 'total' => $pagination->total(), + ]; + } + ] +]; diff --git a/public/kirby/config/sections/mixins/parent.php b/public/kirby/config/sections/mixins/parent.php new file mode 100644 index 0000000..463e643 --- /dev/null +++ b/public/kirby/config/sections/mixins/parent.php @@ -0,0 +1,51 @@ + [ + /** + * Sets the query to a parent to find items for the list + */ + 'parent' => function (string|null $parent = null) { + return $parent; + } + ], + 'methods' => [ + 'parentModel' => function () { + $parent = $this->parent; + + if (is_string($parent) === true) { + $query = $parent; + $parent = $this->model->query($query); + + if (!$parent) { + throw new Exception( + message: 'The parent for the query "' . $query . '" cannot be found in the section "' . $this->name() . '"' + ); + } + + if ( + $parent instanceof Page === false && + $parent instanceof Site === false && + $parent instanceof File === false && + $parent instanceof User === false + ) { + throw new Exception( + message: 'The parent for the section "' . $this->name() . '" has to be a page, site or user object' + ); + } + } + + if ($parent === null) { + return $this->model; + } + + return $parent; + } + ] +]; diff --git a/public/kirby/config/sections/mixins/search.php b/public/kirby/config/sections/mixins/search.php new file mode 100644 index 0000000..05fb50f --- /dev/null +++ b/public/kirby/config/sections/mixins/search.php @@ -0,0 +1,23 @@ + [ + /** + * Enable/disable the search in the sections + */ + 'search' => function (bool $search = false): bool { + return $search; + } + ], + 'methods' => [ + 'searchterm' => function (): string|null { + if ($this->search() === true) { + return App::instance()->request()->get('searchterm') ?? null; + } + + return null; + } + ] +]; diff --git a/public/kirby/config/sections/mixins/sort.php b/public/kirby/config/sections/mixins/sort.php new file mode 100644 index 0000000..185cc6d --- /dev/null +++ b/public/kirby/config/sections/mixins/sort.php @@ -0,0 +1,57 @@ + [ + /** + * Enables/disables reverse sorting + */ + 'flip' => function (bool $flip = false) { + return $flip; + }, + /** + * Enables/disables manual sorting + */ + 'sortable' => function (bool $sortable = true) { + return $sortable; + }, + /** + * Overwrites manual sorting and sorts by the given field and sorting direction (i.e. `date desc`) + */ + 'sortBy' => function (string|null $sortBy = null) { + return $sortBy; + }, + ], + 'computed' => [ + 'sortable' => function () { + if ($this->sortable === false) { + return false; + } + + if ( + $this->type === 'pages' && + in_array($this->status, ['listed', 'published', 'all'], true) === false + ) { + return false; + } + + // don't allow sorting while search filter is active + if (empty($this->searchterm()) === false) { + return false; + } + + if ($this->query !== null) { + return false; + } + + if ($this->sortBy !== null) { + return false; + } + + if ($this->flip === true) { + return false; + } + + return true; + } + ] +]; diff --git a/public/kirby/config/sections/pages.php b/public/kirby/config/sections/pages.php new file mode 100644 index 0000000..cfa39aa --- /dev/null +++ b/public/kirby/config/sections/pages.php @@ -0,0 +1,288 @@ + [ + 'batch', + 'details', + 'empty', + 'headline', + 'help', + 'layout', + 'min', + 'max', + 'pagination', + 'parent', + 'search', + 'sort' + ], + 'props' => [ + /** + * Optional array of templates that should only be allowed to add + * or `false` to completely disable page creation + */ + 'create' => function ($create = null) { + return $create; + }, + /** + * Filters pages by a query. Sorting will be disabled + */ + 'query' => function (string|null $query = null) { + return $query; + }, + /** + * Filters pages by their status. Available status settings: `draft`, `unlisted`, `listed`, `published`, `all`. + */ + 'status' => function (string $status = '') { + if ($status === 'drafts') { + $status = 'draft'; + } + + if (in_array($status, ['all', 'draft', 'published', 'listed', 'unlisted'], true) === false) { + $status = 'all'; + } + + return $status; + }, + /** + * Filters the list by single template. + */ + 'template' => function (string|array|null $template = null) { + return $template; + }, + /** + * Filters the list by templates and sets template options when adding new pages to the section. + */ + 'templates' => function ($templates = null) { + return A::wrap($templates ?? $this->template); + }, + /** + * Excludes the selected templates. + */ + 'templatesIgnore' => function ($templates = null) { + return A::wrap($templates); + } + ], + 'computed' => [ + 'parent' => function () { + $parent = $this->parentModel(); + + if ( + $parent instanceof Site === false && + $parent instanceof Page === false + ) { + throw new InvalidArgumentException( + message: 'The parent is invalid. You must choose the site or a page as parent.' + ); + } + + return $parent; + }, + 'collector' => function () { + return $this->collector ??= new PagesCollector( + limit: $this->limit(), + page: $this->page() ?? 1, + parent: $this->parent(), + query: $this->query(), + status: $this->status(), + templates: $this->templates(), + templatesIgnore: $this->templatesIgnore(), + search: $this->searchterm(), + sortBy: $this->sortBy(), + flip: $this->flip() + ); + }, + 'models' => function () { + return $this->collector()->models(); + }, + 'modelsPaginated' => function () { + return $this->collector()->models(paginated: true); + }, + 'pages' => function () { + return $this->models(); + }, + 'total' => function () { + return $this->models()->count(); + }, + 'data' => function () { + $data = []; + + foreach ($this->modelsPaginated() as $page) { + $item = (new PageItem( + page: $page, + image: $this->image, + layout: $this->layout, + info: $this->info, + text: $this->text, + ))->props(); + + if ($this->layout === 'table') { + $item = $this->columnsValues($item, $page); + } + + $data[] = $item; + } + + return $data; + }, + 'errors' => function () { + $errors = []; + + if ($this->validateMax() === false) { + $errors['max'] = I18n::template('error.section.pages.max.' . I18n::form($this->max), [ + 'max' => $this->max, + 'section' => $this->headline + ]); + } + + if ($this->validateMin() === false) { + $errors['min'] = I18n::template('error.section.pages.min.' . I18n::form($this->min), [ + 'min' => $this->min, + 'section' => $this->headline + ]); + } + + if (empty($errors) === true) { + return []; + } + + return [ + $this->name => [ + 'label' => $this->headline, + 'message' => $errors, + ] + ]; + }, + 'add' => function () { + if ($this->create === false) { + return false; + } + + if ($this->isFull() === true) { + return false; + } + + // form here on, we need to check with which status + // the pages are created and if the section can show + // these newly created pages + + // if the section shows pages no matter what status they have, + // we can always show the add button + if ($this->status === 'all') { + return true; + } + + // collect all statuses of the blueprints + // that are allowed to be created + $statuses = []; + + foreach ($this->blueprintNames() as $blueprint) { + try { + $props = Blueprint::load('pages/' . $blueprint); + $statuses[] = $props['create']['status'] ?? 'draft'; + } catch (Throwable) { + $statuses[] = 'draft'; // @codeCoverageIgnore + } + } + + $statuses = array_unique($statuses); + + // if there are multiple statuses or if the section is showing + // a different status than new pages would be created with, + // we cannot show the add button + if (count($statuses) > 1 || $this->status !== $statuses[0]) { + return false; + } + + return true; + }, + 'pagination' => function () { + return $this->pagination(); + } + ], + 'methods' => [ + 'blueprints' => function () { + $blueprints = []; + + // convert every template to a usable option array + // for the template select box + foreach ($this->blueprintNames() as $blueprint) { + try { + $props = Blueprint::load('pages/' . $blueprint); + + $blueprints[] = [ + 'name' => basename($props['name']), + 'title' => $props['title'], + ]; + } catch (Throwable) { + $blueprints[] = [ + 'name' => basename($blueprint), + 'title' => ucfirst($blueprint), + ]; + } + } + + return $blueprints; + }, + 'blueprintNames' => function () { + $blueprints = empty($this->create) === false ? A::wrap($this->create) : $this->templates; + + if (empty($blueprints) === true) { + $blueprints = $this->kirby()->blueprints(); + } + + // excludes ignored templates + if ($templatesIgnore = $this->templatesIgnore) { + $blueprints = array_diff($blueprints, $templatesIgnore); + } + + 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, + 'help' => $this->help, + 'layout' => $this->layout, + 'link' => $this->link(), + 'max' => $this->max, + 'min' => $this->min, + 'search' => $this->search, + 'size' => $this->size, + 'sortable' => $this->sortable + ], + 'pagination' => $this->pagination, + ]; + } +]; diff --git a/public/kirby/config/sections/stats.php b/public/kirby/config/sections/stats.php new file mode 100644 index 0000000..f5e06fd --- /dev/null +++ b/public/kirby/config/sections/stats.php @@ -0,0 +1,38 @@ + [ + 'headline', + ], + 'props' => [ + /** + * Array or query string for reports. Each report needs a `label` and `value` and can have additional `info`, `link`, `icon` and `theme` settings. + */ + 'reports' => function (array|string|null $reports = null) { + return $reports ?? []; + }, + /** + * The size of the report cards. Available sizes: `tiny`, `small`, `medium`, `large` + */ + 'size' => function (string $size = 'large') { + return $size; + } + ], + 'computed' => [ + 'stats' => function (): Stats { + return $this->stats ??= Stats::from( + model: $this->model(), + reports: $this->reports(), + size: $this->size() + ); + }, + 'reports' => function (): array { + return $this->stats->reports(); + }, + 'size' => function (): string { + return $this->stats->size(); + } + ] +]; diff --git a/public/kirby/config/setup.php b/public/kirby/config/setup.php new file mode 100644 index 0000000..09b73bb --- /dev/null +++ b/public/kirby/config/setup.php @@ -0,0 +1,31 @@ + [ + 'attr' => [], + 'html' => function (KirbyTag $tag): string { + if (strtolower($tag->date) === 'year') { + return date('Y'); + } + + return date($tag->date); + } + ], + + /** + * Email + */ + 'email' => [ + 'attr' => [ + 'class', + 'rel', + 'target', + 'text', + 'title' + ], + 'html' => function (KirbyTag $tag): string { + return Html::email($tag->value, $tag->text, [ + 'class' => $tag->class, + 'rel' => $tag->rel, + 'target' => $tag->target, + 'title' => $tag->title, + ]); + } + ], + + /** + * File + */ + 'file' => [ + 'attr' => [ + 'class', + 'download', + 'rel', + 'target', + 'text', + 'title' + ], + 'html' => function (KirbyTag $tag): string { + if (!$file = $tag->file($tag->value)) { + return $tag->text ?? $tag->value; + } + + // use filename if the text is empty and make sure to + // ignore markdown italic underscores in filenames + if (empty($tag->text) === true) { + $tag->text = str_replace('_', '\_', $file->filename()); + } + + return Html::a($file->url(), $tag->text, [ + 'class' => $tag->class, + 'download' => $tag->download !== 'false', + 'rel' => $tag->rel, + 'target' => $tag->target, + 'title' => $tag->title, + ]); + } + ], + + /** + * Gist + */ + 'gist' => [ + 'attr' => [ + 'file' + ], + 'html' => function (KirbyTag $tag): string { + return Html::gist($tag->value, $tag->file); + } + ], + + /** + * Image + */ + 'image' => [ + 'attr' => [ + 'alt', + 'caption', + 'class', + 'height', + 'imgclass', + 'link', + 'linkclass', + 'rel', + 'srcset', + 'target', + 'title', + 'width' + ], + 'html' => function (KirbyTag $tag): string { + $kirby = $tag->kirby(); + + $tag->width ??= $kirby->option('kirbytext.image.width'); + $tag->height ??= $kirby->option('kirbytext.image.height'); + + if ($tag->file = $tag->file($tag->value)) { + $tag->src = $tag->file->url(); + $tag->alt ??= $tag->file->alt()->or('')->value(); + $tag->title ??= $tag->file->title()->value(); + $tag->caption ??= $tag->file->caption()->value(); + + if ($srcset = $tag->srcset) { + $srcset = Str::split($srcset); + $srcset = match (count($srcset) > 1) { + // comma-separated list of sizes + true => A::map($srcset, fn ($size) => (int)trim($size)), + // srcset config name + default => $srcset[0] + }; + + $tag->srcset = $tag->file->srcset($srcset); + } + + if ($tag->width === 'auto') { + $tag->width = $tag->file->width(); + } + if ($tag->height === 'auto') { + $tag->height = $tag->file->height(); + } + } else { + $tag->src = Url::to($tag->value); + } + + $link = function ($img) use ($tag) { + if (empty($tag->link) === true) { + return $img; + } + + $link = $tag->file($tag->link)?->url(); + $link ??= $tag->link === 'self' ? $tag->src : $tag->link; + + return Html::a($link, [$img], [ + 'rel' => $tag->rel, + 'class' => $tag->linkclass, + 'target' => $tag->target + ]); + }; + + $image = Html::img($tag->src, [ + 'srcset' => $tag->srcset, + 'width' => $tag->width, + 'height' => $tag->height, + 'class' => $tag->imgclass, + 'title' => $tag->title, + 'alt' => $tag->alt ?? '' + ]); + + if ($kirby->option('kirbytext.image.figure', true) === false) { + return $link($image); + } + + // render KirbyText in caption + if ($tag->caption) { + $options = ['markdown' => ['inline' => true]]; + $caption = $kirby->kirbytext($tag->caption, $options); + $tag->caption = [$caption]; + } + + return Html::figure([$link($image)], $tag->caption, [ + 'class' => $tag->class + ]); + } + ], + + /** + * Link + */ + 'link' => [ + 'attr' => [ + 'class', + 'lang', + 'rel', + 'role', + 'target', + 'title', + 'text', + ], + 'html' => function (KirbyTag $tag): string { + if (empty($tag->lang) === false) { + $tag->value = Url::to($tag->value, $tag->lang); + } + + // if value is a UUID, resolve to page/file model + // and use the URL as value + if (Uuid::is($tag->value, ['page', 'file']) === true) { + $tag->value = Uuid::for($tag->value)?->toUrl(); + } + + // 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) { + $error .= ' for the link text "' . $tag->text . '"'; + } + + throw new NotFoundException( + message: $error + ); + } + + $tag->value = Url::to($tag->kirby()->site()->errorPageId()); + } + + return Html::a($tag->value, $tag->text, [ + 'rel' => $tag->rel, + 'class' => $tag->class, + 'role' => $tag->role, + 'title' => $tag->title, + 'target' => $tag->target, + ]); + } + ], + + /** + * Tel + */ + 'tel' => [ + 'attr' => [ + 'class', + 'rel', + 'text', + 'title' + ], + 'html' => function (KirbyTag $tag): string { + return Html::tel($tag->value, $tag->text, [ + 'class' => $tag->class, + 'rel' => $tag->rel, + 'title' => $tag->title + ]); + } + ], + + /** + * Video + */ + 'video' => [ + 'attr' => [ + 'autoplay', + 'caption', + 'controls', + 'class', + 'disablepictureinpicture', + 'height', + 'loop', + 'muted', + 'playsinline', + 'poster', + 'preload', + 'style', + 'width', + ], + 'html' => function (KirbyTag $tag): string { + // checks and gets if poster is local file + if ( + empty($tag->poster) === false && + Str::startsWith($tag->poster, 'http://') !== true && + Str::startsWith($tag->poster, 'https://') !== true + ) { + if ($poster = $tag->file($tag->poster)) { + $tag->poster = $poster->url(); + } + } + + // checks video is local or provider(remote) + $isLocalVideo = ( + Str::startsWith($tag->value, 'http://') !== true && + Str::startsWith($tag->value, 'https://') !== true + ); + $isProviderVideo = ( + $isLocalVideo === false && + ( + Str::contains($tag->value, 'youtu', true) === true || + Str::contains($tag->value, 'vimeo', true) === true + ) + ); + + // default attributes for local and remote videos + $attrs = [ + 'height' => $tag->height, + 'width' => $tag->width + ]; + + // don't use attributes that iframe doesn't support + if ($isProviderVideo === false) { + // convert tag attributes to supported formats (bool, string) + // to output correct html attributes + // + // for ex: + // `autoplay` will not work if `false` is a string + // instead of a boolean + $attrs['autoplay'] = $autoplay = Str::toType($tag->autoplay, 'bool'); + $attrs['controls'] = Str::toType($tag->controls ?? true, 'bool'); + $attrs['disablepictureinpicture'] = Str::toType($tag->disablepictureinpicture ?? false, 'bool'); + $attrs['loop'] = Str::toType($tag->loop, 'bool'); + $attrs['muted'] = Str::toType($tag->muted ?? $autoplay, 'bool'); + $attrs['playsinline'] = Str::toType($tag->playsinline ?? $autoplay, 'bool'); + $attrs['poster'] = $tag->poster; + $attrs['preload'] = $tag->preload; + } + + // handles local and remote video file + if ($isLocalVideo === true) { + // handles local video file + if ($tag->file = $tag->file($tag->value)) { + $source = Html::tag('source', '', [ + 'src' => $tag->file->url(), + 'type' => $tag->file->mime() + ]); + $video = Html::tag('video', [$source], $attrs); + } + } else { + $video = Html::video( + $tag->value, + $tag->kirby()->option('kirbytext.video.options', []), + $attrs + ); + } + + return Html::figure([$video ?? ''], $tag->caption, [ + 'class' => $tag->class ?? 'video', + 'style' => $tag->style + ]); + } + ], + +]; diff --git a/public/kirby/config/templates/emails/auth/login.php b/public/kirby/config/templates/emails/auth/login.php new file mode 100644 index 0000000..cacea18 --- /dev/null +++ b/public/kirby/config/templates/emails/auth/login.php @@ -0,0 +1,16 @@ +language() +); diff --git a/public/kirby/config/templates/emails/auth/password-reset.php b/public/kirby/config/templates/emails/auth/password-reset.php new file mode 100644 index 0000000..4480f31 --- /dev/null +++ b/public/kirby/config/templates/emails/auth/password-reset.php @@ -0,0 +1,16 @@ +language() +); diff --git a/public/kirby/dependencies/parsedown-extra/ParsedownExtra.php b/public/kirby/dependencies/parsedown-extra/ParsedownExtra.php new file mode 100644 index 0000000..390edd7 --- /dev/null +++ b/public/kirby/dependencies/parsedown-extra/ParsedownExtra.php @@ -0,0 +1,637 @@ +BlockTypes[':'] []= 'DefinitionList'; + $this->BlockTypes['*'] []= 'Abbreviation'; + + # identify footnote definitions before reference definitions + array_unshift($this->BlockTypes['['], 'Footnote'); + + # identify footnote markers before before links + array_unshift($this->InlineTypes['['], 'FootnoteMarker'); + } + + # + # ~ + + public function text($text) + { + $Elements = $this->textElements($text); + + # convert to markup + $markup = $this->elements($Elements); + + # trim line breaks + $markup = trim($markup, "\n"); + + # merge consecutive dl elements + + $markup = preg_replace('/<\/dl>\s+
\s+/', '', $markup); + + # add footnotes + + if (isset($this->DefinitionData['Footnote'])) { + $Element = $this->buildFootnoteElement(); + + $markup .= "\n" . $this->element($Element); + } + + return $markup; + } + + # + # Blocks + # + + # + # Abbreviation + + protected function blockAbbreviation($Line) + { + if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches)) { + $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2]; + + $Block = array( + 'hidden' => true, + ); + + return $Block; + } + } + + # + # Footnote + + protected function blockFootnote($Line) + { + if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches)) { + $Block = array( + 'label' => $matches[1], + 'text' => $matches[2], + 'hidden' => true, + ); + + return $Block; + } + } + + protected function blockFootnoteContinue($Line, $Block) + { + if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text'])) { + return; + } + + if (isset($Block['interrupted'])) { + if ($Line['indent'] >= 4) { + $Block['text'] .= "\n\n" . $Line['text']; + + return $Block; + } + } else { + $Block['text'] .= "\n" . $Line['text']; + + return $Block; + } + } + + protected function blockFootnoteComplete($Block) + { + $this->DefinitionData['Footnote'][$Block['label']] = array( + 'text' => $Block['text'], + 'count' => null, + 'number' => null, + ); + + return $Block; + } + + # + # Definition List + + protected function blockDefinitionList($Line, $Block) + { + if (! isset($Block) or $Block['type'] !== 'Paragraph') { + return; + } + + $Element = array( + 'name' => 'dl', + 'elements' => array(), + ); + + $terms = explode("\n", $Block['element']['handler']['argument']); + + foreach ($terms as $term) { + $Element['elements'] []= array( + 'name' => 'dt', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $term, + 'destination' => 'elements' + ), + ); + } + + $Block['element'] = $Element; + + $Block = $this->addDdElement($Line, $Block); + + return $Block; + } + + protected function blockDefinitionListContinue($Line, array $Block) + { + if ($Line['text'][0] === ':') { + $Block = $this->addDdElement($Line, $Block); + + return $Block; + } else { + if (isset($Block['interrupted']) and $Line['indent'] === 0) { + return; + } + + if (isset($Block['interrupted'])) { + $Block['dd']['handler']['function'] = 'textElements'; + $Block['dd']['handler']['argument'] .= "\n\n"; + + $Block['dd']['handler']['destination'] = 'elements'; + + unset($Block['interrupted']); + } + + $text = substr($Line['body'], min($Line['indent'], 4)); + + $Block['dd']['handler']['argument'] .= "\n" . $text; + + return $Block; + } + } + + # + # Header + + protected function blockHeader($Line) + { + $Block = parent::blockHeader($Line); + + if (preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) { + $attributeString = $matches[1][0]; + + $Block['element']['attributes'] = $this->parseAttributeData($attributeString); + + $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]); + } + + return $Block; + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) { + return; + } + + $Block = array( + 'name' => $matches[1], + 'depth' => 0, + 'element' => array( + 'rawHtml' => $Line['text'], + 'autobreak' => true, + ), + ); + + $length = strlen($matches[0]); + $remainder = substr($Line['text'], $length); + + if (trim($remainder) === '') { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) { + $Block['closed'] = true; + $Block['void'] = true; + } + } else { + if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) { + return; + } + if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) { + $Block['closed'] = true; + } + } + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed'])) { + return; + } + + if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) { # open + $Block['depth'] ++; + } + + if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) { # close + if ($Block['depth'] > 0) { + $Block['depth'] --; + } else { + $Block['closed'] = true; + } + } + + if (isset($Block['interrupted'])) { + $Block['element']['rawHtml'] .= "\n"; + unset($Block['interrupted']); + } + + $Block['element']['rawHtml'] .= "\n".$Line['body']; + + return $Block; + } + + protected function blockMarkupComplete($Block) + { + if (! isset($Block['void'])) { + $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']); + } + + return $Block; + } + + # + # Setext + + protected function blockSetextHeader($Line, array|null $Block = null) + { + $Block = parent::blockSetextHeader($Line, $Block); + + if (preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) { + $attributeString = $matches[1][0]; + + $Block['element']['attributes'] = $this->parseAttributeData($attributeString); + + $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]); + } + + return $Block; + } + + # + # Inline Elements + # + + # + # Footnote Marker + + protected function inlineFootnoteMarker($Excerpt) + { + if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) { + $name = $matches[1]; + + if (! isset($this->DefinitionData['Footnote'][$name])) { + return; + } + + $this->DefinitionData['Footnote'][$name]['count'] ++; + + if (! isset($this->DefinitionData['Footnote'][$name]['number'])) { + $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » & + } + + $Element = array( + 'name' => 'sup', + 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name), + 'element' => array( + 'name' => 'a', + 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'), + 'text' => $this->DefinitionData['Footnote'][$name]['number'], + ), + ); + + return array( + 'extent' => strlen($matches[0]), + 'element' => $Element, + ); + } + } + + private $footnoteCount = 0; + + # + # Link + + protected function inlineLink($Excerpt) + { + $Link = parent::inlineLink($Excerpt); + + $remainder = substr($Excerpt['text'], $Link['extent']); + + if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches)) { + $Link['element']['attributes'] += $this->parseAttributeData($matches[1]); + + $Link['extent'] += strlen($matches[0]); + } + + return $Link; + } + + # + # ~ + # + + private $currentAbreviation; + private $currentMeaning; + + protected function insertAbreviation(array $Element) + { + if (isset($Element['text'])) { + $Element['elements'] = self::pregReplaceElements( + '/\b'.preg_quote($this->currentAbreviation, '/').'\b/', + array( + array( + 'name' => 'abbr', + 'attributes' => array( + 'title' => $this->currentMeaning, + ), + 'text' => $this->currentAbreviation, + ) + ), + $Element['text'] + ); + + unset($Element['text']); + } + + return $Element; + } + + protected function inlineText($text) + { + $Inline = parent::inlineText($text); + + if (isset($this->DefinitionData['Abbreviation'])) { + foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning) { + $this->currentAbreviation = $abbreviation; + $this->currentMeaning = $meaning; + + $Inline['element'] = $this->elementApplyRecursiveDepthFirst( + array($this, 'insertAbreviation'), + $Inline['element'] + ); + } + } + + return $Inline; + } + + # + # Util Methods + # + + protected function addDdElement(array $Line, array $Block) + { + $text = substr($Line['text'], 1); + $text = trim($text); + + unset($Block['dd']); + + $Block['dd'] = array( + 'name' => 'dd', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $text, + 'destination' => 'elements' + ), + ); + + if (isset($Block['interrupted'])) { + $Block['dd']['handler']['function'] = 'textElements'; + + unset($Block['interrupted']); + } + + $Block['element']['elements'] []= & $Block['dd']; + + return $Block; + } + + protected function buildFootnoteElement() + { + $Element = array( + 'name' => 'div', + 'attributes' => array('class' => 'footnotes'), + 'elements' => array( + array('name' => 'hr'), + array( + 'name' => 'ol', + 'elements' => array(), + ), + ), + ); + + uasort($this->DefinitionData['Footnote'], [$this,'sortFootnotes']); + + foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData) { + if (! isset($DefinitionData['number'])) { + continue; + } + + $text = $DefinitionData['text']; + + $textElements = parent::textElements($text); + + $numbers = range(1, $DefinitionData['count']); + + $backLinkElements = array(); + + foreach ($numbers as $number) { + $backLinkElements[] = array('text' => ' '); + $backLinkElements[] = array( + 'name' => 'a', + 'attributes' => array( + 'href' => "#fnref$number:$definitionId", + 'rev' => 'footnote', + 'class' => 'footnote-backref', + ), + 'rawHtml' => '↩', + 'allowRawHtmlInSafeMode' => true, + 'autobreak' => false, + ); + } + + unset($backLinkElements[0]); + + $n = count($textElements) -1; + + if ($textElements[$n]['name'] === 'p') { + $backLinkElements = array_merge( + array( + array( + 'rawHtml' => ' ', + 'allowRawHtmlInSafeMode' => true, + ), + ), + $backLinkElements + ); + + unset($textElements[$n]['name']); + + $textElements[$n] = array( + 'name' => 'p', + 'elements' => array_merge( + array($textElements[$n]), + $backLinkElements + ), + ); + } else { + $textElements[] = array( + 'name' => 'p', + 'elements' => $backLinkElements + ); + } + + $Element['elements'][1]['elements'] []= array( + 'name' => 'li', + 'attributes' => array('id' => 'fn:'.$definitionId), + 'elements' => array_merge( + $textElements + ), + ); + } + + return $Element; + } + + # ~ + + protected function parseAttributeData($attributeString) + { + $Data = array(); + + $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY); + + foreach ($attributes as $attribute) { + if ($attribute[0] === '#') { + $Data['id'] = substr($attribute, 1); + } else { # "." + $classes []= substr($attribute, 1); + } + } + + if (isset($classes)) { + $Data['class'] = implode(' ', $classes); + } + + return $Data; + } + + # ~ + + protected function processTag($elementMarkup) # recursive + { + # http://stackoverflow.com/q/1148928/200145 + libxml_use_internal_errors(true); + + $DOMDocument = new DOMDocument(); + + // Migrating away from `mb_convert_encoding($elementMarkup, + //'HTML-ENTITIES', 'UTF-8');` has caused multibyte characters like + // emojis not to be converted into entities, which is needed so that + // the `DOM` extension can properly parse the markup. + // The following line works like this: It treats the input string + // as UTF-8 and converts every Unicode character with 8 or more bits + // (= character code starting at 128 or 0x80 up to the Unicode limit + // of 0x10ffff) to an entity; the third and fourth arguments for the + // map are not needed for our use case and are set to the default values + // (no offset and a full mask) + // [http://stackoverflow.com/q/11309194/200145] + $elementMarkup = mb_encode_numericentity($elementMarkup, [0x80, 0x10ffff, 0, 0xffffff], 'UTF-8'); + + # Ensure that saveHTML() is not remove new line characters. New lines will be split by this character. + $DOMDocument->formatOutput = true; + + # http://stackoverflow.com/q/4879946/200145 + $DOMDocument->loadHTML($elementMarkup); + $DOMDocument->removeChild($DOMDocument->doctype); + $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild); + + $elementText = ''; + + if ($DOMDocument->documentElement->getAttribute('markdown') === '1') { + foreach ($DOMDocument->documentElement->childNodes as $Node) { + $elementText .= $DOMDocument->saveHTML($Node); + } + + $DOMDocument->documentElement->removeAttribute('markdown'); + + $elementText = "\n".$this->text($elementText)."\n"; + } else { + foreach ($DOMDocument->documentElement->childNodes as $Node) { + $nodeMarkup = $DOMDocument->saveHTML($Node); + + if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements)) { + $elementText .= $this->processTag($nodeMarkup); + } else { + $elementText .= $nodeMarkup; + } + } + } + + # because we don't want for markup to get encoded + $DOMDocument->documentElement->nodeValue = 'placeholder\x1A'; + + $markup = $DOMDocument->saveHTML($DOMDocument->documentElement); + $markup = str_replace('placeholder\x1A', $elementText, $markup); + + return $markup; + } + + # ~ + + protected function sortFootnotes($A, $B) # callback + { + return $A['number'] - $B['number']; + } + + # + # Fields + # + + protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; +} diff --git a/public/kirby/dependencies/parsedown/Parsedown.php b/public/kirby/dependencies/parsedown/Parsedown.php new file mode 100644 index 0000000..76b2a7c --- /dev/null +++ b/public/kirby/dependencies/parsedown/Parsedown.php @@ -0,0 +1,1818 @@ +textElements($text); + + # convert to markup + $markup = $this->elements($Elements); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + protected function textElements($text) + { + # make sure no definitions are set + $this->DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + return $this->linesElements($lines); + } + + # + # Setters + # + + public function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + public function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + public function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + public function setSafeMode($safeMode) + { + $this->safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + public function setStrictMode($strictMode) + { + $this->strictMode = (bool) $strictMode; + + return $this; + } + + protected $strictMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + return $this->elements($this->linesElements($lines)); + } + + protected function linesElements(array $lines) + { + $Elements = array(); + $CurrentBlock = null; + + foreach ($lines as $line) { + if (chop($line) === '') { + if (isset($CurrentBlock)) { + $CurrentBlock['interrupted'] = ( + isset($CurrentBlock['interrupted']) + ? $CurrentBlock['interrupted'] + 1 : 1 + ); + } + + continue; + } + + while (($beforeTab = strstr($line, "\t", true)) !== false) { + $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; + + $line = $beforeTab + . str_repeat(' ', $shortage) + . substr($line, strlen($beforeTab) + 1) + ; + } + + $indent = strspn($line, ' '); + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) { + $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; + $Block = $this->$methodName($Line, $CurrentBlock); + + if (isset($Block)) { + $CurrentBlock = $Block; + + continue; + } elseif ($this->isBlockCompletable($CurrentBlock['type'])) { + $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) { + foreach ($this->BlockTypes[$marker] as $blockType) { + $blockTypes []= $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) { + $Block = $this->{"block$blockType"}($Line, $CurrentBlock); + + if (isset($Block)) { + $Block['type'] = $blockType; + + if (! isset($Block['identified'])) { + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') { + $Block = $this->paragraphContinue($Line, $CurrentBlock); + } + + if (isset($Block)) { + $CurrentBlock = $Block; + } else { + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) { + $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + + # ~ + + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + # ~ + + return $Elements; + } + + protected function extractElement(array $Component) + { + if (! isset($Component['element'])) { + if (isset($Component['markup'])) { + $Component['element'] = array('rawHtml' => $Component['markup']); + } elseif (isset($Component['hidden'])) { + $Component['element'] = array(); + } + } + + return $Component['element']; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block' . $Type . 'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block' . $Type . 'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) { + return; + } + + if ($Line['indent'] >= 4) { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) { + if (isset($Block['interrupted'])) { + $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); + + unset($Block['interrupted']); + } + + $Block['element']['element']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['element']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (strpos($Line['text'], '') !== false) { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) { + return; + } + + $Block['element']['rawHtml'] .= "\n" . $Line['body']; + + if (strpos($Line['text'], '-->') !== false) { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + $marker = $Line['text'][0]; + + $openerLength = strspn($Line['text'], $marker); + + if ($openerLength < 3) { + return; + } + + $infostring = trim(substr($Line['text'], $openerLength), "\t "); + + if (strpos($infostring, '`') !== false) { + return; + } + + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if ($infostring !== '') { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); + + $Element['attributes'] = array('class' => "language-$language"); + } + + $Block = array( + 'char' => $marker, + 'openerLength' => $openerLength, + 'element' => array( + 'name' => 'pre', + 'element' => $Element, + ), + ); + + return $Block; + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) { + return; + } + + if (isset($Block['interrupted'])) { + $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); + + unset($Block['interrupted']); + } + + if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength'] + and chop(substr($Line['text'], $len), ' ') === '' + ) { + $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['element']['text'] .= "\n" . $Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + $level = strspn($Line['text'], '#'); + + if ($level > 6) { + return; + } + + $text = trim($Line['text'], '#'); + + if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') { + return; + } + + $text = trim($text, ' '); + + $Block = array( + 'element' => array( + 'name' => 'h' . min(6, $level), + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $text, + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + + # + # List + + protected function blockList($Line, array|null $CurrentBlock = null) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); + + if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) { + $contentIndent = strlen($matches[2]); + + if ($contentIndent >= 5) { + $contentIndent -= 1; + $matches[1] = substr($matches[1], 0, -$contentIndent); + $matches[3] = str_repeat(' ', $contentIndent) . $matches[3]; + } elseif ($contentIndent === 0) { + $matches[1] .= ' '; + } + + $markerWithoutWhitespace = strstr($matches[1], ' ', true); + + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'data' => array( + 'type' => $name, + 'marker' => $matches[1], + 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), + ), + 'element' => array( + 'name' => $name, + 'elements' => array(), + ), + ); + $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); + + if ($name === 'ol') { + $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; + + if ($listStart !== '1') { + if ( + isset($CurrentBlock) + and $CurrentBlock['type'] === 'Paragraph' + and ! isset($CurrentBlock['interrupted']) + ) { + return; + } + + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] []= & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) { + return null; + } + + $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); + + if ($Line['indent'] < $requiredIndent + and ( + ( + $Block['data']['type'] === 'ol' + and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) or ( + $Block['data']['type'] === 'ul' + and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) + ) + ) { + if (isset($Block['interrupted'])) { + $Block['li']['handler']['argument'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['indent'] = $Line['indent']; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => array($text), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] []= & $Block['li']; + + return $Block; + } elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) { + return null; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) { + return $Block; + } + + if ($Line['indent'] >= $requiredIndent) { + if (isset($Block['interrupted'])) { + $Block['li']['handler']['argument'] []= ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + $text = substr($Line['body'], $requiredIndent); + + $Block['li']['handler']['argument'] []= $text; + + return $Block; + } + + if (! isset($Block['interrupted'])) { + $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']); + + $Block['li']['handler']['argument'] []= $text; + + return $Block; + } + } + + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) { + foreach ($Block['element']['elements'] as &$li) { + if (end($li['handler']['argument']) !== '') { + $li['handler']['argument'] []= ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => array( + 'function' => 'linesElements', + 'argument' => (array) $matches[1], + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) { + return; + } + + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { + $Block['element']['handler']['argument'] []= $matches[1]; + + return $Block; + } + + if (! isset($Block['interrupted'])) { + $Block['element']['handler']['argument'] []= $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + $marker = $Line['text'][0]; + + if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') { + $Block = array( + 'element' => array( + 'name' => 'hr', + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, array|null $Block = null) + { + if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { + return; + } + + if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements)) { + return; + } + + $Block = array( + 'name' => $matches[1], + 'element' => array( + 'rawHtml' => $Line['text'], + 'autobreak' => true, + ), + ); + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed']) or isset($Block['interrupted'])) { + return; + } + + $Block['element']['rawHtml'] .= "\n" . $Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (strpos($Line['text'], ']') !== false + and preg_match('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) + ) { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => isset($matches[3]) ? $matches[3] : null, + ); + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'element' => array(), + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, array|null $Block = null) + { + if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { + return; + } + + if ( + strpos($Block['element']['handler']['argument'], '|') === false + and strpos($Line['text'], '|') === false + and strpos($Line['text'], ':') === false + or strpos($Block['element']['handler']['argument'], "\n") !== false + ) { + return; + } + + if (chop($Line['text'], ' -:|') !== '') { + return; + } + + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') { + return; + } + + $alignment = null; + + if ($dividerCell[0] === ':') { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments []= $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['handler']['argument']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + if (count($headerCells) !== count($alignments)) { + return; + } + + foreach ($headerCells as $index => $headerCell) { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $headerCell, + 'destination' => 'elements', + ) + ); + + if (isset($alignments[$index])) { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => "text-align: $alignment;", + ); + } + + $HeaderElements []= $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'elements' => array(), + ), + ); + + $Block['element']['elements'] []= array( + 'name' => 'thead', + ); + + $Block['element']['elements'] []= array( + 'name' => 'tbody', + 'elements' => array(), + ); + + $Block['element']['elements'][0]['elements'] []= array( + 'name' => 'tr', + 'elements' => $HeaderElements, + ); + + return $Block; + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) { + return; + } + + if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches); + + $cells = array_slice($matches[0], 0, count($Block['alignments'])); + + foreach ($cells as $index => $cell) { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $cell, + 'destination' => 'elements', + ) + ); + + if (isset($Block['alignments'][$index])) { + $Element['attributes'] = array( + 'style' => 'text-align: ' . $Block['alignments'][$index] . ';', + ); + } + + $Elements []= $Element; + } + + $Element = array( + 'name' => 'tr', + 'elements' => $Elements, + ); + + $Block['element']['elements'][1]['elements'] []= $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + return array( + 'type' => 'Paragraph', + 'element' => array( + 'name' => 'p', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $Line['text'], + 'destination' => 'elements', + ), + ), + ); + } + + protected function paragraphContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) { + return; + } + + $Block['element']['handler']['argument'] .= "\n".$Line['text']; + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!*_&[:<`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables = array()) + { + return $this->elements($this->lineElements($text, $nonNestables)); + } + + protected function lineElements($text, $nonNestables = array()) + { + $Elements = array(); + + $nonNestables = ( + empty($nonNestables) + ? array() + : array_combine($nonNestables, $nonNestables) + ); + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) { + $marker = $excerpt[0]; + + $markerPosition = strlen($text) - strlen($excerpt); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) { + # check to see if the current inline type is nestable in the current context + + if (isset($nonNestables[$inlineType])) { + continue; + } + + $Inline = $this->{"inline$inlineType"}($Excerpt); + + if (! isset($Inline)) { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) { + continue; + } + + # sets a default inline position + + if (! isset($Inline['position'])) { + $Inline['position'] = $markerPosition; + } + + # cause the new element to 'inherit' our non nestables + + + $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) + ? array_merge($Inline['element']['nonNestables'], $nonNestables) + : $nonNestables + ; + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + # compile the inline + $Elements[] = $this->extractElement($Inline); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + $text = substr($text, $markerPosition + 1); + } + + $InlineText = $this->inlineText($text); + $Elements[] = $InlineText['element']; + + foreach ($Elements as &$Element) { + if (! isset($Element['autobreak'])) { + $Element['autobreak'] = false; + } + } + + return $Elements; + } + + # + # ~ + # + + protected function inlineText($text) + { + $Inline = array( + 'extent' => strlen($text), + 'element' => array(), + ); + + $Inline['element']['elements'] = self::pregReplaceElements( + $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', + array( + array('name' => 'br'), + array('text' => "\n"), + ), + $text + ); + + return $Inline; + } + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; + + $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' + . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*'; + + if (strpos($Excerpt['text'], '>') !== false + and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) + ) { + $url = $matches[1]; + + if (! isset($matches[2])) { + $url = "mailto:$url"; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if (! isset($Excerpt['text'][1])) { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'strong'; + } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'em'; + } else { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) { + return array( + 'element' => array('rawHtml' => $Excerpt['text'][1]), + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if (! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') { + return; + } + + $Excerpt['text']= substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['handler']['argument'], + ), + 'autobreak' => true, + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => null, + 'destination' => 'elements', + ), + 'nonNestables' => array('Url', 'Link'), + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) { + $Element['handler']['argument'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } else { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } else { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) { + $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } else { + $definition = strtolower($Element['handler']['argument']); + } + + if (! isset($this->DefinitionData['Reference'][$definition])) { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if ($Excerpt['text'][1] !== ' ' and strpos($Excerpt['text'], ';') !== false + and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) + ) { + return array( + 'element' => array('rawHtml' => '&' . $matches[1] . ';'), + 'extent' => strlen($matches[0]), + ); + } + + return; + } + + protected function inlineStrikethrough($Excerpt) + { + if (! isset($Excerpt['text'][1])) { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') { + return; + } + + if (strpos($Excerpt['context'], 'http') !== false + and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) + ) { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + $Inline = $this->inlineText($text); + return $this->element($Inline['element']); + } + + # + # Handlers + # + + protected function handle(array $Element) + { + if (isset($Element['handler'])) { + if (!isset($Element['nonNestables'])) { + $Element['nonNestables'] = array(); + } + + if (is_string($Element['handler'])) { + $function = $Element['handler']; + $argument = $Element['text']; + unset($Element['text']); + $destination = 'rawHtml'; + } else { + $function = $Element['handler']['function']; + $argument = $Element['handler']['argument']; + $destination = $Element['handler']['destination']; + } + + $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']); + + if ($destination === 'handler') { + $Element = $this->handle($Element); + } + + unset($Element['handler']); + } + + return $Element; + } + + protected function handleElementRecursive(array $Element) + { + return $this->elementApplyRecursive(array($this, 'handle'), $Element); + } + + protected function handleElementsRecursive(array $Elements) + { + return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); + } + + protected function elementApplyRecursive($closure, array $Element) + { + $Element = call_user_func($closure, $Element); + + if (isset($Element['elements'])) { + $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); + } elseif (isset($Element['element'])) { + $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); + } + + return $Element; + } + + protected function elementApplyRecursiveDepthFirst($closure, array $Element) + { + if (isset($Element['elements'])) { + $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); + } elseif (isset($Element['element'])) { + $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); + } + + $Element = call_user_func($closure, $Element); + + return $Element; + } + + protected function elementsApplyRecursive($closure, array $Elements) + { + foreach ($Elements as &$Element) { + $Element = $this->elementApplyRecursive($closure, $Element); + } + + return $Elements; + } + + protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) + { + foreach ($Elements as &$Element) { + $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); + } + + return $Elements; + } + + protected function element(array $Element) + { + if ($this->safeMode) { + $Element = $this->sanitiseElement($Element); + } + + # identity map if element has no handler + $Element = $this->handle($Element); + + $hasName = isset($Element['name']); + + $markup = ''; + + if ($hasName) { + $markup .= '<' . $Element['name']; + + if (isset($Element['attributes'])) { + foreach ($Element['attributes'] as $name => $value) { + if ($value === null) { + continue; + } + + $markup .= " $name=\"".self::escape($value).'"'; + } + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) { + $text = $Element['rawHtml']; + + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']); + + if ($hasContent) { + $markup .= $hasName ? '>' : ''; + + if (isset($Element['elements'])) { + $markup .= $this->elements($Element['elements']); + } elseif (isset($Element['element'])) { + $markup .= $this->element($Element['element']); + } elseif (!$permitRawHtml) { + $markup .= self::escape($text, true); + } else { + $markup .= $text; + } + + $markup .= $hasName ? '' : ''; + } elseif ($hasName) { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + $autoBreak = true; + + foreach ($Elements as $Element) { + if (empty($Element)) { + continue; + } + + $autoBreakNext = ( + isset($Element['autobreak']) + ? $Element['autobreak'] : isset($Element['name']) + ); + // (autobreak === false) covers both sides of an element + $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext; + + $markup .= ($autoBreak ? "\n" : '') . $this->element($Element); + $autoBreak = $autoBreakNext; + } + + $markup .= $autoBreak ? "\n" : ''; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $Elements = $this->linesElements($lines); + + if (! in_array('', $lines) + and isset($Elements[0]) and isset($Elements[0]['name']) + and $Elements[0]['name'] === 'p' + ) { + unset($Elements[0]['name']); + } + + return $Elements; + } + + # + # AST Convenience + # + + /** + * Replace occurrences $regexp with $Elements in $text. Return an array of + * elements representing the replacement. + */ + protected static function pregReplaceElements($regexp, $Elements, $text) + { + $newElements = array(); + + while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) { + $offset = $matches[0][1]; + $before = substr($text, 0, $offset); + $after = substr($text, $offset + strlen($matches[0][0])); + + $newElements[] = array('text' => $before); + + foreach ($Elements as $Element) { + $newElements[] = $Element; + } + + $text = $after; + } + + $newElements[] = array('text' => $text); + + return $newElements; + } + + # + # Deprecated Methods + # + + public function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (! isset($Element['name'])) { + unset($Element['attributes']); + return $Element; + } + + if (isset($safeUrlNameToAtt[$Element['name']])) { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if (! empty($Element['attributes'])) { + foreach ($Element['attributes'] as $att => $val) { + # filter out badly parsed attribute + if (! preg_match($goodAttribute, $att)) { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) { + return false; + } else { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + public static function instance($name = 'default') + { + if (isset(self::$instances[$name])) { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/public/kirby/dependencies/spyc/COPYING b/public/kirby/dependencies/spyc/COPYING new file mode 100644 index 0000000..8e7ddbc --- /dev/null +++ b/public/kirby/dependencies/spyc/COPYING @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2011 Vladimir Andersen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/public/kirby/dependencies/spyc/Spyc.php b/public/kirby/dependencies/spyc/Spyc.php new file mode 100644 index 0000000..06a2270 --- /dev/null +++ b/public/kirby/dependencies/spyc/Spyc.php @@ -0,0 +1,1196 @@ + + * @author Chris Wanstrath + * @link https://github.com/mustangostang/spyc/ + * @copyright Copyright 2005-2006 Chris Wanstrath, 2006-2011 Vlad Andersen + * @license http://www.opensource.org/licenses/mit-license.php MIT License + * @package Spyc + */ +class Spyc +{ + // SETTINGS + + public const REMPTY = "\0\0\0\0\0"; + + /** + * Setting this to true will force YAMLDump to enclose any string value in + * quotes. False by default. + * + * @var bool + */ + public $setting_dump_force_quotes = false; + + /** + * Setting this to true will forse YAMLLoad to use syck_load function when + * possible. False by default. + * @var bool + */ + public $setting_use_syck_is_possible = false; + + /** + * Setting this to true will forse YAMLLoad to use syck_load function when + * possible. False by default. + * @var bool + */ + public $setting_empty_hash_as_object = false; + + /**#@+ + * @access private + * @var mixed + */ + private $_dumpIndent; + private $_dumpWordWrap; + private $_containsGroupAnchor = false; + private $_containsGroupAlias = false; + private $path; + private $result; + private $LiteralPlaceHolder = '___YAML_Literal_Block___'; + private $SavedGroups = array(); + private $indent; + /** + * Path modifier that should be applied after adding current element. + * @var array + */ + private $delayedPath = array(); + + /**#@+ + * @access public + * @var mixed + */ + public $_nodeId; + + /** + * Load a valid YAML string to Spyc. + * @param string $input + * @return array + */ + public function load($input) + { + return $this->_loadString($input); + } + + /** + * Load a valid YAML file to Spyc. + * @param string $file + * @return array + */ + public function loadFile($file) + { + return $this->_load($file); + } + + /** + * Load YAML into a PHP array statically + * + * The load method, when supplied with a YAML stream (string or file), + * will do its best to convert YAML in a file into a PHP array. Pretty + * simple. + * Usage: + * + * $array = Spyc::YAMLLoad('lucky.yaml'); + * print_r($array); + * + * @access public + * @param string $input Path of YAML file or string containing YAML + * @param array set options + * @return array + */ + public static function YAMLLoad($input, $options = []) + { + $Spyc = new Spyc(); + foreach ($options as $key => $value) { + if (property_exists($Spyc, $key)) { + $Spyc->$key = $value; + } + } + return $Spyc->_load($input); + } + + /** + * Load a string of YAML into a PHP array statically + * + * The load method, when supplied with a YAML string, will do its best + * to convert YAML in a string into a PHP array. Pretty simple. + * + * Note: use this function if you don't want files from the file system + * loaded and processed as YAML. This is of interest to people concerned + * about security whose input is from a string. + * + * Usage: + * + * $array = Spyc::YAMLLoadString("---\n0: hello world\n"); + * print_r($array); + * + * @access public + * @param string $input String containing YAML + * @param array set options + * @return array + */ + public static function YAMLLoadString($input, $options = []) + { + $Spyc = new Spyc(); + foreach ($options as $key => $value) { + if (property_exists($Spyc, $key)) { + $Spyc->$key = $value; + } + } + return $Spyc->_loadString($input); + } + + /** + * Dump YAML from PHP array statically + * + * The dump method, when supplied with an array, will do its best + * to convert the array into friendly YAML. Pretty simple. Feel free to + * save the returned string as nothing.yaml and pass it around. + * + * Oh, and you can decide how big the indent is and what the wordwrap + * for folding is. Pretty cool -- just pass in 'false' for either if + * you want to use the default. + * + * Indent's default is 2 spaces, wordwrap's default is 40 characters. And + * you can turn off wordwrap by passing in 0. + * + * @access public + * @param array|\stdClass $array PHP array + * @param int $indent Pass in false to use the default, which is 2 + * @param int $wordwrap Pass in 0 for no wordwrap, false for default (40) + * @param bool $no_opening_dashes Do not start YAML file with "---\n" + * @return string + */ + public static function YAMLDump($array, $indent = false, $wordwrap = false, $no_opening_dashes = false) + { + $spyc = new Spyc(); + return $spyc->dump($array, $indent, $wordwrap, $no_opening_dashes); + } + + /** + * Dump PHP array to YAML + * + * The dump method, when supplied with an array, will do its best + * to convert the array into friendly YAML. Pretty simple. Feel free to + * save the returned string as tasteful.yaml and pass it around. + * + * Oh, and you can decide how big the indent is and what the wordwrap + * for folding is. Pretty cool -- just pass in 'false' for either if + * you want to use the default. + * + * Indent's default is 2 spaces, wordwrap's default is 40 characters. And + * you can turn off wordwrap by passing in 0. + * + * @access public + * @param array $array PHP array + * @param int $indent Pass in false to use the default, which is 2 + * @param int $wordwrap Pass in 0 for no wordwrap, false for default (40) + * @return string + */ + public function dump($array, $indent = false, $wordwrap = false, $no_opening_dashes = false) + { + // Dumps to some very clean YAML. We'll have to add some more features + // and options soon. And better support for folding. + + // New features and options. + if ($indent === false or !is_numeric($indent)) { + $this->_dumpIndent = 2; + } else { + $this->_dumpIndent = $indent; + } + + if ($wordwrap === false or !is_numeric($wordwrap)) { + $this->_dumpWordWrap = 40; + } else { + $this->_dumpWordWrap = $wordwrap; + } + + // New YAML document + $string = ""; + if (!$no_opening_dashes) $string = "---\n"; + + // Start at the base of the array and move through it. + if ($array) { + $array = (array)$array; + $previous_key = -1; + foreach ($array as $key => $value) { + if (!isset($first_key)) $first_key = $key; + $string .= $this->_yamlize($key, $value, 0, $previous_key, $first_key, $array); + $previous_key = $key; + } + } + return $string; + } + + /** + * Attempts to convert a key / value array item to YAML + * @access private + * @param $key The name of the key + * @param $value The value of the item + * @param $indent The indent of the current node + * @return string + */ + private function _yamlize($key, $value, $indent, $previous_key = -1, $first_key = 0, $source_array = null) + { + if (is_object($value)) $value = (array)$value; + if (is_array($value)) { + if (empty ($value)) + return $this->_dumpNode($key, array(), $indent, $previous_key, $first_key, $source_array); + // It has children. What to do? + // Make it the right kind of item + $string = $this->_dumpNode($key, self::REMPTY, $indent, $previous_key, $first_key, $source_array); + // Add the indent + $indent += $this->_dumpIndent; + // Yamlize the array + $string .= $this->_yamlizeArray($value, $indent); + } elseif (!is_array($value)) { + // It doesn't have children. Yip. + $string = $this->_dumpNode($key, $value, $indent, $previous_key, $first_key, $source_array); + } + return $string; + } + + /** + * Attempts to convert an array to YAML + * @access private + * @param $array The array you want to convert + * @param $indent The indent of the current level + * @return string + */ + private function _yamlizeArray($array, $indent) + { + if (is_array($array)) { + $string = ''; + $previous_key = -1; + foreach ($array as $key => $value) { + if (!isset($first_key)) $first_key = $key; + $string .= $this->_yamlize($key, $value, $indent, $previous_key, $first_key, $array); + $previous_key = $key; + } + return $string; + } else { + return false; + } + } + + /** + * Returns YAML from a key and a value + * @access private + * @param $key The name of the key + * @param $value The value of the item + * @param $indent The indent of the current node + * @return string + */ + private function _dumpNode($key, $value, $indent, $previous_key = -1, $first_key = 0, $source_array = null) + { + // do some folding here, for blocks + if ( + is_string($value) && + ( + strpos($value, "\n") !== false || + strpos($value, ": ") !== false || + strpos($value, "- ") !== false || + strpos($value, "*") !== false || + strpos($value, "#") !== false || + strpos($value, "<") !== false || + strpos($value, ">") !== false || + strpos($value, '%') !== false || + strpos($value, ' ') !== false || + strpos($value, "[") !== false || + strpos($value, "]") !== false || + strpos($value, "{") !== false || + strpos($value, "}") !== false || + strpos($value, "&") !== false || + strpos($value, "'") !== false || + strpos($value, "!") === 0 || + substr($value, -1, 1) == ':' + ) + ) { + $value = $this->_doLiteralBlock($value, $indent); + } else { + $value = $this->_doFolding($value, $indent); + } + + if ($value === array()) $value = '[ ]'; + if ($value === "") $value = '""'; + if (self::isTranslationWord($value)) { + $value = $this->_doLiteralBlock($value, $indent); + } + if (trim($value ?? '') != $value) + $value = $this->_doLiteralBlock($value, $indent); + + if (is_bool($value)) { + $value = $value ? "true" : "false"; + } + + if ($value === null) $value = 'null'; + if ($value === "'" . self::REMPTY . "'") $value = null; + + $spaces = str_repeat(' ', $indent); + + //if (is_int($key) && $key - 1 == $previous_key && $first_key===0) { + if (is_array($source_array) && array_keys($source_array) === range(0, count($source_array) - 1)) { + // It's a sequence + $string = $spaces . '- ' . $value . "\n"; + } else { + // if ($first_key===0) throw new Exception('Keys are all screwy. The first one was zero, now it\'s "'. $key .'"'); + // It's mapped + if (strpos($key, ":") !== false || strpos($key, "#") !== false) { + $key = '"' . $key . '"'; + } + $string = rtrim($spaces . $key . ': ' . $value) . "\n"; + } + return $string; + } + + /** + * Creates a literal block for dumping + * @access private + * @param $value + * @param $indent int The value of the indent + * @return string + */ + private function _doLiteralBlock($value, $indent) + { + $value ??= ''; + + if ($value === "\n") return '\n'; + if (strpos($value, "\n") === false && strpos($value, "'") === false) { + return sprintf("'%s'", $value); + } + if (strpos($value, "\n") === false && strpos($value, '"') === false) { + return sprintf('"%s"', $value); + } + $exploded = explode("\n", $value); + $newValue = '|'; + if (isset($exploded[0]) && ($exploded[0] == "|" || $exploded[0] == "|-" || $exploded[0] == ">")) { + $newValue = $exploded[0]; + unset($exploded[0]); + } + $indent += $this->_dumpIndent; + $spaces = str_repeat(' ', $indent); + foreach ($exploded as $line) { + $line = trim($line); + if (strpos($line, '"') === 0 && strrpos($line, '"') == (strlen($line) - 1) || strpos($line, "'") === 0 && strrpos($line, "'") == (strlen($line) - 1)) { + $line = substr($line, 1, -1); + } + $newValue .= "\n" . $spaces . ($line); + } + return $newValue; + } + + /** + * Folds a string of text, if necessary + * @access private + * @param $value The string you wish to fold + * @return string + */ + private function _doFolding($value, $indent) + { + // Don't do anything if wordwrap is set to 0 + if ($this->_dumpWordWrap !== 0 && is_string($value) && strlen($value) > $this->_dumpWordWrap) { + $indent += $this->_dumpIndent; + $indent = str_repeat(' ', $indent); + $wrapped = wordwrap($value, $this->_dumpWordWrap, "\n$indent"); + $value = ">\n" . $indent . $wrapped; + } else { + if ($this->setting_dump_force_quotes && is_string($value) && $value !== self::REMPTY) + $value = '"' . $value . '"'; + if (is_numeric($value) && is_string($value)) + $value = '"' . $value . '"'; + } + + + return $value; + } + + private function isTrueWord($value) + { + $words = self::getTranslations(array('true', 'on', 'yes', 'y')); + return in_array($value, $words, true); + } + + private function isFalseWord($value) + { + $words = self::getTranslations(array('false', 'off', 'no', 'n')); + return in_array($value, $words, true); + } + + private function isNullWord($value) + { + $words = self::getTranslations(array('null', '~')); + return in_array($value, $words, true); + } + + private function isTranslationWord($value) + { + return ( + self::isTrueWord($value) || + self::isFalseWord($value) || + self::isNullWord($value) + ); + } + + /** + * Coerce a string into a native type + * Reference: http://yaml.org/type/bool.html + * TODO: Use only words from the YAML spec. + * @access private + * @param $value The value to coerce + */ + private function coerceValue(&$value) + { + if (self::isTrueWord($value)) { + $value = true; + } elseif (self::isFalseWord($value)) { + $value = false; + } elseif (self::isNullWord($value)) { + $value = null; + } + } + + /** + * Given a set of words, perform the appropriate translations on them to + * match the YAML 1.1 specification for type coercing. + * @param $words The words to translate + * @access private + */ + private static function getTranslations(array $words) + { + $result = array(); + foreach ($words as $i) { + $result = array_merge($result, array(ucfirst($i), strtoupper($i), strtolower($i))); + } + return $result; + } + + // LOADING FUNCTIONS + + private function _load($input) + { + $Source = $this->loadFromSource($input); + return $this->loadWithSource($Source); + } + + private function _loadString($input) + { + $Source = $this->loadFromString($input); + return $this->loadWithSource($Source); + } + + private function loadWithSource($Source) + { + if (empty ($Source)) return array(); + if ($this->setting_use_syck_is_possible && function_exists('syck_load')) { + $array = syck_load(implode("\n", $Source)); + return is_array($array) ? $array : array(); + } + + $this->path = array(); + $this->result = array(); + + $cnt = count($Source); + for ($i = 0; $i < $cnt; $i++) { + $line = $Source[$i]; + + $this->indent = strlen($line) - strlen(ltrim($line)); + $tempPath = $this->getParentPathByIndent($this->indent); + $line = self::stripIndent($line, $this->indent); + if (self::isComment($line)) continue; + if (self::isEmpty($line)) continue; + $this->path = $tempPath; + + $literalBlockStyle = self::startsLiteralBlock($line); + if ($literalBlockStyle) { + $line = rtrim($line, $literalBlockStyle . " \n"); + $literalBlock = ''; + $line .= ' ' . $this->LiteralPlaceHolder; + $literal_block_indent = strlen($Source[$i + 1]) - strlen(ltrim($Source[$i + 1])); + while (++$i < $cnt && $this->literalBlockContinues($Source[$i], $this->indent)) { + $literalBlock = $this->addLiteralLine($literalBlock, $Source[$i], $literalBlockStyle, $literal_block_indent); + } + $i--; + } + + // Strip out comments + if (strpos($line, '#')) { + $line = preg_replace('/\s*#([^"\']+)$/', '', $line); + } + + while (++$i < $cnt && self::greedilyNeedNextLine($line)) { + $line = rtrim($line, " \n\t\r") . ' ' . ltrim($Source[$i], " \t"); + } + $i--; + + $lineArray = $this->_parseLine($line); + + if ($literalBlockStyle) + $lineArray = $this->revertLiteralPlaceHolder($lineArray, $literalBlock); + + $this->addArray($lineArray, $this->indent); + + foreach ($this->delayedPath as $indent => $delayedPath) + $this->path[$indent] = $delayedPath; + + $this->delayedPath = array(); + + } + return $this->result; + } + + private function loadFromSource($input) + { + if (!empty($input) && strpos($input, "\n") === false && file_exists($input)) + $input = file_get_contents($input); + + return $this->loadFromString($input); + } + + private function loadFromString($input) + { + $lines = explode("\n", $input); + foreach ($lines as $k => $_) { + $lines[$k] = rtrim($_, "\r"); + } + return $lines; + } + + /** + * Parses YAML code and returns an array for a node + * @access private + * @param string $line A line from the YAML file + * @return array + */ + private function _parseLine($line) + { + if (!$line) return array(); + $line = trim($line); + if (!$line) return array(); + + $group = $this->nodeContainsGroup($line); + if ($group) { + $this->addGroup($line, $group); + $line = $this->stripGroup($line, $group); + } + + if ($this->startsMappedSequence($line)) { + return $this->returnMappedSequence($line); + } + + if ($this->startsMappedValue($line)) { + return $this->returnMappedValue($line); + } + + if ($this->isArrayElement($line)) + return $this->returnArrayElement($line); + + if ($this->isPlainArray($line)) + return $this->returnPlainArray($line); + + return $this->returnKeyValuePair($line); + } + + /** + * Finds the type of the passed value, returns the value as the new type. + * @access private + * @param string $value + * @return mixed + */ + private function _toType($value) + { + if ($value === '') return ""; + + if ($this->setting_empty_hash_as_object && $value === '{}') { + return new stdClass(); + } + + $first_character = $value[0]; + $last_character = substr($value, -1, 1); + + $is_quoted = false; + do { + if (!$value) break; + if ($first_character != '"' && $first_character != "'") break; + if ($last_character != '"' && $last_character != "'") break; + $is_quoted = true; + } while (0); + + if ($is_quoted) { + $value = str_replace('\n', "\n", $value); + if ($first_character == "'") + return strtr(substr($value, 1, -1), array('\'\'' => '\'', '\\\'' => '\'')); + return strtr(substr($value, 1, -1), array('\\"' => '"', '\\\'' => '\'')); + } + + if (strpos($value, ' #') !== false && !$is_quoted) + $value = preg_replace('/\s+#(.+)$/', '', $value); + + if ($first_character == '[' && $last_character == ']') { + // Take out strings sequences and mappings + $innerValue = trim(substr($value, 1, -1)); + if ($innerValue === '') return array(); + $explode = $this->_inlineEscape($innerValue); + // Propagate value array + $value = array(); + foreach ($explode as $v) { + $value[] = $this->_toType($v); + } + return $value; + } + + if (strpos($value, ': ') !== false && $first_character != '{') { + $array = explode(': ', $value); + $key = trim($array[0]); + array_shift($array); + $value = trim(implode(': ', $array)); + $value = $this->_toType($value); + return array($key => $value); + } + + if ($first_character == '{' && $last_character == '}') { + $innerValue = trim(substr($value, 1, -1)); + if ($innerValue === '') return array(); + // Inline Mapping + // Take out strings sequences and mappings + $explode = $this->_inlineEscape($innerValue); + // Propagate value array + $array = array(); + foreach ($explode as $v) { + $SubArr = $this->_toType($v); + if (empty($SubArr)) continue; + if (is_array($SubArr)) { + $array[key($SubArr)] = $SubArr[key($SubArr)]; + continue; + } + $array[] = $SubArr; + } + return $array; + } + + if ($value == 'null' || $value == 'NULL' || $value == 'Null' || $value == '' || $value == '~') { + return null; + } + + if (is_numeric($value) && preg_match('/^(-|)[1-9]+[0-9]*$/', $value)) { + $intvalue = (int)$value; + if ($intvalue != PHP_INT_MAX && $intvalue != ~PHP_INT_MAX) + $value = $intvalue; + return $value; + } + + if (is_string($value) && preg_match('/^0[xX][0-9a-fA-F]+$/', $value)) { + // Hexadecimal value. + return hexdec($value); + } + + $this->coerceValue($value); + + if (is_numeric($value)) { + if ($value === '0') return 0; + if (rtrim($value, 0) === $value) + $value = (float)$value; + return $value; + } + + return $value; + } + + /** + * Used in inlines to check for more inlines or quoted strings + * @access private + * @return array + */ + private function _inlineEscape($inline) + { + // There's gotta be a cleaner way to do this... + // While pure sequences seem to be nesting just fine, + // pure mappings and mappings with sequences inside can't go very + // deep. This needs to be fixed. + + $seqs = array(); + $maps = array(); + $saved_strings = array(); + $saved_empties = array(); + + // Check for empty strings + $regex = '/("")|(\'\')/'; + if (preg_match_all($regex, $inline, $strings)) { + $saved_empties = $strings[0]; + $inline = preg_replace($regex, 'YAMLEmpty', $inline); + } + unset($regex); + + // Check for strings + $regex = '/(?:(")|(?:\'))((?(1)[^"]+|[^\']+))(?(1)"|\')/'; + if (preg_match_all($regex, $inline, $strings)) { + $saved_strings = $strings[0]; + $inline = preg_replace($regex, 'YAMLString', $inline); + } + unset($regex); + + $i = 0; + do { + + // Check for sequences + while (preg_match('/\[([^{}\[\]]+)\]/U', $inline, $matchseqs)) { + $seqs[] = $matchseqs[0]; + $inline = preg_replace('/\[([^{}\[\]]+)\]/U', ('YAMLSeq' . (count($seqs) - 1) . 's'), $inline, 1); + } + + // Check for mappings + while (preg_match('/{([^\[\]{}]+)}/U', $inline, $matchmaps)) { + $maps[] = $matchmaps[0]; + $inline = preg_replace('/{([^\[\]{}]+)}/U', ('YAMLMap' . (count($maps) - 1) . 's'), $inline, 1); + } + + if ($i++ >= 10) break; + + } while (strpos($inline, '[') !== false || strpos($inline, '{') !== false); + + $explode = explode(',', $inline); + $explode = array_map('trim', $explode); + $stringi = 0; + $i = 0; + + while (1) { + + // Re-add the sequences + if (!empty($seqs)) { + foreach ($explode as $key => $value) { + if (strpos($value, 'YAMLSeq') !== false) { + foreach ($seqs as $seqk => $seq) { + $explode[$key] = str_replace(('YAMLSeq' . $seqk . 's'), $seq, $value); + $value = $explode[$key]; + } + } + } + } + + // Re-add the mappings + if (!empty($maps)) { + foreach ($explode as $key => $value) { + if (strpos($value, 'YAMLMap') !== false) { + foreach ($maps as $mapk => $map) { + $explode[$key] = str_replace(('YAMLMap' . $mapk . 's'), $map, $value); + $value = $explode[$key]; + } + } + } + } + + // Re-add the strings + if (!empty($saved_strings)) { + foreach ($explode as $key => $value) { + while (strpos($value, 'YAMLString') !== false) { + $explode[$key] = preg_replace('/YAMLString/', $saved_strings[$stringi], $value, 1); + unset($saved_strings[$stringi]); + ++$stringi; + $value = $explode[$key]; + } + } + } + + + // Re-add the empties + if (!empty($saved_empties)) { + foreach ($explode as $key => $value) { + while (strpos($value, 'YAMLEmpty') !== false) { + $explode[$key] = preg_replace('/YAMLEmpty/', '', $value, 1); + $value = $explode[$key]; + } + } + } + + $finished = true; + foreach ($explode as $key => $value) { + if (strpos($value, 'YAMLSeq') !== false) { + $finished = false; + break; + } + if (strpos($value, 'YAMLMap') !== false) { + $finished = false; + break; + } + if (strpos($value, 'YAMLString') !== false) { + $finished = false; + break; + } + if (strpos($value, 'YAMLEmpty') !== false) { + $finished = false; + break; + } + } + if ($finished) break; + + $i++; + if ($i > 10) + break; // Prevent infinite loops. + } + + return $explode; + } + + private function literalBlockContinues($line, $lineIndent) + { + if (!trim($line ?? '')) return true; + if (strlen($line) - strlen(ltrim($line)) > $lineIndent) return true; + return false; + } + + private function referenceContentsByAlias($alias) + { + do { + if (!isset($this->SavedGroups[$alias])) { + echo "Bad group name: $alias."; + break; + } + $groupPath = $this->SavedGroups[$alias]; + $value = $this->result; + foreach ($groupPath as $k) { + $value = $value[$k]; + } + } while (false); + return $value; + } + + private function addArrayInline($array, $indent) + { + $CommonGroupPath = $this->path; + if (empty ($array)) return false; + + foreach ($array as $k => $_) { + $this->addArray(array($k => $_), $indent); + $this->path = $CommonGroupPath; + } + return true; + } + + private function addArray($incoming_data, $incoming_indent) + { + if (count($incoming_data) > 1) + return $this->addArrayInline($incoming_data, $incoming_indent); + + $key = key($incoming_data); + $value = isset($incoming_data[$key]) ? $incoming_data[$key] : null; + if ($key === '__!YAMLZero') $key = '0'; + + if ($incoming_indent == 0 && !$this->_containsGroupAlias && !$this->_containsGroupAnchor) { // Shortcut for root-level values. + if ($key || $key === '' || $key === '0') { + $this->result[$key] = $value; + } else { + $this->result[] = $value; + end($this->result); + $key = key($this->result); + } + $this->path[$incoming_indent] = $key; + return; + } + + $history = array(); + // Unfolding inner array tree. + $history[] = $_arr = $this->result; + foreach ($this->path as $k) { + $history[] = $_arr = $_arr[$k]; + } + + if ($this->_containsGroupAlias) { + $value = $this->referenceContentsByAlias($this->_containsGroupAlias); + $this->_containsGroupAlias = false; + } + + + // Adding string or numeric key to the innermost level or $this->arr. + if (is_string($key) && $key == '<<') { + if (!is_array($_arr)) { + $_arr = array(); + } + + $_arr = array_merge($_arr, $value); + } elseif ($key || $key === '' || $key === '0') { + if (!is_array($_arr)) + $_arr = array($key => $value); + else + $_arr[$key] = $value; + } elseif (!is_array($_arr)) { + $_arr = array($value); + $key = 0; + } else { + $_arr[] = $value; + end($_arr); + $key = key($_arr); + } + + $reverse_path = array_reverse($this->path); + $reverse_history = array_reverse($history); + $reverse_history[0] = $_arr; + $cnt = count($reverse_history) - 1; + for ($i = 0; $i < $cnt; $i++) { + $reverse_history[$i + 1][$reverse_path[$i]] = $reverse_history[$i]; + } + $this->result = $reverse_history[$cnt]; + + $this->path[$incoming_indent] = $key; + + if ($this->_containsGroupAnchor) { + $this->SavedGroups[$this->_containsGroupAnchor] = $this->path; + if (is_array($value)) { + $k = key($value); + if (!is_int($k)) { + $this->SavedGroups[$this->_containsGroupAnchor][$incoming_indent + 2] = $k; + } + } + $this->_containsGroupAnchor = false; + } + + } + + private static function startsLiteralBlock($line) + { + $lastChar = substr(trim($line ?? ''), -1); + if ($lastChar != '>' && $lastChar != '|') return false; + if ($lastChar == '|') return $lastChar; + // HTML tags should not be counted as literal blocks. + if (preg_match('#<.*?>$#', $line)) return false; + return $lastChar; + } + + private static function greedilyNeedNextLine($line) + { + $line = trim($line ?? ''); + if (!strlen($line)) return false; + if (substr($line, -1, 1) == ']') return false; + if ($line[0] == '[') return true; + if (preg_match('#^[^:]+?:\s*\[#', $line)) return true; + return false; + } + + private function addLiteralLine($literalBlock, $line, $literalBlockStyle, $indent = -1) + { + $line = self::stripIndent($line, $indent); + if ($literalBlockStyle !== '|') { + $line = self::stripIndent($line); + } + $line = rtrim($line, "\r\n\t ") . "\n"; + if ($literalBlockStyle == '|') { + return $literalBlock . $line; + } + if (strlen($line) == 0) + return rtrim($literalBlock, ' ') . "\n"; + if ($line == "\n" && $literalBlockStyle == '>') { + return rtrim($literalBlock, " \t") . "\n"; + } + if ($line != "\n") + $line = trim($line, "\r\n ") . " "; + return $literalBlock . $line; + } + + public function revertLiteralPlaceHolder($lineArray, $literalBlock) + { + foreach ($lineArray as $k => $_) { + if (is_array($_)) + $lineArray[$k] = $this->revertLiteralPlaceHolder($_, $literalBlock); + elseif (substr($_, -1 * strlen($this->LiteralPlaceHolder)) == $this->LiteralPlaceHolder) + $lineArray[$k] = rtrim($literalBlock, " \r\n"); + } + return $lineArray; + } + + private static function stripIndent($line, $indent = -1) + { + $line ??= ''; + + if ($indent == -1) $indent = strlen($line) - strlen(ltrim($line)); + return substr($line, $indent); + } + + private function getParentPathByIndent($indent) + { + if ($indent == 0) return array(); + $linePath = $this->path; + do { + end($linePath); + $lastIndentInParentPath = key($linePath); + if ($indent <= $lastIndentInParentPath) array_pop($linePath); + } while ($indent <= $lastIndentInParentPath); + return $linePath; + } + + private function clearBiggerPathValues($indent) + { + if ($indent == 0) $this->path = array(); + if (empty ($this->path)) return true; + + foreach ($this->path as $k => $_) { + if ($k > $indent) unset ($this->path[$k]); + } + + return true; + } + + private static function isComment($line) + { + if (!$line) return false; + if ($line[0] == '#') return true; + if (trim($line, " \r\n\t") == '---') return true; + return false; + } + + private static function isEmpty($line) + { + return (trim($line ?? '') === ''); + } + + private function isArrayElement($line) + { + if (!$line || !is_scalar($line)) return false; + if (substr($line, 0, 2) != '- ') return false; + if (strlen($line) > 3) + if (substr($line, 0, 3) == '---') return false; + + return true; + } + + private function isHashElement($line) + { + return strpos($line, ':'); + } + + private function isLiteral($line) + { + if ($this->isArrayElement($line)) return false; + if ($this->isHashElement($line)) return false; + return true; + } + + + private static function unquote($value) + { + if (!$value) return $value; + if (!is_string($value)) return $value; + if ($value[0] == '\'') return trim($value, '\''); + if ($value[0] == '"') return trim($value, '"'); + return $value; + } + + private function startsMappedSequence($line) + { + return (substr($line ?? '', 0, 2) == '- ' && substr($line ?? '', -1, 1) == ':'); + } + + private function returnMappedSequence($line) + { + $array = array(); + $key = self::unquote(trim(substr($line ?? '', 1, -1))); + $array[$key] = array(); + $this->delayedPath = array(strpos($line ?? '', $key) + $this->indent => $key); + return array($array); + } + + private function checkKeysInValue($value) + { + if (strchr('[{"\'', $value[0] ?? '') === false) { + if (strchr($value ?? '', ': ') !== false) { + throw new Exception('Too many keys: ' . $value); + } + } + } + + private function returnMappedValue($line) + { + $this->checkKeysInValue($line); + $array = array(); + $key = self::unquote(trim(substr($line ?? '', 0, -1))); + $array[$key] = ''; + return $array; + } + + private function startsMappedValue($line) + { + return (substr($line, -1, 1) == ':'); + } + + private function isPlainArray($line) + { + return ($line[0] == '[' && substr($line, -1, 1) == ']'); + } + + private function returnPlainArray($line) + { + return $this->_toType($line); + } + + private function returnKeyValuePair($line) + { + $array = array(); + $key = ''; + if (strpos($line ?? '', ': ')) { + // It's a key/value pair most likely + // If the key is in double quotes pull it out + if (($line[0] == '"' || $line[0] == "'") && preg_match('/^(["\'](.*)["\'](\s)*:)/', $line, $matches)) { + $value = trim(str_replace($matches[1], '', $line)); + $key = $matches[2]; + } else { + // Do some guesswork as to the key and the value + $explode = explode(': ', $line); + $key = trim(array_shift($explode)); + $value = trim(implode(': ', $explode)); + $this->checkKeysInValue($value); + } + // Set the type of the value. Int, string, etc + $value = $this->_toType($value); + + if ($key === '0') $key = '__!YAMLZero'; + $array[$key] = $value; + } else { + $array = array($line); + } + return $array; + + } + + + private function returnArrayElement($line) + { + if (strlen($line ?? '') <= 1) return array(array()); // Weird %) + $array = array(); + $value = trim(substr($line, 1)); + $value = $this->_toType($value); + if ($this->isArrayElement($value)) { + $value = $this->returnArrayElement($value); + } + $array[] = $value; + return $array; + } + + + private function nodeContainsGroup($line) + { + $symbolsForReference = 'A-z0-9_\-'; + if (strpos($line, '&') === false && strpos($line, '*') === false) return false; // Please die fast ;-) + if ($line[0] == '&' && preg_match('/^(&[' . $symbolsForReference . ']+)/', $line, $matches)) return $matches[1]; + if ($line[0] == '*' && preg_match('/^(\*[' . $symbolsForReference . ']+)/', $line, $matches)) return $matches[1]; + if (preg_match('/(&[' . $symbolsForReference . ']+)$/', $line, $matches)) return $matches[1]; + if (preg_match('/(\*[' . $symbolsForReference . ']+$)/', $line, $matches)) return $matches[1]; + if (preg_match('#^\s*<<\s*:\s*(\*[^\s]+).*$#', $line, $matches)) return $matches[1]; + return false; + + } + + private function addGroup($line, $group) + { + if ($group[0] == '&') $this->_containsGroupAnchor = substr($group ?? '', 1); + if ($group[0] == '*') $this->_containsGroupAlias = substr($group ?? '', 1); + } + + private function stripGroup($line, $group) + { + $line = trim(str_replace($group ?? '', '', $line)); + return $line; + } +} diff --git a/public/kirby/i18n/rules/LICENSE b/public/kirby/i18n/rules/LICENSE new file mode 100644 index 0000000..36c3036 --- /dev/null +++ b/public/kirby/i18n/rules/LICENSE @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2012-217 Florian Eckerstorfer + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/public/kirby/i18n/rules/ar.json b/public/kirby/i18n/rules/ar.json new file mode 100644 index 0000000..e46915f --- /dev/null +++ b/public/kirby/i18n/rules/ar.json @@ -0,0 +1,30 @@ +{ + "أ" : "a", + "ب" : "b", + "ت" : "t", + "ث" : "th", + "ج" : "g", + "ح" : "h", + "خ" : "kh", + "د" : "d", + "ذ" : "th", + "ر" : "r", + "ز" : "z", + "س" : "s", + "ش" : "sh", + "ص" : "s", + "ض" : "d", + "ط" : "t", + "ظ" : "th", + "ع" : "aa", + "غ" : "gh", + "ف" : "f", + "ق" : "k", + "ك" : "k", + "ل" : "l", + "م" : "m", + "ن" : "n", + "ه" : "h", + "و" : "o", + "ي" : "y" +} diff --git a/public/kirby/i18n/rules/az.json b/public/kirby/i18n/rules/az.json new file mode 100644 index 0000000..ad6e2a9 --- /dev/null +++ b/public/kirby/i18n/rules/az.json @@ -0,0 +1,16 @@ +{ + "Ə": "E", + "Ç": "C", + "Ğ": "G", + "İ": "I", + "Ş": "S", + "Ö": "O", + "Ü": "U", + "ə": "e", + "ç": "c", + "ğ": "g", + "ı": "i", + "ş": "s", + "ö": "o", + "ü": "u" +} diff --git a/public/kirby/i18n/rules/bg.json b/public/kirby/i18n/rules/bg.json new file mode 100644 index 0000000..4c45ca1 --- /dev/null +++ b/public/kirby/i18n/rules/bg.json @@ -0,0 +1,65 @@ +{ + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Е": "E", + "Ж": "J", + "З": "Z", + "И": "I", + "Й": "Y", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "Ts", + "Ч": "Ch", + "Ш": "Sh", + "Щ": "Sht", + "Ъ": "A", + "Ь": "I", + "Ю": "Iu", + "Я": "Ia", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ж": "j", + "з": "z", + "и": "i", + "й": "y", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "ts", + "ч": "ch", + "ш": "sh", + "щ": "sht", + "ъ": "a", + "ь": "i", + "ю": "iu", + "я": "ia", + "ия": "ia", + "йо": "iо", + "ьо": "io" +} diff --git a/public/kirby/i18n/rules/cs.json b/public/kirby/i18n/rules/cs.json new file mode 100644 index 0000000..549f805 --- /dev/null +++ b/public/kirby/i18n/rules/cs.json @@ -0,0 +1,20 @@ +{ + "Č": "C", + "Ď": "D", + "Ě": "E", + "Ň": "N", + "Ř": "R", + "Š": "S", + "Ť": "T", + "Ů": "U", + "Ž": "Z", + "č": "c", + "ď": "d", + "ě": "e", + "ň": "n", + "ř": "r", + "š": "s", + "ť": "t", + "ů": "u", + "ž": "z" +} diff --git a/public/kirby/i18n/rules/da.json b/public/kirby/i18n/rules/da.json new file mode 100644 index 0000000..b88c17c --- /dev/null +++ b/public/kirby/i18n/rules/da.json @@ -0,0 +1,10 @@ +{ + "Æ": "Ae", + "æ": "ae", + "Ø": "Oe", + "ø": "oe", + "Å": "Aa", + "å": "aa", + "É": "E", + "é": "e" +} diff --git a/public/kirby/i18n/rules/de.json b/public/kirby/i18n/rules/de.json new file mode 100644 index 0000000..881b68c --- /dev/null +++ b/public/kirby/i18n/rules/de.json @@ -0,0 +1,9 @@ +{ + "Ä": "AE", + "Ö": "OE", + "Ü": "UE", + "ß": "ss", + "ä": "ae", + "ö": "oe", + "ü": "ue" +} diff --git a/public/kirby/i18n/rules/el.json b/public/kirby/i18n/rules/el.json new file mode 100644 index 0000000..767a223 --- /dev/null +++ b/public/kirby/i18n/rules/el.json @@ -0,0 +1,111 @@ +{ + "ΑΥ": "AU", + "Αυ": "Au", + "ΟΥ": "OU", + "Ου": "Ou", + "ΕΥ": "EU", + "Ευ": "Eu", + "ΕΙ": "I", + "Ει": "I", + "ΟΙ": "I", + "Οι": "I", + "ΥΙ": "I", + "Υι": "I", + "ΑΎ": "AU", + "Αύ": "Au", + "ΟΎ": "OU", + "Ού": "Ou", + "ΕΎ": "EU", + "Εύ": "Eu", + "ΕΊ": "I", + "Εί": "I", + "ΟΊ": "I", + "Οί": "I", + "ΎΙ": "I", + "Ύι": "I", + "ΥΊ": "I", + "Υί": "I", + "αυ": "au", + "ου": "ou", + "ευ": "eu", + "ει": "i", + "οι": "i", + "υι": "i", + "αύ": "au", + "ού": "ou", + "εύ": "eu", + "εί": "i", + "οί": "i", + "ύι": "i", + "υί": "i", + "Α": "A", + "Β": "V", + "Γ": "G", + "Δ": "D", + "Ε": "E", + "Ζ": "Z", + "Η": "I", + "Θ": "Th", + "Ι": "I", + "Κ": "K", + "Λ": "L", + "Μ": "M", + "Ν": "N", + "Ξ": "X", + "Ο": "O", + "Π": "P", + "Ρ": "R", + "Σ": "S", + "Τ": "T", + "Υ": "I", + "Φ": "F", + "Χ": "Ch", + "Ψ": "Ps", + "Ω": "O", + "Ά": "A", + "Έ": "E", + "Ή": "I", + "Ί": "I", + "Ό": "O", + "Ύ": "I", + "Ϊ": "I", + "Ϋ": "I", + "ϒ": "I", + "α": "a", + "β": "v", + "γ": "g", + "δ": "d", + "ε": "e", + "ζ": "z", + "η": "i", + "θ": "th", + "ι": "i", + "κ": "k", + "λ": "l", + "μ": "m", + "ν": "n", + "ξ": "x", + "ο": "o", + "π": "p", + "ρ": "r", + "ς": "s", + "σ": "s", + "τ": "t", + "υ": "i", + "φ": "f", + "χ": "ch", + "ψ": "ps", + "ω": "o", + "ά": "a", + "έ": "e", + "ή": "i", + "ί": "i", + "ό": "o", + "ύ": "i", + "ϊ": "i", + "ϋ": "i", + "ΰ": "i", + "ώ": "o", + "ϐ": "v", + "ϑ": "th" +} diff --git a/public/kirby/i18n/rules/eo.json b/public/kirby/i18n/rules/eo.json new file mode 100644 index 0000000..9a4e658 --- /dev/null +++ b/public/kirby/i18n/rules/eo.json @@ -0,0 +1,14 @@ +{ + "ĉ": "cx", + "ĝ": "gx", + "ĥ": "hx", + "ĵ": "jx", + "ŝ": "sx", + "ŭ": "ux", + "Ĉ": "CX", + "Ĝ": "GX", + "Ĥ": "HX", + "Ĵ": "JX", + "Ŝ": "SX", + "Ŭ": "UX" +} diff --git a/public/kirby/i18n/rules/et.json b/public/kirby/i18n/rules/et.json new file mode 100644 index 0000000..fcea469 --- /dev/null +++ b/public/kirby/i18n/rules/et.json @@ -0,0 +1,14 @@ +{ + "Š": "S", + "Ž": "Z", + "Õ": "O", + "Ä": "A", + "Ö": "O", + "Ü": "U", + "š": "s", + "ž": "z", + "õ": "o", + "ä": "a", + "ö": "o", + "ü": "u" +} \ No newline at end of file diff --git a/public/kirby/i18n/rules/fa.json b/public/kirby/i18n/rules/fa.json new file mode 100644 index 0000000..0448016 --- /dev/null +++ b/public/kirby/i18n/rules/fa.json @@ -0,0 +1,36 @@ +{ + "آ" : "A", + "ا" : "a", + "ب" : "b", + "پ" : "p", + "ت" : "t", + "ث" : "th", + "ج" : "j", + "چ" : "ch", + "ح" : "h", + "خ" : "kh", + "د" : "d", + "ذ" : "th", + "ر" : "r", + "ز" : "z", + "ژ" : "zh", + "س" : "s", + "ش" : "sh", + "ص" : "s", + "ض" : "z", + "ط" : "t", + "ظ" : "z", + "ع" : "a", + "غ" : "gh", + "ف" : "f", + "ق" : "g", + "ك" : "k", + "ک" : "k", + "گ" : "g", + "ل" : "l", + "م" : "m", + "ن" : "n", + "و" : "o", + "ه" : "h", + "ی" : "y" +} diff --git a/public/kirby/i18n/rules/fi.json b/public/kirby/i18n/rules/fi.json new file mode 100644 index 0000000..fd35423 --- /dev/null +++ b/public/kirby/i18n/rules/fi.json @@ -0,0 +1,6 @@ +{ + "Ä": "A", + "Ö": "O", + "ä": "a", + "ö": "o" +} diff --git a/public/kirby/i18n/rules/fr.json b/public/kirby/i18n/rules/fr.json new file mode 100644 index 0000000..29c94b9 --- /dev/null +++ b/public/kirby/i18n/rules/fr.json @@ -0,0 +1,34 @@ +{ + "À": "A", + "Â": "A", + "Æ": "AE", + "Ç": "C", + "É": "E", + "È": "E", + "Ê": "E", + "Ë": "E", + "Ï": "I", + "Î": "I", + "Ô": "O", + "Œ": "OE", + "Ù": "U", + "Û": "U", + "Ü": "U", + "à": "a", + "â": "a", + "æ": "ae", + "ç": "c", + "é": "e", + "è": "e", + "ê": "e", + "ë": "e", + "ï": "i", + "î": "i", + "ô": "o", + "œ": "oe", + "ù": "u", + "û": "u", + "ü": "u", + "ÿ": "y", + "Ÿ": "Y" +} diff --git a/public/kirby/i18n/rules/hi.json b/public/kirby/i18n/rules/hi.json new file mode 100644 index 0000000..f653f15 --- /dev/null +++ b/public/kirby/i18n/rules/hi.json @@ -0,0 +1,66 @@ +{ + "अ": "a", + "आ": "aa", + "ए": "e", + "ई": "ii", + "ऍ": "ei", + "ऎ": "ae", + "ऐ": "ai", + "इ": "i", + "ओ": "o", + "ऑ": "oi", + "ऒ": "oii", + "ऊ": "uu", + "औ": "ou", + "उ": "u", + "ब": "B", + "भ": "Bha", + "च": "Ca", + "छ": "Chha", + "ड": "Da", + "ढ": "Dha", + "फ": "Fa", + "फ़": "Fi", + "ग": "Ga", + "घ": "Gha", + "ग़": "Ghi", + "ह": "Ha", + "ज": "Ja", + "झ": "Jha", + "क": "Ka", + "ख": "Kha", + "ख़": "Khi", + "ल": "L", + "ळ": "Li", + "ऌ": "Li", + "ऴ": "Lii", + "ॡ": "Lii", + "म": "Ma", + "न": "Na", + "ङ": "Na", + "ञ": "Nia", + "ण": "Nae", + "ऩ": "Ni", + "ॐ": "oms", + "प": "Pa", + "क़": "Qi", + "र": "Ra", + "ऋ": "Ri", + "ॠ": "Ri", + "ऱ": "Ri", + "स": "Sa", + "श": "Sha", + "ष": "Shha", + "ट": "Ta", + "त": "Ta", + "ठ": "Tha", + "द": "Tha", + "थ": "Tha", + "ध": "Thha", + "ड़": "ugDha", + "ढ़": "ugDhha", + "व": "Va", + "य": "Ya", + "य़": "Yi", + "ज़": "Za" +} diff --git a/public/kirby/i18n/rules/hr.json b/public/kirby/i18n/rules/hr.json new file mode 100644 index 0000000..bf2b10d --- /dev/null +++ b/public/kirby/i18n/rules/hr.json @@ -0,0 +1,12 @@ +{ + "Č": "C", + "Ć": "C", + "Ž": "Z", + "Š": "S", + "Đ": "Dj", + "č": "c", + "ć": "c", + "ž": "z", + "š": "s", + "đ": "dj" +} \ No newline at end of file diff --git a/public/kirby/i18n/rules/hu.json b/public/kirby/i18n/rules/hu.json new file mode 100644 index 0000000..2bb2f3a --- /dev/null +++ b/public/kirby/i18n/rules/hu.json @@ -0,0 +1,20 @@ +{ + "Á": "a", + "É": "e", + "Í": "i", + "Ó": "o", + "Ö": "o", + "Ő": "o", + "Ú": "u", + "Ü": "u", + "Ű": "u", + "á": "a", + "é": "e", + "í": "i", + "ó": "o", + "ö": "o", + "ő": "o", + "ú": "u", + "ü": "u", + "ű": "u" +} diff --git a/public/kirby/i18n/rules/hy.json b/public/kirby/i18n/rules/hy.json new file mode 100644 index 0000000..08188e6 --- /dev/null +++ b/public/kirby/i18n/rules/hy.json @@ -0,0 +1,79 @@ +{ + "Ա": "A", + "Բ": "B", + "Գ": "G", + "Դ": "D", + "Ե": "E", + "Զ": "Z", + "Է": "E", + "Ը": "Y", + "Թ": "Th", + "Ժ": "Zh", + "Ի": "I", + "Լ": "L", + "Խ": "Kh", + "Ծ": "Ts", + "Կ": "K", + "Հ": "H", + "Ձ": "Dz", + "Ղ": "Gh", + "Ճ": "Tch", + "Մ": "M", + "Յ": "Y", + "Ն": "N", + "Շ": "Sh", + "Ո": "Vo", + "Չ": "Ch", + "Պ": "P", + "Ջ": "J", + "Ռ": "R", + "Ս": "S", + "Վ": "V", + "Տ": "T", + "Ր": "R", + "Ց": "C", + "Ւ": "u", + "Փ": "Ph", + "Ք": "Q", + "և": "ev", + "Օ": "O", + "Ֆ": "F", + "ա": "a", + "բ": "b", + "գ": "g", + "դ": "d", + "ե": "e", + "զ": "z", + "է": "e", + "ը": "y", + "թ": "th", + "ժ": "zh", + "ի": "i", + "լ": "l", + "խ": "kh", + "ծ": "ts", + "կ": "k", + "հ": "h", + "ձ": "dz", + "ղ": "gh", + "ճ": "tch", + "մ": "m", + "յ": "y", + "ն": "n", + "շ": "sh", + "ո": "vo", + "չ": "ch", + "պ": "p", + "ջ": "j", + "ռ": "r", + "ս": "s", + "վ": "v", + "տ": "t", + "ր": "r", + "ց": "c", + "ւ": "u", + "փ": "ph", + "ք": "q", + "օ": "o", + "ֆ": "f" +} diff --git a/public/kirby/i18n/rules/is_IS.json b/public/kirby/i18n/rules/is_IS.json new file mode 100644 index 0000000..7035056 --- /dev/null +++ b/public/kirby/i18n/rules/is_IS.json @@ -0,0 +1,22 @@ +{ + "Æ": "Ae", + "æ": "ae", + "Ö": "O", + "ö": "o", + "Þ": "Th", + "þ": "th", + "Ð": "D", + "ð": "d", + "Á": "A", + "á": "a", + "É": "E", + "é": "e", + "Í": "I", + "í": "i", + "Ó": "O", + "ó": "o", + "Ú": "U", + "ú": "u", + "Ý": "Y", + "ý": "y" +} diff --git a/public/kirby/i18n/rules/it.json b/public/kirby/i18n/rules/it.json new file mode 100644 index 0000000..647c2cf --- /dev/null +++ b/public/kirby/i18n/rules/it.json @@ -0,0 +1,13 @@ +{ + "À": "a", + "È": "e", + "Ì": "i", + "Ò": "o", + "Ù": "u", + "à": "a", + "é": "e", + "è": "e", + "ì": "i", + "ò": "o", + "ù": "u" +} diff --git a/public/kirby/i18n/rules/iu.json b/public/kirby/i18n/rules/iu.json new file mode 100644 index 0000000..2ec5018 --- /dev/null +++ b/public/kirby/i18n/rules/iu.json @@ -0,0 +1,163 @@ +{ + "ᐁ": "ai", + "ᐃ": "i", + "ᐄ": "ii", + "ᐅ": "u", + "ᐆ": "uu", + "ᐊ": "a", + "ᐋ": "aa", + + "ᐯ": "pai", + "ᐱ": "pi", + "ᐲ": "pii", + "ᐳ": "pu", + "ᐴ": "puu", + "ᐸ": "pa", + "ᐹ": "paa", + + "ᑌ": "tai", + "ᑎ": "ti", + "ᑏ": "tii", + "ᑐ": "tu", + "ᑑ": "tuu", + "ᑕ": "ta", + "ᑖ": "taa", + + "ᕴ": "hai", + "ᕵ": "hi", + "ᕶ": "hii", + "ᕷ": "hu", + "ᕸ": "huu", + "ᕹ": "ha", + "ᕺ": "haa", + + "ᒉ": "gai", + "ᒋ": "gi", + "ᒌ": "gii", + "ᒍ": "gu", + "ᒎ": "guu", + "ᒐ": "ga", + "ᒑ": "gaa", + + "ᒣ": "mai", + "ᒥ": "mi", + "ᒦ": "mii", + "ᒧ": "mu", + "ᒨ": "muu", + "ᒪ": "ma", + "ᒫ": "maa", + + "ᓀ": "nai", + "ᓂ": "ni", + "ᓃ": "nii", + "ᓄ": "nu", + "ᓅ": "nuu", + "ᓇ": "na", + "ᓈ": "naa", + + "ᓭ": "sai", + "ᓯ": "si", + "ᓰ": "sii", + "ᓱ": "su", + "ᓲ": "suu", + "ᓴ": "sa", + "ᓵ": "saa", + + "ᓓ": "lai", + "ᓕ": "li", + "ᓖ": "lii", + "ᓗ": "lu", + "ᓘ": "luu", + "ᓚ": "la", + "ᓛ": "laa", + + "ᔦ": "jai", + "ᔨ": "ji", + "ᔩ": "jii", + "ᔪ": "ju", + "ᔫ": "juu", + "ᔭ": "ja", + "ᔮ": "jaa", + + "ᕓ": "vai", + "ᕕ": "vi", + "ᕖ": "vii", + "ᕗ": "vu", + "ᕘ": "vuu", + "ᕙ": "va", + "ᕚ": "vaa", + + "ᕃ": "rai", + "ᕆ": "ri", + "ᕇ": "rii", + "ᕈ": "ru", + "ᕉ": "ruu", + "ᕋ": "ra", + "ᕌ": "raa", + + "ᖅᑫ": "qqai", + "ᖅᑭ": "qqi", + "ᖅᑮ": "qqii", + "ᖅᑯ": "qqu", + "ᖅᑰ": "qquu", + "ᖅᑲ": "qqa", + "ᖅᑳ": "qqaa", + "ᖅᒃ": "qq", + + "ᙯ": "qai", + "ᕿ": "qi", + "ᖀ": "qii", + "ᖁ": "qu", + "ᖂ": "quu", + "ᖃ": "qa", + "ᖄ": "qaa", + + "ᑫ": "kai", + "ᑭ": "ki", + "ᑮ": "kii", + "ᑯ": "ku", + "ᑰ": "kuu", + "ᑲ": "ka", + "ᑳ": "kaa", + + "ᙰ": "ngai", + "ᖏ": "ngi", + "ᖐ": "ngii", + "ᖑ": "ngu", + "ᖒ": "nguu", + "ᖓ": "nga", + "ᖔ": "ngaa", + + "ᙱ": "nngi", + "ᙲ": "nngii", + "ᙳ": "nngu", + "ᙴ": "nnguu", + "ᙵ": "nnga", + "ᙶ": "nngaa", + + "ᖠ": "lhi", + "ᖡ": "lhii", + "ᖢ": "lhu", + "ᖣ": "lhuu", + "ᖤ": "lha", + "ᖥ": "lhaa", + + "ᑉ": "p", + "ᑦ": "t", + "ᒃ": "k", + "ᒡ": "g", + "ᒻ": "m", + "ᓐ": "n", + "ᔅ": "s", + "ᓪ": "l", + "ᔾ": "j", + "ᕝ": "v", + "ᕐ": "r", + "ᖅ": "q", + "ᖕ": "ng", + "ᖖ": "nng", + "ᖦ": "lh", + + "ᖯ": "b", + "ᕼ": "h" +} \ No newline at end of file diff --git a/public/kirby/i18n/rules/ja.json b/public/kirby/i18n/rules/ja.json new file mode 100644 index 0000000..12f842d --- /dev/null +++ b/public/kirby/i18n/rules/ja.json @@ -0,0 +1,182 @@ +{ + "きゃ": "kya", + "しゃ": "sha", + "ちゃ": "cha", + "にゃ": "nya", + "ひゃ": "hya", + "みゃ": "mya", + "りゃ": "rya", + "ぎゃ": "gya", + "じゃ": "ja", + "ぢゃ": "ja", + "びゃ": "bya", + "ぴゃ": "pya", + + "きゅ": "kyu", + "しゅ": "shu", + "ちゅ": "chu", + "にゅ": "nyu", + "ひゅ": "hyu", + "みゅ": "myu", + "りゅ": "ryu", + "ぎゅ": "gyu", + "じゅ": "ju", + "ぢゅ": "ju", + "びゅ": "byu", + "ぴゅ": "pyu", + + "きょ": "kyo", + "しょ": "sho", + "ちょ": "cho", + "にょ": "nyo", + "ひょ": "hyo", + "みょ": "myo", + "りょ": "ryo", + "ぎょ": "gyo", + "じょ": "jo", + "ぢょ": "jo", + "びょ": "byo", + "ぴょ": "pyo", + + "あ": "a", + "ア": "a", + "か": "ka", + "カ": "ka", + "さ": "sa", + "サ": "sa", + "た": "ta", + "タ": "ta", + "な": "na", + "ナ": "na", + "は": "ha", + "ハ": "ha", + "ま": "ma", + "マ": "ma", + "や": "ya", + "ヤ": "ya", + "ら": "ra", + "ラ": "ra", + "わ": "wa", + "ワ": "wa", + "が": "ga", + "ざ": "za", + "ザ": "za", + "だ": "da", + "ば": "ba", + "ぱ": "pa", + "中": "naka", + "場": "ba", + "版": "han", + + "い": "i", + "イ": "i", + "き": "ki", + "キ": "ki", + "し": "shi", + "シ": "shi", + "ち": "chi", + "チ": "chi", + "に": "ni", + "ニ": "ni", + "ひ": "hi", + "ヒ": "hi", + "み": "mi", + "ミ": "mi", + "り": "ri", + "リ": "ri", + "ゐ": "wi", + "ヰ": "wi", + "ぎ": "gi", + "じ": "dji", + "ぢ": "ji", + "び": "bi", + "ぴ": "pi", + "仮": "kari", + "国": "kuni", + "鳥": "tori", + "劇": "geki", + + "う": "u", + "ウ": "u", + "く": "ku", + "ク": "ku", + "す": "su", + "ス": "su", + "つ": "tsu", + "ツ": "tsu", + "ぬ": "nu", + "ヌ": "nu", + "ふ": "fu", + "フ": "fu", + "む": "mu", + "ム": "mu", + "ゆ": "yu", + "ユ": "yu", + "る": "ru", + "ル": "ru", + "ぐ": "gu", + "ず": "zu", + "づ": "dzu", + "ぶ": "bu", + "ぷ": "pu", + "プ": "pu", + "ズ": "zu", + "グ": "gu", + + "え": "e", + "エ": "e", + "け": "ke", + "ケ": "ke", + "せ": "se", + "セ": "se", + "て": "te", + "テ": "te", + "ね": "ne", + "ネ": "ne", + "へ": "he", + "ヘ": "he", + "め": "me", + "メ": "me", + "れ": "re", + "レ": "re", + "ゑ": "we", + "ヱ": "we", + "げ": "ge", + "ぜ": "ze", + "で": "de", + "べ": "be", + "ぺ": "pe", + "面": "men", + + "お": "o", + "オ": "o", + "こ": "ko", + "コ": "ko", + "そ": "so", + "ソ": "so", + "と": "to", + "ト": "to", + "の": "no", + "ノ": "no", + "ほ": "ho", + "ホ": "ho", + "も": "mo", + "モ": "mo", + "よ": "yo", + "ヨ": "yo", + "ろ": "ro", + "ロ": "ro", + "を": "wo", + "ヲ": "wo", + "ん": "n", + "ン": "n", + "ご": "go", + "ぞ": "zo", + "ど": "do", + "ド": "do", + "ぼ": "bo", + "ポ": "po", + "ぽ": "po", + "男": "otoko", + "人": "hito" +} diff --git a/public/kirby/i18n/rules/ka.json b/public/kirby/i18n/rules/ka.json new file mode 100644 index 0000000..2c63573 --- /dev/null +++ b/public/kirby/i18n/rules/ka.json @@ -0,0 +1,35 @@ +{ + "ა": "a", + "ბ": "b", + "გ": "g", + "დ": "d", + "ე": "e", + "ვ": "v", + "ზ": "z", + "თ": "t", + "ი": "i", + "კ": "k", + "ლ": "l", + "მ": "m", + "ნ": "n", + "ო": "o", + "პ": "p", + "ჟ": "zh", + "რ": "r", + "ს": "s", + "ტ": "t", + "უ": "u", + "ფ": "f", + "ქ": "k", + "ღ": "gh", + "ყ": "q", + "შ": "sh", + "ჩ": "ch", + "ც": "ts", + "ძ": "dz", + "წ": "ts", + "ჭ": "ch", + "ხ": "kh", + "ჯ": "j", + "ჰ": "h" +} diff --git a/public/kirby/i18n/rules/ko.json b/public/kirby/i18n/rules/ko.json new file mode 100644 index 0000000..8dad2c0 --- /dev/null +++ b/public/kirby/i18n/rules/ko.json @@ -0,0 +1,11174 @@ +{ + "가": "ga", + "각": "gak", + "갂": "gakk", + "갃": "gak", + "간": "gan", + "갅": "gan", + "갆": "gan", + "갇": "gat", + "갈": "gal", + "갉": "gak", + "갊": "gam", + "갋": "gap", + "갌": "gat", + "갍": "gat", + "갎": "gap", + "갏": "gal", + "감": "gam", + "갑": "gap", + "값": "gap", + "갓": "gat", + "갔": "gat", + "강": "gang", + "갖": "gat", + "갗": "gat", + "갘": "gak", + "같": "gat", + "갚": "gap", + "갛": "gat", + "개": "gae", + "객": "gaek", + "갞": "gaekk", + "갟": "gaek", + "갠": "gaen", + "갡": "gaen", + "갢": "gaen", + "갣": "gaet", + "갤": "gael", + "갥": "gaek", + "갦": "gaem", + "갧": "gaep", + "갨": "gaet", + "갩": "gaet", + "갪": "gaep", + "갫": "gael", + "갬": "gaem", + "갭": "gaep", + "갮": "gaep", + "갯": "gaet", + "갰": "gaet", + "갱": "gaeng", + "갲": "gaet", + "갳": "gaet", + "갴": "gaek", + "갵": "gaet", + "갶": "gaep", + "갷": "gaet", + "갸": "gya", + "갹": "gyak", + "갺": "gyakk", + "갻": "gyak", + "갼": "gyan", + "갽": "gyan", + "갾": "gyan", + "갿": "gyat", + "걀": "gyal", + "걁": "gyak", + "걂": "gyam", + "걃": "gyap", + "걄": "gyat", + "걅": "gyat", + "걆": "gyap", + "걇": "gyal", + "걈": "gyam", + "걉": "gyap", + "걊": "gyap", + "걋": "gyat", + "걌": "gyat", + "걍": "gyang", + "걎": "gyat", + "걏": "gyat", + "걐": "gyak", + "걑": "gyat", + "걒": "gyap", + "걓": "gyat", + "걔": "gyae", + "걕": "gyaek", + "걖": "gyaekk", + "걗": "gyaek", + "걘": "gyaen", + "걙": "gyaen", + "걚": "gyaen", + "걛": "gyaet", + "걜": "gyael", + "걝": "gyaek", + "걞": "gyaem", + "걟": "gyaep", + "걠": "gyaet", + "걡": "gyaet", + "걢": "gyaep", + "걣": "gyael", + "걤": "gyaem", + "걥": "gyaep", + "걦": "gyaep", + "걧": "gyaet", + "걨": "gyaet", + "걩": "gyaeng", + "걪": "gyaet", + "걫": "gyaet", + "걬": "gyaek", + "걭": "gyaet", + "걮": "gyaep", + "걯": "gyaet", + "거": "geo", + "걱": "geok", + "걲": "geokk", + "걳": "geok", + "건": "geon", + "걵": "geon", + "걶": "geon", + "걷": "geot", + "걸": "geol", + "걹": "geok", + "걺": "geom", + "걻": "geop", + "걼": "geot", + "걽": "geot", + "걾": "geop", + "걿": "geol", + "검": "geom", + "겁": "geop", + "겂": "geop", + "것": "geot", + "겄": "geot", + "겅": "geong", + "겆": "geot", + "겇": "geot", + "겈": "geok", + "겉": "geot", + "겊": "geop", + "겋": "geot", + "게": "ge", + "겍": "gek", + "겎": "gekk", + "겏": "gek", + "겐": "gen", + "겑": "gen", + "겒": "gen", + "겓": "get", + "겔": "gel", + "겕": "gek", + "겖": "gem", + "겗": "gep", + "겘": "get", + "겙": "get", + "겚": "gep", + "겛": "gel", + "겜": "gem", + "겝": "gep", + "겞": "gep", + "겟": "get", + "겠": "get", + "겡": "geng", + "겢": "get", + "겣": "get", + "겤": "gek", + "겥": "get", + "겦": "gep", + "겧": "get", + "겨": "gyeo", + "격": "gyeok", + "겪": "gyeokk", + "겫": "gyeok", + "견": "gyeon", + "겭": "gyeon", + "겮": "gyeon", + "겯": "gyeot", + "결": "gyeol", + "겱": "gyeok", + "겲": "gyeom", + "겳": "gyeop", + "겴": "gyeot", + "겵": "gyeot", + "겶": "gyeop", + "겷": "gyeol", + "겸": "gyeom", + "겹": "gyeop", + "겺": "gyeop", + "겻": "gyeot", + "겼": "gyeot", + "경": "gyeong", + "겾": "gyeot", + "겿": "gyeot", + "곀": "gyeok", + "곁": "gyeot", + "곂": "gyeop", + "곃": "gyeot", + "계": "gye", + "곅": "gyek", + "곆": "gyekk", + "곇": "gyek", + "곈": "gyen", + "곉": "gyen", + "곊": "gyen", + "곋": "gyet", + "곌": "gyel", + "곍": "gyek", + "곎": "gyem", + "곏": "gyep", + "곐": "gyet", + "곑": "gyet", + "곒": "gyep", + "곓": "gyel", + "곔": "gyem", + "곕": "gyep", + "곖": "gyep", + "곗": "gyet", + "곘": "gyet", + "곙": "gyeng", + "곚": "gyet", + "곛": "gyet", + "곜": "gyek", + "곝": "gyet", + "곞": "gyep", + "곟": "gyet", + "고": "go", + "곡": "gok", + "곢": "gokk", + "곣": "gok", + "곤": "gon", + "곥": "gon", + "곦": "gon", + "곧": "got", + "골": "gol", + "곩": "gok", + "곪": "gom", + "곫": "gop", + "곬": "got", + "곭": "got", + "곮": "gop", + "곯": "gol", + "곰": "gom", + "곱": "gop", + "곲": "gop", + "곳": "got", + "곴": "got", + "공": "gong", + "곶": "got", + "곷": "got", + "곸": "gok", + "곹": "got", + "곺": "gop", + "곻": "got", + "과": "gwa", + "곽": "gwak", + "곾": "gwakk", + "곿": "gwak", + "관": "gwan", + "괁": "gwan", + "괂": "gwan", + "괃": "gwat", + "괄": "gwal", + "괅": "gwak", + "괆": "gwam", + "괇": "gwap", + "괈": "gwat", + "괉": "gwat", + "괊": "gwap", + "괋": "gwal", + "괌": "gwam", + "괍": "gwap", + "괎": "gwap", + "괏": "gwat", + "괐": "gwat", + "광": "gwang", + "괒": "gwat", + "괓": "gwat", + "괔": "gwak", + "괕": "gwat", + "괖": "gwap", + "괗": "gwat", + "괘": "gwae", + "괙": "gwaek", + "괚": "gwaekk", + "괛": "gwaek", + "괜": "gwaen", + "괝": "gwaen", + "괞": "gwaen", + "괟": "gwaet", + "괠": "gwael", + "괡": "gwaek", + "괢": "gwaem", + "괣": "gwaep", + "괤": "gwaet", + "괥": "gwaet", + "괦": "gwaep", + "괧": "gwael", + "괨": "gwaem", + "괩": "gwaep", + "괪": "gwaep", + "괫": "gwaet", + "괬": "gwaet", + "괭": "gwaeng", + "괮": "gwaet", + "괯": "gwaet", + "괰": "gwaek", + "괱": "gwaet", + "괲": "gwaep", + "괳": "gwaet", + "괴": "goe", + "괵": "goek", + "괶": "goekk", + "괷": "goek", + "괸": "goen", + "괹": "goen", + "괺": "goen", + "괻": "goet", + "괼": "goel", + "괽": "goek", + "괾": "goem", + "괿": "goep", + "굀": "goet", + "굁": "goet", + "굂": "goep", + "굃": "goel", + "굄": "goem", + "굅": "goep", + "굆": "goep", + "굇": "goet", + "굈": "goet", + "굉": "goeng", + "굊": "goet", + "굋": "goet", + "굌": "goek", + "굍": "goet", + "굎": "goep", + "굏": "goet", + "교": "gyo", + "굑": "gyok", + "굒": "gyokk", + "굓": "gyok", + "굔": "gyon", + "굕": "gyon", + "굖": "gyon", + "굗": "gyot", + "굘": "gyol", + "굙": "gyok", + "굚": "gyom", + "굛": "gyop", + "굜": "gyot", + "굝": "gyot", + "굞": "gyop", + "굟": "gyol", + "굠": "gyom", + "굡": "gyop", + "굢": "gyop", + "굣": "gyot", + "굤": "gyot", + "굥": "gyong", + "굦": "gyot", + "굧": "gyot", + "굨": "gyok", + "굩": "gyot", + "굪": "gyop", + "굫": "gyot", + "구": "gu", + "국": "guk", + "굮": "gukk", + "굯": "guk", + "군": "gun", + "굱": "gun", + "굲": "gun", + "굳": "gut", + "굴": "gul", + "굵": "guk", + "굶": "gum", + "굷": "gup", + "굸": "gut", + "굹": "gut", + "굺": "gup", + "굻": "gul", + "굼": "gum", + "굽": "gup", + "굾": "gup", + "굿": "gut", + "궀": "gut", + "궁": "gung", + "궂": "gut", + "궃": "gut", + "궄": "guk", + "궅": "gut", + "궆": "gup", + "궇": "gut", + "궈": "gwo", + "궉": "gwok", + "궊": "gwokk", + "궋": "gwok", + "권": "gwon", + "궍": "gwon", + "궎": "gwon", + "궏": "gwot", + "궐": "gwol", + "궑": "gwok", + "궒": "gwom", + "궓": "gwop", + "궔": "gwot", + "궕": "gwot", + "궖": "gwop", + "궗": "gwol", + "궘": "gwom", + "궙": "gwop", + "궚": "gwop", + "궛": "gwot", + "궜": "gwot", + "궝": "gwong", + "궞": "gwot", + "궟": "gwot", + "궠": "gwok", + "궡": "gwot", + "궢": "gwop", + "궣": "gwot", + "궤": "gwe", + "궥": "gwek", + "궦": "gwekk", + "궧": "gwek", + "궨": "gwen", + "궩": "gwen", + "궪": "gwen", + "궫": "gwet", + "궬": "gwel", + "궭": "gwek", + "궮": "gwem", + "궯": "gwep", + "궰": "gwet", + "궱": "gwet", + "궲": "gwep", + "궳": "gwel", + "궴": "gwem", + "궵": "gwep", + "궶": "gwep", + "궷": "gwet", + "궸": "gwet", + "궹": "gweng", + "궺": "gwet", + "궻": "gwet", + "궼": "gwek", + "궽": "gwet", + "궾": "gwep", + "궿": "gwet", + "귀": "gwi", + "귁": "gwik", + "귂": "gwikk", + "귃": "gwik", + "귄": "gwin", + "귅": "gwin", + "귆": "gwin", + "귇": "gwit", + "귈": "gwil", + "귉": "gwik", + "귊": "gwim", + "귋": "gwip", + "귌": "gwit", + "귍": "gwit", + "귎": "gwip", + "귏": "gwil", + "귐": "gwim", + "귑": "gwip", + "귒": "gwip", + "귓": "gwit", + "귔": "gwit", + "귕": "gwing", + "귖": "gwit", + "귗": "gwit", + "귘": "gwik", + "귙": "gwit", + "귚": "gwip", + "귛": "gwit", + "규": "gyu", + "귝": "gyuk", + "귞": "gyukk", + "귟": "gyuk", + "균": "gyun", + "귡": "gyun", + "귢": "gyun", + "귣": "gyut", + "귤": "gyul", + "귥": "gyuk", + "귦": "gyum", + "귧": "gyup", + "귨": "gyut", + "귩": "gyut", + "귪": "gyup", + "귫": "gyul", + "귬": "gyum", + "귭": "gyup", + "귮": "gyup", + "귯": "gyut", + "귰": "gyut", + "귱": "gyung", + "귲": "gyut", + "귳": "gyut", + "귴": "gyuk", + "귵": "gyut", + "귶": "gyup", + "귷": "gyut", + "그": "geu", + "극": "geuk", + "귺": "geukk", + "귻": "geuk", + "근": "geun", + "귽": "geun", + "귾": "geun", + "귿": "geut", + "글": "geul", + "긁": "geuk", + "긂": "geum", + "긃": "geup", + "긄": "geut", + "긅": "geut", + "긆": "geup", + "긇": "geul", + "금": "geum", + "급": "geup", + "긊": "geup", + "긋": "geut", + "긌": "geut", + "긍": "geung", + "긎": "geut", + "긏": "geut", + "긐": "geuk", + "긑": "geut", + "긒": "geup", + "긓": "geut", + "긔": "geui", + "긕": "geuik", + "긖": "geuikk", + "긗": "geuik", + "긘": "geuin", + "긙": "geuin", + "긚": "geuin", + "긛": "geuit", + "긜": "geuil", + "긝": "geuik", + "긞": "geuim", + "긟": "geuip", + "긠": "geuit", + "긡": "geuit", + "긢": "geuip", + "긣": "geuil", + "긤": "geuim", + "긥": "geuip", + "긦": "geuip", + "긧": "geuit", + "긨": "geuit", + "긩": "geuing", + "긪": "geuit", + "긫": "geuit", + "긬": "geuik", + "긭": "geuit", + "긮": "geuip", + "긯": "geuit", + "기": "gi", + "긱": "gik", + "긲": "gikk", + "긳": "gik", + "긴": "gin", + "긵": "gin", + "긶": "gin", + "긷": "git", + "길": "gil", + "긹": "gik", + "긺": "gim", + "긻": "gip", + "긼": "git", + "긽": "git", + "긾": "gip", + "긿": "gil", + "김": "gim", + "깁": "gip", + "깂": "gip", + "깃": "git", + "깄": "git", + "깅": "ging", + "깆": "git", + "깇": "git", + "깈": "gik", + "깉": "git", + "깊": "gip", + "깋": "git", + "까": "kka", + "깍": "kkak", + "깎": "kkakk", + "깏": "kkak", + "깐": "kkan", + "깑": "kkan", + "깒": "kkan", + "깓": "kkat", + "깔": "kkal", + "깕": "kkak", + "깖": "kkam", + "깗": "kkap", + "깘": "kkat", + "깙": "kkat", + "깚": "kkap", + "깛": "kkal", + "깜": "kkam", + "깝": "kkap", + "깞": "kkap", + "깟": "kkat", + "깠": "kkat", + "깡": "kkang", + "깢": "kkat", + "깣": "kkat", + "깤": "kkak", + "깥": "kkat", + "깦": "kkap", + "깧": "kkat", + "깨": "kkae", + "깩": "kkaek", + "깪": "kkaekk", + "깫": "kkaek", + "깬": "kkaen", + "깭": "kkaen", + "깮": "kkaen", + "깯": "kkaet", + "깰": "kkael", + "깱": "kkaek", + "깲": "kkaem", + "깳": "kkaep", + "깴": "kkaet", + "깵": "kkaet", + "깶": "kkaep", + "깷": "kkael", + "깸": "kkaem", + "깹": "kkaep", + "깺": "kkaep", + "깻": "kkaet", + "깼": "kkaet", + "깽": "kkaeng", + "깾": "kkaet", + "깿": "kkaet", + "꺀": "kkaek", + "꺁": "kkaet", + "꺂": "kkaep", + "꺃": "kkaet", + "꺄": "kkya", + "꺅": "kkyak", + "꺆": "kkyakk", + "꺇": "kkyak", + "꺈": "kkyan", + "꺉": "kkyan", + "꺊": "kkyan", + "꺋": "kkyat", + "꺌": "kkyal", + "꺍": "kkyak", + "꺎": "kkyam", + "꺏": "kkyap", + "꺐": "kkyat", + "꺑": "kkyat", + "꺒": "kkyap", + "꺓": "kkyal", + "꺔": "kkyam", + "꺕": "kkyap", + "꺖": "kkyap", + "꺗": "kkyat", + "꺘": "kkyat", + "꺙": "kkyang", + "꺚": "kkyat", + "꺛": "kkyat", + "꺜": "kkyak", + "꺝": "kkyat", + "꺞": "kkyap", + "꺟": "kkyat", + "꺠": "kkyae", + "꺡": "kkyaek", + "꺢": "kkyaekk", + "꺣": "kkyaek", + "꺤": "kkyaen", + "꺥": "kkyaen", + "꺦": "kkyaen", + "꺧": "kkyaet", + "꺨": "kkyael", + "꺩": "kkyaek", + "꺪": "kkyaem", + "꺫": "kkyaep", + "꺬": "kkyaet", + "꺭": "kkyaet", + "꺮": "kkyaep", + "꺯": "kkyael", + "꺰": "kkyaem", + "꺱": "kkyaep", + "꺲": "kkyaep", + "꺳": "kkyaet", + "꺴": "kkyaet", + "꺵": "kkyaeng", + "꺶": "kkyaet", + "꺷": "kkyaet", + "꺸": "kkyaek", + "꺹": "kkyaet", + "꺺": "kkyaep", + "꺻": "kkyaet", + "꺼": "kkeo", + "꺽": "kkeok", + "꺾": "kkeokk", + "꺿": "kkeok", + "껀": "kkeon", + "껁": "kkeon", + "껂": "kkeon", + "껃": "kkeot", + "껄": "kkeol", + "껅": "kkeok", + "껆": "kkeom", + "껇": "kkeop", + "껈": "kkeot", + "껉": "kkeot", + "껊": "kkeop", + "껋": "kkeol", + "껌": "kkeom", + "껍": "kkeop", + "껎": "kkeop", + "껏": "kkeot", + "껐": "kkeot", + "껑": "kkeong", + "껒": "kkeot", + "껓": "kkeot", + "껔": "kkeok", + "껕": "kkeot", + "껖": "kkeop", + "껗": "kkeot", + "께": "kke", + "껙": "kkek", + "껚": "kkekk", + "껛": "kkek", + "껜": "kken", + "껝": "kken", + "껞": "kken", + "껟": "kket", + "껠": "kkel", + "껡": "kkek", + "껢": "kkem", + "껣": "kkep", + "껤": "kket", + "껥": "kket", + "껦": "kkep", + "껧": "kkel", + "껨": "kkem", + "껩": "kkep", + "껪": "kkep", + "껫": "kket", + "껬": "kket", + "껭": "kkeng", + "껮": "kket", + "껯": "kket", + "껰": "kkek", + "껱": "kket", + "껲": "kkep", + "껳": "kket", + "껴": "kkyeo", + "껵": "kkyeok", + "껶": "kkyeokk", + "껷": "kkyeok", + "껸": "kkyeon", + "껹": "kkyeon", + "껺": "kkyeon", + "껻": "kkyeot", + "껼": "kkyeol", + "껽": "kkyeok", + "껾": "kkyeom", + "껿": "kkyeop", + "꼀": "kkyeot", + "꼁": "kkyeot", + "꼂": "kkyeop", + "꼃": "kkyeol", + "꼄": "kkyeom", + "꼅": "kkyeop", + "꼆": "kkyeop", + "꼇": "kkyeot", + "꼈": "kkyeot", + "꼉": "kkyeong", + "꼊": "kkyeot", + "꼋": "kkyeot", + "꼌": "kkyeok", + "꼍": "kkyeot", + "꼎": "kkyeop", + "꼏": "kkyeot", + "꼐": "kkye", + "꼑": "kkyek", + "꼒": "kkyekk", + "꼓": "kkyek", + "꼔": "kkyen", + "꼕": "kkyen", + "꼖": "kkyen", + "꼗": "kkyet", + "꼘": "kkyel", + "꼙": "kkyek", + "꼚": "kkyem", + "꼛": "kkyep", + "꼜": "kkyet", + "꼝": "kkyet", + "꼞": "kkyep", + "꼟": "kkyel", + "꼠": "kkyem", + "꼡": "kkyep", + "꼢": "kkyep", + "꼣": "kkyet", + "꼤": "kkyet", + "꼥": "kkyeng", + "꼦": "kkyet", + "꼧": "kkyet", + "꼨": "kkyek", + "꼩": "kkyet", + "꼪": "kkyep", + "꼫": "kkyet", + "꼬": "kko", + "꼭": "kkok", + "꼮": "kkokk", + "꼯": "kkok", + "꼰": "kkon", + "꼱": "kkon", + "꼲": "kkon", + "꼳": "kkot", + "꼴": "kkol", + "꼵": "kkok", + "꼶": "kkom", + "꼷": "kkop", + "꼸": "kkot", + "꼹": "kkot", + "꼺": "kkop", + "꼻": "kkol", + "꼼": "kkom", + "꼽": "kkop", + "꼾": "kkop", + "꼿": "kkot", + "꽀": "kkot", + "꽁": "kkong", + "꽂": "kkot", + "꽃": "kkot", + "꽄": "kkok", + "꽅": "kkot", + "꽆": "kkop", + "꽇": "kkot", + "꽈": "kkwa", + "꽉": "kkwak", + "꽊": "kkwakk", + "꽋": "kkwak", + "꽌": "kkwan", + "꽍": "kkwan", + "꽎": "kkwan", + "꽏": "kkwat", + "꽐": "kkwal", + "꽑": "kkwak", + "꽒": "kkwam", + "꽓": "kkwap", + "꽔": "kkwat", + "꽕": "kkwat", + "꽖": "kkwap", + "꽗": "kkwal", + "꽘": "kkwam", + "꽙": "kkwap", + "꽚": "kkwap", + "꽛": "kkwat", + "꽜": "kkwat", + "꽝": "kkwang", + "꽞": "kkwat", + "꽟": "kkwat", + "꽠": "kkwak", + "꽡": "kkwat", + "꽢": "kkwap", + "꽣": "kkwat", + "꽤": "kkwae", + "꽥": "kkwaek", + "꽦": "kkwaekk", + "꽧": "kkwaek", + "꽨": "kkwaen", + "꽩": "kkwaen", + "꽪": "kkwaen", + "꽫": "kkwaet", + "꽬": "kkwael", + "꽭": "kkwaek", + "꽮": "kkwaem", + "꽯": "kkwaep", + "꽰": "kkwaet", + "꽱": "kkwaet", + "꽲": "kkwaep", + "꽳": "kkwael", + "꽴": "kkwaem", + "꽵": "kkwaep", + "꽶": "kkwaep", + "꽷": "kkwaet", + "꽸": "kkwaet", + "꽹": "kkwaeng", + "꽺": "kkwaet", + "꽻": "kkwaet", + "꽼": "kkwaek", + "꽽": "kkwaet", + "꽾": "kkwaep", + "꽿": "kkwaet", + "꾀": "kkoe", + "꾁": "kkoek", + "꾂": "kkoekk", + "꾃": "kkoek", + "꾄": "kkoen", + "꾅": "kkoen", + "꾆": "kkoen", + "꾇": "kkoet", + "꾈": "kkoel", + "꾉": "kkoek", + "꾊": "kkoem", + "꾋": "kkoep", + "꾌": "kkoet", + "꾍": "kkoet", + "꾎": "kkoep", + "꾏": "kkoel", + "꾐": "kkoem", + "꾑": "kkoep", + "꾒": "kkoep", + "꾓": "kkoet", + "꾔": "kkoet", + "꾕": "kkoeng", + "꾖": "kkoet", + "꾗": "kkoet", + "꾘": "kkoek", + "꾙": "kkoet", + "꾚": "kkoep", + "꾛": "kkoet", + "꾜": "kkyo", + "꾝": "kkyok", + "꾞": "kkyokk", + "꾟": "kkyok", + "꾠": "kkyon", + "꾡": "kkyon", + "꾢": "kkyon", + "꾣": "kkyot", + "꾤": "kkyol", + "꾥": "kkyok", + "꾦": "kkyom", + "꾧": "kkyop", + "꾨": "kkyot", + "꾩": "kkyot", + "꾪": "kkyop", + "꾫": "kkyol", + "꾬": "kkyom", + "꾭": "kkyop", + "꾮": "kkyop", + "꾯": "kkyot", + "꾰": "kkyot", + "꾱": "kkyong", + "꾲": "kkyot", + "꾳": "kkyot", + "꾴": "kkyok", + "꾵": "kkyot", + "꾶": "kkyop", + "꾷": "kkyot", + "꾸": "kku", + "꾹": "kkuk", + "꾺": "kkukk", + "꾻": "kkuk", + "꾼": "kkun", + "꾽": "kkun", + "꾾": "kkun", + "꾿": "kkut", + "꿀": "kkul", + "꿁": "kkuk", + "꿂": "kkum", + "꿃": "kkup", + "꿄": "kkut", + "꿅": "kkut", + "꿆": "kkup", + "꿇": "kkul", + "꿈": "kkum", + "꿉": "kkup", + "꿊": "kkup", + "꿋": "kkut", + "꿌": "kkut", + "꿍": "kkung", + "꿎": "kkut", + "꿏": "kkut", + "꿐": "kkuk", + "꿑": "kkut", + "꿒": "kkup", + "꿓": "kkut", + "꿔": "kkwo", + "꿕": "kkwok", + "꿖": "kkwokk", + "꿗": "kkwok", + "꿘": "kkwon", + "꿙": "kkwon", + "꿚": "kkwon", + "꿛": "kkwot", + "꿜": "kkwol", + "꿝": "kkwok", + "꿞": "kkwom", + "꿟": "kkwop", + "꿠": "kkwot", + "꿡": "kkwot", + "꿢": "kkwop", + "꿣": "kkwol", + "꿤": "kkwom", + "꿥": "kkwop", + "꿦": "kkwop", + "꿧": "kkwot", + "꿨": "kkwot", + "꿩": "kkwong", + "꿪": "kkwot", + "꿫": "kkwot", + "꿬": "kkwok", + "꿭": "kkwot", + "꿮": "kkwop", + "꿯": "kkwot", + "꿰": "kkwe", + "꿱": "kkwek", + "꿲": "kkwekk", + "꿳": "kkwek", + "꿴": "kkwen", + "꿵": "kkwen", + "꿶": "kkwen", + "꿷": "kkwet", + "꿸": "kkwel", + "꿹": "kkwek", + "꿺": "kkwem", + "꿻": "kkwep", + "꿼": "kkwet", + "꿽": "kkwet", + "꿾": "kkwep", + "꿿": "kkwel", + "뀀": "kkwem", + "뀁": "kkwep", + "뀂": "kkwep", + "뀃": "kkwet", + "뀄": "kkwet", + "뀅": "kkweng", + "뀆": "kkwet", + "뀇": "kkwet", + "뀈": "kkwek", + "뀉": "kkwet", + "뀊": "kkwep", + "뀋": "kkwet", + "뀌": "kkwi", + "뀍": "kkwik", + "뀎": "kkwikk", + "뀏": "kkwik", + "뀐": "kkwin", + "뀑": "kkwin", + "뀒": "kkwin", + "뀓": "kkwit", + "뀔": "kkwil", + "뀕": "kkwik", + "뀖": "kkwim", + "뀗": "kkwip", + "뀘": "kkwit", + "뀙": "kkwit", + "뀚": "kkwip", + "뀛": "kkwil", + "뀜": "kkwim", + "뀝": "kkwip", + "뀞": "kkwip", + "뀟": "kkwit", + "뀠": "kkwit", + "뀡": "kkwing", + "뀢": "kkwit", + "뀣": "kkwit", + "뀤": "kkwik", + "뀥": "kkwit", + "뀦": "kkwip", + "뀧": "kkwit", + "뀨": "kkyu", + "뀩": "kkyuk", + "뀪": "kkyukk", + "뀫": "kkyuk", + "뀬": "kkyun", + "뀭": "kkyun", + "뀮": "kkyun", + "뀯": "kkyut", + "뀰": "kkyul", + "뀱": "kkyuk", + "뀲": "kkyum", + "뀳": "kkyup", + "뀴": "kkyut", + "뀵": "kkyut", + "뀶": "kkyup", + "뀷": "kkyul", + "뀸": "kkyum", + "뀹": "kkyup", + "뀺": "kkyup", + "뀻": "kkyut", + "뀼": "kkyut", + "뀽": "kkyung", + "뀾": "kkyut", + "뀿": "kkyut", + "끀": "kkyuk", + "끁": "kkyut", + "끂": "kkyup", + "끃": "kkyut", + "끄": "kkeu", + "끅": "kkeuk", + "끆": "kkeukk", + "끇": "kkeuk", + "끈": "kkeun", + "끉": "kkeun", + "끊": "kkeun", + "끋": "kkeut", + "끌": "kkeul", + "끍": "kkeuk", + "끎": "kkeum", + "끏": "kkeup", + "끐": "kkeut", + "끑": "kkeut", + "끒": "kkeup", + "끓": "kkeul", + "끔": "kkeum", + "끕": "kkeup", + "끖": "kkeup", + "끗": "kkeut", + "끘": "kkeut", + "끙": "kkeung", + "끚": "kkeut", + "끛": "kkeut", + "끜": "kkeuk", + "끝": "kkeut", + "끞": "kkeup", + "끟": "kkeut", + "끠": "kkeui", + "끡": "kkeuik", + "끢": "kkeuikk", + "끣": "kkeuik", + "끤": "kkeuin", + "끥": "kkeuin", + "끦": "kkeuin", + "끧": "kkeuit", + "끨": "kkeuil", + "끩": "kkeuik", + "끪": "kkeuim", + "끫": "kkeuip", + "끬": "kkeuit", + "끭": "kkeuit", + "끮": "kkeuip", + "끯": "kkeuil", + "끰": "kkeuim", + "끱": "kkeuip", + "끲": "kkeuip", + "끳": "kkeuit", + "끴": "kkeuit", + "끵": "kkeuing", + "끶": "kkeuit", + "끷": "kkeuit", + "끸": "kkeuik", + "끹": "kkeuit", + "끺": "kkeuip", + "끻": "kkeuit", + "끼": "kki", + "끽": "kkik", + "끾": "kkikk", + "끿": "kkik", + "낀": "kkin", + "낁": "kkin", + "낂": "kkin", + "낃": "kkit", + "낄": "kkil", + "낅": "kkik", + "낆": "kkim", + "낇": "kkip", + "낈": "kkit", + "낉": "kkit", + "낊": "kkip", + "낋": "kkil", + "낌": "kkim", + "낍": "kkip", + "낎": "kkip", + "낏": "kkit", + "낐": "kkit", + "낑": "kking", + "낒": "kkit", + "낓": "kkit", + "낔": "kkik", + "낕": "kkit", + "낖": "kkip", + "낗": "kkit", + "나": "na", + "낙": "nak", + "낚": "nakk", + "낛": "nak", + "난": "nan", + "낝": "nan", + "낞": "nan", + "낟": "nat", + "날": "nal", + "낡": "nak", + "낢": "nam", + "낣": "nap", + "낤": "nat", + "낥": "nat", + "낦": "nap", + "낧": "nal", + "남": "nam", + "납": "nap", + "낪": "nap", + "낫": "nat", + "났": "nat", + "낭": "nang", + "낮": "nat", + "낯": "nat", + "낰": "nak", + "낱": "nat", + "낲": "nap", + "낳": "nat", + "내": "nae", + "낵": "naek", + "낶": "naekk", + "낷": "naek", + "낸": "naen", + "낹": "naen", + "낺": "naen", + "낻": "naet", + "낼": "nael", + "낽": "naek", + "낾": "naem", + "낿": "naep", + "냀": "naet", + "냁": "naet", + "냂": "naep", + "냃": "nael", + "냄": "naem", + "냅": "naep", + "냆": "naep", + "냇": "naet", + "냈": "naet", + "냉": "naeng", + "냊": "naet", + "냋": "naet", + "냌": "naek", + "냍": "naet", + "냎": "naep", + "냏": "naet", + "냐": "nya", + "냑": "nyak", + "냒": "nyakk", + "냓": "nyak", + "냔": "nyan", + "냕": "nyan", + "냖": "nyan", + "냗": "nyat", + "냘": "nyal", + "냙": "nyak", + "냚": "nyam", + "냛": "nyap", + "냜": "nyat", + "냝": "nyat", + "냞": "nyap", + "냟": "nyal", + "냠": "nyam", + "냡": "nyap", + "냢": "nyap", + "냣": "nyat", + "냤": "nyat", + "냥": "nyang", + "냦": "nyat", + "냧": "nyat", + "냨": "nyak", + "냩": "nyat", + "냪": "nyap", + "냫": "nyat", + "냬": "nyae", + "냭": "nyaek", + "냮": "nyaekk", + "냯": "nyaek", + "냰": "nyaen", + "냱": "nyaen", + "냲": "nyaen", + "냳": "nyaet", + "냴": "nyael", + "냵": "nyaek", + "냶": "nyaem", + "냷": "nyaep", + "냸": "nyaet", + "냹": "nyaet", + "냺": "nyaep", + "냻": "nyael", + "냼": "nyaem", + "냽": "nyaep", + "냾": "nyaep", + "냿": "nyaet", + "넀": "nyaet", + "넁": "nyaeng", + "넂": "nyaet", + "넃": "nyaet", + "넄": "nyaek", + "넅": "nyaet", + "넆": "nyaep", + "넇": "nyaet", + "너": "neo", + "넉": "neok", + "넊": "neokk", + "넋": "neok", + "넌": "neon", + "넍": "neon", + "넎": "neon", + "넏": "neot", + "널": "neol", + "넑": "neok", + "넒": "neom", + "넓": "neop", + "넔": "neot", + "넕": "neot", + "넖": "neop", + "넗": "neol", + "넘": "neom", + "넙": "neop", + "넚": "neop", + "넛": "neot", + "넜": "neot", + "넝": "neong", + "넞": "neot", + "넟": "neot", + "넠": "neok", + "넡": "neot", + "넢": "neop", + "넣": "neot", + "네": "ne", + "넥": "nek", + "넦": "nekk", + "넧": "nek", + "넨": "nen", + "넩": "nen", + "넪": "nen", + "넫": "net", + "넬": "nel", + "넭": "nek", + "넮": "nem", + "넯": "nep", + "넰": "net", + "넱": "net", + "넲": "nep", + "넳": "nel", + "넴": "nem", + "넵": "nep", + "넶": "nep", + "넷": "net", + "넸": "net", + "넹": "neng", + "넺": "net", + "넻": "net", + "넼": "nek", + "넽": "net", + "넾": "nep", + "넿": "net", + "녀": "nyeo", + "녁": "nyeok", + "녂": "nyeokk", + "녃": "nyeok", + "년": "nyeon", + "녅": "nyeon", + "녆": "nyeon", + "녇": "nyeot", + "녈": "nyeol", + "녉": "nyeok", + "녊": "nyeom", + "녋": "nyeop", + "녌": "nyeot", + "녍": "nyeot", + "녎": "nyeop", + "녏": "nyeol", + "념": "nyeom", + "녑": "nyeop", + "녒": "nyeop", + "녓": "nyeot", + "녔": "nyeot", + "녕": "nyeong", + "녖": "nyeot", + "녗": "nyeot", + "녘": "nyeok", + "녙": "nyeot", + "녚": "nyeop", + "녛": "nyeot", + "녜": "nye", + "녝": "nyek", + "녞": "nyekk", + "녟": "nyek", + "녠": "nyen", + "녡": "nyen", + "녢": "nyen", + "녣": "nyet", + "녤": "nyel", + "녥": "nyek", + "녦": "nyem", + "녧": "nyep", + "녨": "nyet", + "녩": "nyet", + "녪": "nyep", + "녫": "nyel", + "녬": "nyem", + "녭": "nyep", + "녮": "nyep", + "녯": "nyet", + "녰": "nyet", + "녱": "nyeng", + "녲": "nyet", + "녳": "nyet", + "녴": "nyek", + "녵": "nyet", + "녶": "nyep", + "녷": "nyet", + "노": "no", + "녹": "nok", + "녺": "nokk", + "녻": "nok", + "논": "non", + "녽": "non", + "녾": "non", + "녿": "not", + "놀": "nol", + "놁": "nok", + "놂": "nom", + "놃": "nop", + "놄": "not", + "놅": "not", + "놆": "nop", + "놇": "nol", + "놈": "nom", + "놉": "nop", + "놊": "nop", + "놋": "not", + "놌": "not", + "농": "nong", + "놎": "not", + "놏": "not", + "놐": "nok", + "놑": "not", + "높": "nop", + "놓": "not", + "놔": "nwa", + "놕": "nwak", + "놖": "nwakk", + "놗": "nwak", + "놘": "nwan", + "놙": "nwan", + "놚": "nwan", + "놛": "nwat", + "놜": "nwal", + "놝": "nwak", + "놞": "nwam", + "놟": "nwap", + "놠": "nwat", + "놡": "nwat", + "놢": "nwap", + "놣": "nwal", + "놤": "nwam", + "놥": "nwap", + "놦": "nwap", + "놧": "nwat", + "놨": "nwat", + "놩": "nwang", + "놪": "nwat", + "놫": "nwat", + "놬": "nwak", + "놭": "nwat", + "놮": "nwap", + "놯": "nwat", + "놰": "nwae", + "놱": "nwaek", + "놲": "nwaekk", + "놳": "nwaek", + "놴": "nwaen", + "놵": "nwaen", + "놶": "nwaen", + "놷": "nwaet", + "놸": "nwael", + "놹": "nwaek", + "놺": "nwaem", + "놻": "nwaep", + "놼": "nwaet", + "놽": "nwaet", + "놾": "nwaep", + "놿": "nwael", + "뇀": "nwaem", + "뇁": "nwaep", + "뇂": "nwaep", + "뇃": "nwaet", + "뇄": "nwaet", + "뇅": "nwaeng", + "뇆": "nwaet", + "뇇": "nwaet", + "뇈": "nwaek", + "뇉": "nwaet", + "뇊": "nwaep", + "뇋": "nwaet", + "뇌": "noe", + "뇍": "noek", + "뇎": "noekk", + "뇏": "noek", + "뇐": "noen", + "뇑": "noen", + "뇒": "noen", + "뇓": "noet", + "뇔": "noel", + "뇕": "noek", + "뇖": "noem", + "뇗": "noep", + "뇘": "noet", + "뇙": "noet", + "뇚": "noep", + "뇛": "noel", + "뇜": "noem", + "뇝": "noep", + "뇞": "noep", + "뇟": "noet", + "뇠": "noet", + "뇡": "noeng", + "뇢": "noet", + "뇣": "noet", + "뇤": "noek", + "뇥": "noet", + "뇦": "noep", + "뇧": "noet", + "뇨": "nyo", + "뇩": "nyok", + "뇪": "nyokk", + "뇫": "nyok", + "뇬": "nyon", + "뇭": "nyon", + "뇮": "nyon", + "뇯": "nyot", + "뇰": "nyol", + "뇱": "nyok", + "뇲": "nyom", + "뇳": "nyop", + "뇴": "nyot", + "뇵": "nyot", + "뇶": "nyop", + "뇷": "nyol", + "뇸": "nyom", + "뇹": "nyop", + "뇺": "nyop", + "뇻": "nyot", + "뇼": "nyot", + "뇽": "nyong", + "뇾": "nyot", + "뇿": "nyot", + "눀": "nyok", + "눁": "nyot", + "눂": "nyop", + "눃": "nyot", + "누": "nu", + "눅": "nuk", + "눆": "nukk", + "눇": "nuk", + "눈": "nun", + "눉": "nun", + "눊": "nun", + "눋": "nut", + "눌": "nul", + "눍": "nuk", + "눎": "num", + "눏": "nup", + "눐": "nut", + "눑": "nut", + "눒": "nup", + "눓": "nul", + "눔": "num", + "눕": "nup", + "눖": "nup", + "눗": "nut", + "눘": "nut", + "눙": "nung", + "눚": "nut", + "눛": "nut", + "눜": "nuk", + "눝": "nut", + "눞": "nup", + "눟": "nut", + "눠": "nwo", + "눡": "nwok", + "눢": "nwokk", + "눣": "nwok", + "눤": "nwon", + "눥": "nwon", + "눦": "nwon", + "눧": "nwot", + "눨": "nwol", + "눩": "nwok", + "눪": "nwom", + "눫": "nwop", + "눬": "nwot", + "눭": "nwot", + "눮": "nwop", + "눯": "nwol", + "눰": "nwom", + "눱": "nwop", + "눲": "nwop", + "눳": "nwot", + "눴": "nwot", + "눵": "nwong", + "눶": "nwot", + "눷": "nwot", + "눸": "nwok", + "눹": "nwot", + "눺": "nwop", + "눻": "nwot", + "눼": "nwe", + "눽": "nwek", + "눾": "nwekk", + "눿": "nwek", + "뉀": "nwen", + "뉁": "nwen", + "뉂": "nwen", + "뉃": "nwet", + "뉄": "nwel", + "뉅": "nwek", + "뉆": "nwem", + "뉇": "nwep", + "뉈": "nwet", + "뉉": "nwet", + "뉊": "nwep", + "뉋": "nwel", + "뉌": "nwem", + "뉍": "nwep", + "뉎": "nwep", + "뉏": "nwet", + "뉐": "nwet", + "뉑": "nweng", + "뉒": "nwet", + "뉓": "nwet", + "뉔": "nwek", + "뉕": "nwet", + "뉖": "nwep", + "뉗": "nwet", + "뉘": "nwi", + "뉙": "nwik", + "뉚": "nwikk", + "뉛": "nwik", + "뉜": "nwin", + "뉝": "nwin", + "뉞": "nwin", + "뉟": "nwit", + "뉠": "nwil", + "뉡": "nwik", + "뉢": "nwim", + "뉣": "nwip", + "뉤": "nwit", + "뉥": "nwit", + "뉦": "nwip", + "뉧": "nwil", + "뉨": "nwim", + "뉩": "nwip", + "뉪": "nwip", + "뉫": "nwit", + "뉬": "nwit", + "뉭": "nwing", + "뉮": "nwit", + "뉯": "nwit", + "뉰": "nwik", + "뉱": "nwit", + "뉲": "nwip", + "뉳": "nwit", + "뉴": "nyu", + "뉵": "nyuk", + "뉶": "nyukk", + "뉷": "nyuk", + "뉸": "nyun", + "뉹": "nyun", + "뉺": "nyun", + "뉻": "nyut", + "뉼": "nyul", + "뉽": "nyuk", + "뉾": "nyum", + "뉿": "nyup", + "늀": "nyut", + "늁": "nyut", + "늂": "nyup", + "늃": "nyul", + "늄": "nyum", + "늅": "nyup", + "늆": "nyup", + "늇": "nyut", + "늈": "nyut", + "늉": "nyung", + "늊": "nyut", + "늋": "nyut", + "늌": "nyuk", + "늍": "nyut", + "늎": "nyup", + "늏": "nyut", + "느": "neu", + "늑": "neuk", + "늒": "neukk", + "늓": "neuk", + "는": "neun", + "늕": "neun", + "늖": "neun", + "늗": "neut", + "늘": "neul", + "늙": "neuk", + "늚": "neum", + "늛": "neup", + "늜": "neut", + "늝": "neut", + "늞": "neup", + "늟": "neul", + "늠": "neum", + "늡": "neup", + "늢": "neup", + "늣": "neut", + "늤": "neut", + "능": "neung", + "늦": "neut", + "늧": "neut", + "늨": "neuk", + "늩": "neut", + "늪": "neup", + "늫": "neut", + "늬": "neui", + "늭": "neuik", + "늮": "neuikk", + "늯": "neuik", + "늰": "neuin", + "늱": "neuin", + "늲": "neuin", + "늳": "neuit", + "늴": "neuil", + "늵": "neuik", + "늶": "neuim", + "늷": "neuip", + "늸": "neuit", + "늹": "neuit", + "늺": "neuip", + "늻": "neuil", + "늼": "neuim", + "늽": "neuip", + "늾": "neuip", + "늿": "neuit", + "닀": "neuit", + "닁": "neuing", + "닂": "neuit", + "닃": "neuit", + "닄": "neuik", + "닅": "neuit", + "닆": "neuip", + "닇": "neuit", + "니": "ni", + "닉": "nik", + "닊": "nikk", + "닋": "nik", + "닌": "nin", + "닍": "nin", + "닎": "nin", + "닏": "nit", + "닐": "nil", + "닑": "nik", + "닒": "nim", + "닓": "nip", + "닔": "nit", + "닕": "nit", + "닖": "nip", + "닗": "nil", + "님": "nim", + "닙": "nip", + "닚": "nip", + "닛": "nit", + "닜": "nit", + "닝": "ning", + "닞": "nit", + "닟": "nit", + "닠": "nik", + "닡": "nit", + "닢": "nip", + "닣": "nit", + "다": "da", + "닥": "dak", + "닦": "dakk", + "닧": "dak", + "단": "dan", + "닩": "dan", + "닪": "dan", + "닫": "dat", + "달": "dal", + "닭": "dak", + "닮": "dam", + "닯": "dap", + "닰": "dat", + "닱": "dat", + "닲": "dap", + "닳": "dal", + "담": "dam", + "답": "dap", + "닶": "dap", + "닷": "dat", + "닸": "dat", + "당": "dang", + "닺": "dat", + "닻": "dat", + "닼": "dak", + "닽": "dat", + "닾": "dap", + "닿": "dat", + "대": "dae", + "댁": "daek", + "댂": "daekk", + "댃": "daek", + "댄": "daen", + "댅": "daen", + "댆": "daen", + "댇": "daet", + "댈": "dael", + "댉": "daek", + "댊": "daem", + "댋": "daep", + "댌": "daet", + "댍": "daet", + "댎": "daep", + "댏": "dael", + "댐": "daem", + "댑": "daep", + "댒": "daep", + "댓": "daet", + "댔": "daet", + "댕": "daeng", + "댖": "daet", + "댗": "daet", + "댘": "daek", + "댙": "daet", + "댚": "daep", + "댛": "daet", + "댜": "dya", + "댝": "dyak", + "댞": "dyakk", + "댟": "dyak", + "댠": "dyan", + "댡": "dyan", + "댢": "dyan", + "댣": "dyat", + "댤": "dyal", + "댥": "dyak", + "댦": "dyam", + "댧": "dyap", + "댨": "dyat", + "댩": "dyat", + "댪": "dyap", + "댫": "dyal", + "댬": "dyam", + "댭": "dyap", + "댮": "dyap", + "댯": "dyat", + "댰": "dyat", + "댱": "dyang", + "댲": "dyat", + "댳": "dyat", + "댴": "dyak", + "댵": "dyat", + "댶": "dyap", + "댷": "dyat", + "댸": "dyae", + "댹": "dyaek", + "댺": "dyaekk", + "댻": "dyaek", + "댼": "dyaen", + "댽": "dyaen", + "댾": "dyaen", + "댿": "dyaet", + "덀": "dyael", + "덁": "dyaek", + "덂": "dyaem", + "덃": "dyaep", + "덄": "dyaet", + "덅": "dyaet", + "덆": "dyaep", + "덇": "dyael", + "덈": "dyaem", + "덉": "dyaep", + "덊": "dyaep", + "덋": "dyaet", + "덌": "dyaet", + "덍": "dyaeng", + "덎": "dyaet", + "덏": "dyaet", + "덐": "dyaek", + "덑": "dyaet", + "덒": "dyaep", + "덓": "dyaet", + "더": "deo", + "덕": "deok", + "덖": "deokk", + "덗": "deok", + "던": "deon", + "덙": "deon", + "덚": "deon", + "덛": "deot", + "덜": "deol", + "덝": "deok", + "덞": "deom", + "덟": "deop", + "덠": "deot", + "덡": "deot", + "덢": "deop", + "덣": "deol", + "덤": "deom", + "덥": "deop", + "덦": "deop", + "덧": "deot", + "덨": "deot", + "덩": "deong", + "덪": "deot", + "덫": "deot", + "덬": "deok", + "덭": "deot", + "덮": "deop", + "덯": "deot", + "데": "de", + "덱": "dek", + "덲": "dekk", + "덳": "dek", + "덴": "den", + "덵": "den", + "덶": "den", + "덷": "det", + "델": "del", + "덹": "dek", + "덺": "dem", + "덻": "dep", + "덼": "det", + "덽": "det", + "덾": "dep", + "덿": "del", + "뎀": "dem", + "뎁": "dep", + "뎂": "dep", + "뎃": "det", + "뎄": "det", + "뎅": "deng", + "뎆": "det", + "뎇": "det", + "뎈": "dek", + "뎉": "det", + "뎊": "dep", + "뎋": "det", + "뎌": "dyeo", + "뎍": "dyeok", + "뎎": "dyeokk", + "뎏": "dyeok", + "뎐": "dyeon", + "뎑": "dyeon", + "뎒": "dyeon", + "뎓": "dyeot", + "뎔": "dyeol", + "뎕": "dyeok", + "뎖": "dyeom", + "뎗": "dyeop", + "뎘": "dyeot", + "뎙": "dyeot", + "뎚": "dyeop", + "뎛": "dyeol", + "뎜": "dyeom", + "뎝": "dyeop", + "뎞": "dyeop", + "뎟": "dyeot", + "뎠": "dyeot", + "뎡": "dyeong", + "뎢": "dyeot", + "뎣": "dyeot", + "뎤": "dyeok", + "뎥": "dyeot", + "뎦": "dyeop", + "뎧": "dyeot", + "뎨": "dye", + "뎩": "dyek", + "뎪": "dyekk", + "뎫": "dyek", + "뎬": "dyen", + "뎭": "dyen", + "뎮": "dyen", + "뎯": "dyet", + "뎰": "dyel", + "뎱": "dyek", + "뎲": "dyem", + "뎳": "dyep", + "뎴": "dyet", + "뎵": "dyet", + "뎶": "dyep", + "뎷": "dyel", + "뎸": "dyem", + "뎹": "dyep", + "뎺": "dyep", + "뎻": "dyet", + "뎼": "dyet", + "뎽": "dyeng", + "뎾": "dyet", + "뎿": "dyet", + "돀": "dyek", + "돁": "dyet", + "돂": "dyep", + "돃": "dyet", + "도": "do", + "독": "dok", + "돆": "dokk", + "돇": "dok", + "돈": "don", + "돉": "don", + "돊": "don", + "돋": "dot", + "돌": "dol", + "돍": "dok", + "돎": "dom", + "돏": "dop", + "돐": "dot", + "돑": "dot", + "돒": "dop", + "돓": "dol", + "돔": "dom", + "돕": "dop", + "돖": "dop", + "돗": "dot", + "돘": "dot", + "동": "dong", + "돚": "dot", + "돛": "dot", + "돜": "dok", + "돝": "dot", + "돞": "dop", + "돟": "dot", + "돠": "dwa", + "돡": "dwak", + "돢": "dwakk", + "돣": "dwak", + "돤": "dwan", + "돥": "dwan", + "돦": "dwan", + "돧": "dwat", + "돨": "dwal", + "돩": "dwak", + "돪": "dwam", + "돫": "dwap", + "돬": "dwat", + "돭": "dwat", + "돮": "dwap", + "돯": "dwal", + "돰": "dwam", + "돱": "dwap", + "돲": "dwap", + "돳": "dwat", + "돴": "dwat", + "돵": "dwang", + "돶": "dwat", + "돷": "dwat", + "돸": "dwak", + "돹": "dwat", + "돺": "dwap", + "돻": "dwat", + "돼": "dwae", + "돽": "dwaek", + "돾": "dwaekk", + "돿": "dwaek", + "됀": "dwaen", + "됁": "dwaen", + "됂": "dwaen", + "됃": "dwaet", + "됄": "dwael", + "됅": "dwaek", + "됆": "dwaem", + "됇": "dwaep", + "됈": "dwaet", + "됉": "dwaet", + "됊": "dwaep", + "됋": "dwael", + "됌": "dwaem", + "됍": "dwaep", + "됎": "dwaep", + "됏": "dwaet", + "됐": "dwaet", + "됑": "dwaeng", + "됒": "dwaet", + "됓": "dwaet", + "됔": "dwaek", + "됕": "dwaet", + "됖": "dwaep", + "됗": "dwaet", + "되": "doe", + "됙": "doek", + "됚": "doekk", + "됛": "doek", + "된": "doen", + "됝": "doen", + "됞": "doen", + "됟": "doet", + "될": "doel", + "됡": "doek", + "됢": "doem", + "됣": "doep", + "됤": "doet", + "됥": "doet", + "됦": "doep", + "됧": "doel", + "됨": "doem", + "됩": "doep", + "됪": "doep", + "됫": "doet", + "됬": "doet", + "됭": "doeng", + "됮": "doet", + "됯": "doet", + "됰": "doek", + "됱": "doet", + "됲": "doep", + "됳": "doet", + "됴": "dyo", + "됵": "dyok", + "됶": "dyokk", + "됷": "dyok", + "됸": "dyon", + "됹": "dyon", + "됺": "dyon", + "됻": "dyot", + "됼": "dyol", + "됽": "dyok", + "됾": "dyom", + "됿": "dyop", + "둀": "dyot", + "둁": "dyot", + "둂": "dyop", + "둃": "dyol", + "둄": "dyom", + "둅": "dyop", + "둆": "dyop", + "둇": "dyot", + "둈": "dyot", + "둉": "dyong", + "둊": "dyot", + "둋": "dyot", + "둌": "dyok", + "둍": "dyot", + "둎": "dyop", + "둏": "dyot", + "두": "du", + "둑": "duk", + "둒": "dukk", + "둓": "duk", + "둔": "dun", + "둕": "dun", + "둖": "dun", + "둗": "dut", + "둘": "dul", + "둙": "duk", + "둚": "dum", + "둛": "dup", + "둜": "dut", + "둝": "dut", + "둞": "dup", + "둟": "dul", + "둠": "dum", + "둡": "dup", + "둢": "dup", + "둣": "dut", + "둤": "dut", + "둥": "dung", + "둦": "dut", + "둧": "dut", + "둨": "duk", + "둩": "dut", + "둪": "dup", + "둫": "dut", + "둬": "dwo", + "둭": "dwok", + "둮": "dwokk", + "둯": "dwok", + "둰": "dwon", + "둱": "dwon", + "둲": "dwon", + "둳": "dwot", + "둴": "dwol", + "둵": "dwok", + "둶": "dwom", + "둷": "dwop", + "둸": "dwot", + "둹": "dwot", + "둺": "dwop", + "둻": "dwol", + "둼": "dwom", + "둽": "dwop", + "둾": "dwop", + "둿": "dwot", + "뒀": "dwot", + "뒁": "dwong", + "뒂": "dwot", + "뒃": "dwot", + "뒄": "dwok", + "뒅": "dwot", + "뒆": "dwop", + "뒇": "dwot", + "뒈": "dwe", + "뒉": "dwek", + "뒊": "dwekk", + "뒋": "dwek", + "뒌": "dwen", + "뒍": "dwen", + "뒎": "dwen", + "뒏": "dwet", + "뒐": "dwel", + "뒑": "dwek", + "뒒": "dwem", + "뒓": "dwep", + "뒔": "dwet", + "뒕": "dwet", + "뒖": "dwep", + "뒗": "dwel", + "뒘": "dwem", + "뒙": "dwep", + "뒚": "dwep", + "뒛": "dwet", + "뒜": "dwet", + "뒝": "dweng", + "뒞": "dwet", + "뒟": "dwet", + "뒠": "dwek", + "뒡": "dwet", + "뒢": "dwep", + "뒣": "dwet", + "뒤": "dwi", + "뒥": "dwik", + "뒦": "dwikk", + "뒧": "dwik", + "뒨": "dwin", + "뒩": "dwin", + "뒪": "dwin", + "뒫": "dwit", + "뒬": "dwil", + "뒭": "dwik", + "뒮": "dwim", + "뒯": "dwip", + "뒰": "dwit", + "뒱": "dwit", + "뒲": "dwip", + "뒳": "dwil", + "뒴": "dwim", + "뒵": "dwip", + "뒶": "dwip", + "뒷": "dwit", + "뒸": "dwit", + "뒹": "dwing", + "뒺": "dwit", + "뒻": "dwit", + "뒼": "dwik", + "뒽": "dwit", + "뒾": "dwip", + "뒿": "dwit", + "듀": "dyu", + "듁": "dyuk", + "듂": "dyukk", + "듃": "dyuk", + "듄": "dyun", + "듅": "dyun", + "듆": "dyun", + "듇": "dyut", + "듈": "dyul", + "듉": "dyuk", + "듊": "dyum", + "듋": "dyup", + "듌": "dyut", + "듍": "dyut", + "듎": "dyup", + "듏": "dyul", + "듐": "dyum", + "듑": "dyup", + "듒": "dyup", + "듓": "dyut", + "듔": "dyut", + "듕": "dyung", + "듖": "dyut", + "듗": "dyut", + "듘": "dyuk", + "듙": "dyut", + "듚": "dyup", + "듛": "dyut", + "드": "deu", + "득": "deuk", + "듞": "deukk", + "듟": "deuk", + "든": "deun", + "듡": "deun", + "듢": "deun", + "듣": "deut", + "들": "deul", + "듥": "deuk", + "듦": "deum", + "듧": "deup", + "듨": "deut", + "듩": "deut", + "듪": "deup", + "듫": "deul", + "듬": "deum", + "듭": "deup", + "듮": "deup", + "듯": "deut", + "듰": "deut", + "등": "deung", + "듲": "deut", + "듳": "deut", + "듴": "deuk", + "듵": "deut", + "듶": "deup", + "듷": "deut", + "듸": "deui", + "듹": "deuik", + "듺": "deuikk", + "듻": "deuik", + "듼": "deuin", + "듽": "deuin", + "듾": "deuin", + "듿": "deuit", + "딀": "deuil", + "딁": "deuik", + "딂": "deuim", + "딃": "deuip", + "딄": "deuit", + "딅": "deuit", + "딆": "deuip", + "딇": "deuil", + "딈": "deuim", + "딉": "deuip", + "딊": "deuip", + "딋": "deuit", + "딌": "deuit", + "딍": "deuing", + "딎": "deuit", + "딏": "deuit", + "딐": "deuik", + "딑": "deuit", + "딒": "deuip", + "딓": "deuit", + "디": "di", + "딕": "dik", + "딖": "dikk", + "딗": "dik", + "딘": "din", + "딙": "din", + "딚": "din", + "딛": "dit", + "딜": "dil", + "딝": "dik", + "딞": "dim", + "딟": "dip", + "딠": "dit", + "딡": "dit", + "딢": "dip", + "딣": "dil", + "딤": "dim", + "딥": "dip", + "딦": "dip", + "딧": "dit", + "딨": "dit", + "딩": "ding", + "딪": "dit", + "딫": "dit", + "딬": "dik", + "딭": "dit", + "딮": "dip", + "딯": "dit", + "따": "tta", + "딱": "ttak", + "딲": "ttakk", + "딳": "ttak", + "딴": "ttan", + "딵": "ttan", + "딶": "ttan", + "딷": "ttat", + "딸": "ttal", + "딹": "ttak", + "딺": "ttam", + "딻": "ttap", + "딼": "ttat", + "딽": "ttat", + "딾": "ttap", + "딿": "ttal", + "땀": "ttam", + "땁": "ttap", + "땂": "ttap", + "땃": "ttat", + "땄": "ttat", + "땅": "ttang", + "땆": "ttat", + "땇": "ttat", + "땈": "ttak", + "땉": "ttat", + "땊": "ttap", + "땋": "ttat", + "때": "ttae", + "땍": "ttaek", + "땎": "ttaekk", + "땏": "ttaek", + "땐": "ttaen", + "땑": "ttaen", + "땒": "ttaen", + "땓": "ttaet", + "땔": "ttael", + "땕": "ttaek", + "땖": "ttaem", + "땗": "ttaep", + "땘": "ttaet", + "땙": "ttaet", + "땚": "ttaep", + "땛": "ttael", + "땜": "ttaem", + "땝": "ttaep", + "땞": "ttaep", + "땟": "ttaet", + "땠": "ttaet", + "땡": "ttaeng", + "땢": "ttaet", + "땣": "ttaet", + "땤": "ttaek", + "땥": "ttaet", + "땦": "ttaep", + "땧": "ttaet", + "땨": "ttya", + "땩": "ttyak", + "땪": "ttyakk", + "땫": "ttyak", + "땬": "ttyan", + "땭": "ttyan", + "땮": "ttyan", + "땯": "ttyat", + "땰": "ttyal", + "땱": "ttyak", + "땲": "ttyam", + "땳": "ttyap", + "땴": "ttyat", + "땵": "ttyat", + "땶": "ttyap", + "땷": "ttyal", + "땸": "ttyam", + "땹": "ttyap", + "땺": "ttyap", + "땻": "ttyat", + "땼": "ttyat", + "땽": "ttyang", + "땾": "ttyat", + "땿": "ttyat", + "떀": "ttyak", + "떁": "ttyat", + "떂": "ttyap", + "떃": "ttyat", + "떄": "ttyae", + "떅": "ttyaek", + "떆": "ttyaekk", + "떇": "ttyaek", + "떈": "ttyaen", + "떉": "ttyaen", + "떊": "ttyaen", + "떋": "ttyaet", + "떌": "ttyael", + "떍": "ttyaek", + "떎": "ttyaem", + "떏": "ttyaep", + "떐": "ttyaet", + "떑": "ttyaet", + "떒": "ttyaep", + "떓": "ttyael", + "떔": "ttyaem", + "떕": "ttyaep", + "떖": "ttyaep", + "떗": "ttyaet", + "떘": "ttyaet", + "떙": "ttyaeng", + "떚": "ttyaet", + "떛": "ttyaet", + "떜": "ttyaek", + "떝": "ttyaet", + "떞": "ttyaep", + "떟": "ttyaet", + "떠": "tteo", + "떡": "tteok", + "떢": "tteokk", + "떣": "tteok", + "떤": "tteon", + "떥": "tteon", + "떦": "tteon", + "떧": "tteot", + "떨": "tteol", + "떩": "tteok", + "떪": "tteom", + "떫": "tteop", + "떬": "tteot", + "떭": "tteot", + "떮": "tteop", + "떯": "tteol", + "떰": "tteom", + "떱": "tteop", + "떲": "tteop", + "떳": "tteot", + "떴": "tteot", + "떵": "tteong", + "떶": "tteot", + "떷": "tteot", + "떸": "tteok", + "떹": "tteot", + "떺": "tteop", + "떻": "tteot", + "떼": "tte", + "떽": "ttek", + "떾": "ttekk", + "떿": "ttek", + "뗀": "tten", + "뗁": "tten", + "뗂": "tten", + "뗃": "ttet", + "뗄": "ttel", + "뗅": "ttek", + "뗆": "ttem", + "뗇": "ttep", + "뗈": "ttet", + "뗉": "ttet", + "뗊": "ttep", + "뗋": "ttel", + "뗌": "ttem", + "뗍": "ttep", + "뗎": "ttep", + "뗏": "ttet", + "뗐": "ttet", + "뗑": "tteng", + "뗒": "ttet", + "뗓": "ttet", + "뗔": "ttek", + "뗕": "ttet", + "뗖": "ttep", + "뗗": "ttet", + "뗘": "ttyeo", + "뗙": "ttyeok", + "뗚": "ttyeokk", + "뗛": "ttyeok", + "뗜": "ttyeon", + "뗝": "ttyeon", + "뗞": "ttyeon", + "뗟": "ttyeot", + "뗠": "ttyeol", + "뗡": "ttyeok", + "뗢": "ttyeom", + "뗣": "ttyeop", + "뗤": "ttyeot", + "뗥": "ttyeot", + "뗦": "ttyeop", + "뗧": "ttyeol", + "뗨": "ttyeom", + "뗩": "ttyeop", + "뗪": "ttyeop", + "뗫": "ttyeot", + "뗬": "ttyeot", + "뗭": "ttyeong", + "뗮": "ttyeot", + "뗯": "ttyeot", + "뗰": "ttyeok", + "뗱": "ttyeot", + "뗲": "ttyeop", + "뗳": "ttyeot", + "뗴": "ttye", + "뗵": "ttyek", + "뗶": "ttyekk", + "뗷": "ttyek", + "뗸": "ttyen", + "뗹": "ttyen", + "뗺": "ttyen", + "뗻": "ttyet", + "뗼": "ttyel", + "뗽": "ttyek", + "뗾": "ttyem", + "뗿": "ttyep", + "똀": "ttyet", + "똁": "ttyet", + "똂": "ttyep", + "똃": "ttyel", + "똄": "ttyem", + "똅": "ttyep", + "똆": "ttyep", + "똇": "ttyet", + "똈": "ttyet", + "똉": "ttyeng", + "똊": "ttyet", + "똋": "ttyet", + "똌": "ttyek", + "똍": "ttyet", + "똎": "ttyep", + "똏": "ttyet", + "또": "tto", + "똑": "ttok", + "똒": "ttokk", + "똓": "ttok", + "똔": "tton", + "똕": "tton", + "똖": "tton", + "똗": "ttot", + "똘": "ttol", + "똙": "ttok", + "똚": "ttom", + "똛": "ttop", + "똜": "ttot", + "똝": "ttot", + "똞": "ttop", + "똟": "ttol", + "똠": "ttom", + "똡": "ttop", + "똢": "ttop", + "똣": "ttot", + "똤": "ttot", + "똥": "ttong", + "똦": "ttot", + "똧": "ttot", + "똨": "ttok", + "똩": "ttot", + "똪": "ttop", + "똫": "ttot", + "똬": "ttwa", + "똭": "ttwak", + "똮": "ttwakk", + "똯": "ttwak", + "똰": "ttwan", + "똱": "ttwan", + "똲": "ttwan", + "똳": "ttwat", + "똴": "ttwal", + "똵": "ttwak", + "똶": "ttwam", + "똷": "ttwap", + "똸": "ttwat", + "똹": "ttwat", + "똺": "ttwap", + "똻": "ttwal", + "똼": "ttwam", + "똽": "ttwap", + "똾": "ttwap", + "똿": "ttwat", + "뙀": "ttwat", + "뙁": "ttwang", + "뙂": "ttwat", + "뙃": "ttwat", + "뙄": "ttwak", + "뙅": "ttwat", + "뙆": "ttwap", + "뙇": "ttwat", + "뙈": "ttwae", + "뙉": "ttwaek", + "뙊": "ttwaekk", + "뙋": "ttwaek", + "뙌": "ttwaen", + "뙍": "ttwaen", + "뙎": "ttwaen", + "뙏": "ttwaet", + "뙐": "ttwael", + "뙑": "ttwaek", + "뙒": "ttwaem", + "뙓": "ttwaep", + "뙔": "ttwaet", + "뙕": "ttwaet", + "뙖": "ttwaep", + "뙗": "ttwael", + "뙘": "ttwaem", + "뙙": "ttwaep", + "뙚": "ttwaep", + "뙛": "ttwaet", + "뙜": "ttwaet", + "뙝": "ttwaeng", + "뙞": "ttwaet", + "뙟": "ttwaet", + "뙠": "ttwaek", + "뙡": "ttwaet", + "뙢": "ttwaep", + "뙣": "ttwaet", + "뙤": "ttoe", + "뙥": "ttoek", + "뙦": "ttoekk", + "뙧": "ttoek", + "뙨": "ttoen", + "뙩": "ttoen", + "뙪": "ttoen", + "뙫": "ttoet", + "뙬": "ttoel", + "뙭": "ttoek", + "뙮": "ttoem", + "뙯": "ttoep", + "뙰": "ttoet", + "뙱": "ttoet", + "뙲": "ttoep", + "뙳": "ttoel", + "뙴": "ttoem", + "뙵": "ttoep", + "뙶": "ttoep", + "뙷": "ttoet", + "뙸": "ttoet", + "뙹": "ttoeng", + "뙺": "ttoet", + "뙻": "ttoet", + "뙼": "ttoek", + "뙽": "ttoet", + "뙾": "ttoep", + "뙿": "ttoet", + "뚀": "ttyo", + "뚁": "ttyok", + "뚂": "ttyokk", + "뚃": "ttyok", + "뚄": "ttyon", + "뚅": "ttyon", + "뚆": "ttyon", + "뚇": "ttyot", + "뚈": "ttyol", + "뚉": "ttyok", + "뚊": "ttyom", + "뚋": "ttyop", + "뚌": "ttyot", + "뚍": "ttyot", + "뚎": "ttyop", + "뚏": "ttyol", + "뚐": "ttyom", + "뚑": "ttyop", + "뚒": "ttyop", + "뚓": "ttyot", + "뚔": "ttyot", + "뚕": "ttyong", + "뚖": "ttyot", + "뚗": "ttyot", + "뚘": "ttyok", + "뚙": "ttyot", + "뚚": "ttyop", + "뚛": "ttyot", + "뚜": "ttu", + "뚝": "ttuk", + "뚞": "ttukk", + "뚟": "ttuk", + "뚠": "ttun", + "뚡": "ttun", + "뚢": "ttun", + "뚣": "ttut", + "뚤": "ttul", + "뚥": "ttuk", + "뚦": "ttum", + "뚧": "ttup", + "뚨": "ttut", + "뚩": "ttut", + "뚪": "ttup", + "뚫": "ttul", + "뚬": "ttum", + "뚭": "ttup", + "뚮": "ttup", + "뚯": "ttut", + "뚰": "ttut", + "뚱": "ttung", + "뚲": "ttut", + "뚳": "ttut", + "뚴": "ttuk", + "뚵": "ttut", + "뚶": "ttup", + "뚷": "ttut", + "뚸": "ttwo", + "뚹": "ttwok", + "뚺": "ttwokk", + "뚻": "ttwok", + "뚼": "ttwon", + "뚽": "ttwon", + "뚾": "ttwon", + "뚿": "ttwot", + "뛀": "ttwol", + "뛁": "ttwok", + "뛂": "ttwom", + "뛃": "ttwop", + "뛄": "ttwot", + "뛅": "ttwot", + "뛆": "ttwop", + "뛇": "ttwol", + "뛈": "ttwom", + "뛉": "ttwop", + "뛊": "ttwop", + "뛋": "ttwot", + "뛌": "ttwot", + "뛍": "ttwong", + "뛎": "ttwot", + "뛏": "ttwot", + "뛐": "ttwok", + "뛑": "ttwot", + "뛒": "ttwop", + "뛓": "ttwot", + "뛔": "ttwe", + "뛕": "ttwek", + "뛖": "ttwekk", + "뛗": "ttwek", + "뛘": "ttwen", + "뛙": "ttwen", + "뛚": "ttwen", + "뛛": "ttwet", + "뛜": "ttwel", + "뛝": "ttwek", + "뛞": "ttwem", + "뛟": "ttwep", + "뛠": "ttwet", + "뛡": "ttwet", + "뛢": "ttwep", + "뛣": "ttwel", + "뛤": "ttwem", + "뛥": "ttwep", + "뛦": "ttwep", + "뛧": "ttwet", + "뛨": "ttwet", + "뛩": "ttweng", + "뛪": "ttwet", + "뛫": "ttwet", + "뛬": "ttwek", + "뛭": "ttwet", + "뛮": "ttwep", + "뛯": "ttwet", + "뛰": "ttwi", + "뛱": "ttwik", + "뛲": "ttwikk", + "뛳": "ttwik", + "뛴": "ttwin", + "뛵": "ttwin", + "뛶": "ttwin", + "뛷": "ttwit", + "뛸": "ttwil", + "뛹": "ttwik", + "뛺": "ttwim", + "뛻": "ttwip", + "뛼": "ttwit", + "뛽": "ttwit", + "뛾": "ttwip", + "뛿": "ttwil", + "뜀": "ttwim", + "뜁": "ttwip", + "뜂": "ttwip", + "뜃": "ttwit", + "뜄": "ttwit", + "뜅": "ttwing", + "뜆": "ttwit", + "뜇": "ttwit", + "뜈": "ttwik", + "뜉": "ttwit", + "뜊": "ttwip", + "뜋": "ttwit", + "뜌": "ttyu", + "뜍": "ttyuk", + "뜎": "ttyukk", + "뜏": "ttyuk", + "뜐": "ttyun", + "뜑": "ttyun", + "뜒": "ttyun", + "뜓": "ttyut", + "뜔": "ttyul", + "뜕": "ttyuk", + "뜖": "ttyum", + "뜗": "ttyup", + "뜘": "ttyut", + "뜙": "ttyut", + "뜚": "ttyup", + "뜛": "ttyul", + "뜜": "ttyum", + "뜝": "ttyup", + "뜞": "ttyup", + "뜟": "ttyut", + "뜠": "ttyut", + "뜡": "ttyung", + "뜢": "ttyut", + "뜣": "ttyut", + "뜤": "ttyuk", + "뜥": "ttyut", + "뜦": "ttyup", + "뜧": "ttyut", + "뜨": "tteu", + "뜩": "tteuk", + "뜪": "tteukk", + "뜫": "tteuk", + "뜬": "tteun", + "뜭": "tteun", + "뜮": "tteun", + "뜯": "tteut", + "뜰": "tteul", + "뜱": "tteuk", + "뜲": "tteum", + "뜳": "tteup", + "뜴": "tteut", + "뜵": "tteut", + "뜶": "tteup", + "뜷": "tteul", + "뜸": "tteum", + "뜹": "tteup", + "뜺": "tteup", + "뜻": "tteut", + "뜼": "tteut", + "뜽": "tteung", + "뜾": "tteut", + "뜿": "tteut", + "띀": "tteuk", + "띁": "tteut", + "띂": "tteup", + "띃": "tteut", + "띄": "tteui", + "띅": "tteuik", + "띆": "tteuikk", + "띇": "tteuik", + "띈": "tteuin", + "띉": "tteuin", + "띊": "tteuin", + "띋": "tteuit", + "띌": "tteuil", + "띍": "tteuik", + "띎": "tteuim", + "띏": "tteuip", + "띐": "tteuit", + "띑": "tteuit", + "띒": "tteuip", + "띓": "tteuil", + "띔": "tteuim", + "띕": "tteuip", + "띖": "tteuip", + "띗": "tteuit", + "띘": "tteuit", + "띙": "tteuing", + "띚": "tteuit", + "띛": "tteuit", + "띜": "tteuik", + "띝": "tteuit", + "띞": "tteuip", + "띟": "tteuit", + "띠": "tti", + "띡": "ttik", + "띢": "ttikk", + "띣": "ttik", + "띤": "ttin", + "띥": "ttin", + "띦": "ttin", + "띧": "ttit", + "띨": "ttil", + "띩": "ttik", + "띪": "ttim", + "띫": "ttip", + "띬": "ttit", + "띭": "ttit", + "띮": "ttip", + "띯": "ttil", + "띰": "ttim", + "띱": "ttip", + "띲": "ttip", + "띳": "ttit", + "띴": "ttit", + "띵": "tting", + "띶": "ttit", + "띷": "ttit", + "띸": "ttik", + "띹": "ttit", + "띺": "ttip", + "띻": "ttit", + "라": "ra", + "락": "rak", + "띾": "rakk", + "띿": "rak", + "란": "ran", + "랁": "ran", + "랂": "ran", + "랃": "rat", + "랄": "ral", + "랅": "rak", + "랆": "ram", + "랇": "rap", + "랈": "rat", + "랉": "rat", + "랊": "rap", + "랋": "ral", + "람": "ram", + "랍": "rap", + "랎": "rap", + "랏": "rat", + "랐": "rat", + "랑": "rang", + "랒": "rat", + "랓": "rat", + "랔": "rak", + "랕": "rat", + "랖": "rap", + "랗": "rat", + "래": "rae", + "랙": "raek", + "랚": "raekk", + "랛": "raek", + "랜": "raen", + "랝": "raen", + "랞": "raen", + "랟": "raet", + "랠": "rael", + "랡": "raek", + "랢": "raem", + "랣": "raep", + "랤": "raet", + "랥": "raet", + "랦": "raep", + "랧": "rael", + "램": "raem", + "랩": "raep", + "랪": "raep", + "랫": "raet", + "랬": "raet", + "랭": "raeng", + "랮": "raet", + "랯": "raet", + "랰": "raek", + "랱": "raet", + "랲": "raep", + "랳": "raet", + "랴": "rya", + "략": "ryak", + "랶": "ryakk", + "랷": "ryak", + "랸": "ryan", + "랹": "ryan", + "랺": "ryan", + "랻": "ryat", + "랼": "ryal", + "랽": "ryak", + "랾": "ryam", + "랿": "ryap", + "럀": "ryat", + "럁": "ryat", + "럂": "ryap", + "럃": "ryal", + "럄": "ryam", + "럅": "ryap", + "럆": "ryap", + "럇": "ryat", + "럈": "ryat", + "량": "ryang", + "럊": "ryat", + "럋": "ryat", + "럌": "ryak", + "럍": "ryat", + "럎": "ryap", + "럏": "ryat", + "럐": "ryae", + "럑": "ryaek", + "럒": "ryaekk", + "럓": "ryaek", + "럔": "ryaen", + "럕": "ryaen", + "럖": "ryaen", + "럗": "ryaet", + "럘": "ryael", + "럙": "ryaek", + "럚": "ryaem", + "럛": "ryaep", + "럜": "ryaet", + "럝": "ryaet", + "럞": "ryaep", + "럟": "ryael", + "럠": "ryaem", + "럡": "ryaep", + "럢": "ryaep", + "럣": "ryaet", + "럤": "ryaet", + "럥": "ryaeng", + "럦": "ryaet", + "럧": "ryaet", + "럨": "ryaek", + "럩": "ryaet", + "럪": "ryaep", + "럫": "ryaet", + "러": "reo", + "럭": "reok", + "럮": "reokk", + "럯": "reok", + "런": "reon", + "럱": "reon", + "럲": "reon", + "럳": "reot", + "럴": "reol", + "럵": "reok", + "럶": "reom", + "럷": "reop", + "럸": "reot", + "럹": "reot", + "럺": "reop", + "럻": "reol", + "럼": "reom", + "럽": "reop", + "럾": "reop", + "럿": "reot", + "렀": "reot", + "렁": "reong", + "렂": "reot", + "렃": "reot", + "렄": "reok", + "렅": "reot", + "렆": "reop", + "렇": "reot", + "레": "re", + "렉": "rek", + "렊": "rekk", + "렋": "rek", + "렌": "ren", + "렍": "ren", + "렎": "ren", + "렏": "ret", + "렐": "rel", + "렑": "rek", + "렒": "rem", + "렓": "rep", + "렔": "ret", + "렕": "ret", + "렖": "rep", + "렗": "rel", + "렘": "rem", + "렙": "rep", + "렚": "rep", + "렛": "ret", + "렜": "ret", + "렝": "reng", + "렞": "ret", + "렟": "ret", + "렠": "rek", + "렡": "ret", + "렢": "rep", + "렣": "ret", + "려": "ryeo", + "력": "ryeok", + "렦": "ryeokk", + "렧": "ryeok", + "련": "ryeon", + "렩": "ryeon", + "렪": "ryeon", + "렫": "ryeot", + "렬": "ryeol", + "렭": "ryeok", + "렮": "ryeom", + "렯": "ryeop", + "렰": "ryeot", + "렱": "ryeot", + "렲": "ryeop", + "렳": "ryeol", + "렴": "ryeom", + "렵": "ryeop", + "렶": "ryeop", + "렷": "ryeot", + "렸": "ryeot", + "령": "ryeong", + "렺": "ryeot", + "렻": "ryeot", + "렼": "ryeok", + "렽": "ryeot", + "렾": "ryeop", + "렿": "ryeot", + "례": "rye", + "롁": "ryek", + "롂": "ryekk", + "롃": "ryek", + "롄": "ryen", + "롅": "ryen", + "롆": "ryen", + "롇": "ryet", + "롈": "ryel", + "롉": "ryek", + "롊": "ryem", + "롋": "ryep", + "롌": "ryet", + "롍": "ryet", + "롎": "ryep", + "롏": "ryel", + "롐": "ryem", + "롑": "ryep", + "롒": "ryep", + "롓": "ryet", + "롔": "ryet", + "롕": "ryeng", + "롖": "ryet", + "롗": "ryet", + "롘": "ryek", + "롙": "ryet", + "롚": "ryep", + "롛": "ryet", + "로": "ro", + "록": "rok", + "롞": "rokk", + "롟": "rok", + "론": "ron", + "롡": "ron", + "롢": "ron", + "롣": "rot", + "롤": "rol", + "롥": "rok", + "롦": "rom", + "롧": "rop", + "롨": "rot", + "롩": "rot", + "롪": "rop", + "롫": "rol", + "롬": "rom", + "롭": "rop", + "롮": "rop", + "롯": "rot", + "롰": "rot", + "롱": "rong", + "롲": "rot", + "롳": "rot", + "롴": "rok", + "롵": "rot", + "롶": "rop", + "롷": "rot", + "롸": "rwa", + "롹": "rwak", + "롺": "rwakk", + "롻": "rwak", + "롼": "rwan", + "롽": "rwan", + "롾": "rwan", + "롿": "rwat", + "뢀": "rwal", + "뢁": "rwak", + "뢂": "rwam", + "뢃": "rwap", + "뢄": "rwat", + "뢅": "rwat", + "뢆": "rwap", + "뢇": "rwal", + "뢈": "rwam", + "뢉": "rwap", + "뢊": "rwap", + "뢋": "rwat", + "뢌": "rwat", + "뢍": "rwang", + "뢎": "rwat", + "뢏": "rwat", + "뢐": "rwak", + "뢑": "rwat", + "뢒": "rwap", + "뢓": "rwat", + "뢔": "rwae", + "뢕": "rwaek", + "뢖": "rwaekk", + "뢗": "rwaek", + "뢘": "rwaen", + "뢙": "rwaen", + "뢚": "rwaen", + "뢛": "rwaet", + "뢜": "rwael", + "뢝": "rwaek", + "뢞": "rwaem", + "뢟": "rwaep", + "뢠": "rwaet", + "뢡": "rwaet", + "뢢": "rwaep", + "뢣": "rwael", + "뢤": "rwaem", + "뢥": "rwaep", + "뢦": "rwaep", + "뢧": "rwaet", + "뢨": "rwaet", + "뢩": "rwaeng", + "뢪": "rwaet", + "뢫": "rwaet", + "뢬": "rwaek", + "뢭": "rwaet", + "뢮": "rwaep", + "뢯": "rwaet", + "뢰": "roe", + "뢱": "roek", + "뢲": "roekk", + "뢳": "roek", + "뢴": "roen", + "뢵": "roen", + "뢶": "roen", + "뢷": "roet", + "뢸": "roel", + "뢹": "roek", + "뢺": "roem", + "뢻": "roep", + "뢼": "roet", + "뢽": "roet", + "뢾": "roep", + "뢿": "roel", + "룀": "roem", + "룁": "roep", + "룂": "roep", + "룃": "roet", + "룄": "roet", + "룅": "roeng", + "룆": "roet", + "룇": "roet", + "룈": "roek", + "룉": "roet", + "룊": "roep", + "룋": "roet", + "료": "ryo", + "룍": "ryok", + "룎": "ryokk", + "룏": "ryok", + "룐": "ryon", + "룑": "ryon", + "룒": "ryon", + "룓": "ryot", + "룔": "ryol", + "룕": "ryok", + "룖": "ryom", + "룗": "ryop", + "룘": "ryot", + "룙": "ryot", + "룚": "ryop", + "룛": "ryol", + "룜": "ryom", + "룝": "ryop", + "룞": "ryop", + "룟": "ryot", + "룠": "ryot", + "룡": "ryong", + "룢": "ryot", + "룣": "ryot", + "룤": "ryok", + "룥": "ryot", + "룦": "ryop", + "룧": "ryot", + "루": "ru", + "룩": "ruk", + "룪": "rukk", + "룫": "ruk", + "룬": "run", + "룭": "run", + "룮": "run", + "룯": "rut", + "룰": "rul", + "룱": "ruk", + "룲": "rum", + "룳": "rup", + "룴": "rut", + "룵": "rut", + "룶": "rup", + "룷": "rul", + "룸": "rum", + "룹": "rup", + "룺": "rup", + "룻": "rut", + "룼": "rut", + "룽": "rung", + "룾": "rut", + "룿": "rut", + "뤀": "ruk", + "뤁": "rut", + "뤂": "rup", + "뤃": "rut", + "뤄": "rwo", + "뤅": "rwok", + "뤆": "rwokk", + "뤇": "rwok", + "뤈": "rwon", + "뤉": "rwon", + "뤊": "rwon", + "뤋": "rwot", + "뤌": "rwol", + "뤍": "rwok", + "뤎": "rwom", + "뤏": "rwop", + "뤐": "rwot", + "뤑": "rwot", + "뤒": "rwop", + "뤓": "rwol", + "뤔": "rwom", + "뤕": "rwop", + "뤖": "rwop", + "뤗": "rwot", + "뤘": "rwot", + "뤙": "rwong", + "뤚": "rwot", + "뤛": "rwot", + "뤜": "rwok", + "뤝": "rwot", + "뤞": "rwop", + "뤟": "rwot", + "뤠": "rwe", + "뤡": "rwek", + "뤢": "rwekk", + "뤣": "rwek", + "뤤": "rwen", + "뤥": "rwen", + "뤦": "rwen", + "뤧": "rwet", + "뤨": "rwel", + "뤩": "rwek", + "뤪": "rwem", + "뤫": "rwep", + "뤬": "rwet", + "뤭": "rwet", + "뤮": "rwep", + "뤯": "rwel", + "뤰": "rwem", + "뤱": "rwep", + "뤲": "rwep", + "뤳": "rwet", + "뤴": "rwet", + "뤵": "rweng", + "뤶": "rwet", + "뤷": "rwet", + "뤸": "rwek", + "뤹": "rwet", + "뤺": "rwep", + "뤻": "rwet", + "뤼": "rwi", + "뤽": "rwik", + "뤾": "rwikk", + "뤿": "rwik", + "륀": "rwin", + "륁": "rwin", + "륂": "rwin", + "륃": "rwit", + "륄": "rwil", + "륅": "rwik", + "륆": "rwim", + "륇": "rwip", + "륈": "rwit", + "륉": "rwit", + "륊": "rwip", + "륋": "rwil", + "륌": "rwim", + "륍": "rwip", + "륎": "rwip", + "륏": "rwit", + "륐": "rwit", + "륑": "rwing", + "륒": "rwit", + "륓": "rwit", + "륔": "rwik", + "륕": "rwit", + "륖": "rwip", + "륗": "rwit", + "류": "ryu", + "륙": "ryuk", + "륚": "ryukk", + "륛": "ryuk", + "륜": "ryun", + "륝": "ryun", + "륞": "ryun", + "륟": "ryut", + "률": "ryul", + "륡": "ryuk", + "륢": "ryum", + "륣": "ryup", + "륤": "ryut", + "륥": "ryut", + "륦": "ryup", + "륧": "ryul", + "륨": "ryum", + "륩": "ryup", + "륪": "ryup", + "륫": "ryut", + "륬": "ryut", + "륭": "ryung", + "륮": "ryut", + "륯": "ryut", + "륰": "ryuk", + "륱": "ryut", + "륲": "ryup", + "륳": "ryut", + "르": "reu", + "륵": "reuk", + "륶": "reukk", + "륷": "reuk", + "른": "reun", + "륹": "reun", + "륺": "reun", + "륻": "reut", + "를": "reul", + "륽": "reuk", + "륾": "reum", + "륿": "reup", + "릀": "reut", + "릁": "reut", + "릂": "reup", + "릃": "reul", + "름": "reum", + "릅": "reup", + "릆": "reup", + "릇": "reut", + "릈": "reut", + "릉": "reung", + "릊": "reut", + "릋": "reut", + "릌": "reuk", + "릍": "reut", + "릎": "reup", + "릏": "reut", + "릐": "reui", + "릑": "reuik", + "릒": "reuikk", + "릓": "reuik", + "릔": "reuin", + "릕": "reuin", + "릖": "reuin", + "릗": "reuit", + "릘": "reuil", + "릙": "reuik", + "릚": "reuim", + "릛": "reuip", + "릜": "reuit", + "릝": "reuit", + "릞": "reuip", + "릟": "reuil", + "릠": "reuim", + "릡": "reuip", + "릢": "reuip", + "릣": "reuit", + "릤": "reuit", + "릥": "reuing", + "릦": "reuit", + "릧": "reuit", + "릨": "reuik", + "릩": "reuit", + "릪": "reuip", + "릫": "reuit", + "리": "ri", + "릭": "rik", + "릮": "rikk", + "릯": "rik", + "린": "rin", + "릱": "rin", + "릲": "rin", + "릳": "rit", + "릴": "ril", + "릵": "rik", + "릶": "rim", + "릷": "rip", + "릸": "rit", + "릹": "rit", + "릺": "rip", + "릻": "ril", + "림": "rim", + "립": "rip", + "릾": "rip", + "릿": "rit", + "맀": "rit", + "링": "ring", + "맂": "rit", + "맃": "rit", + "맄": "rik", + "맅": "rit", + "맆": "rip", + "맇": "rit", + "마": "ma", + "막": "mak", + "맊": "makk", + "맋": "mak", + "만": "man", + "맍": "man", + "많": "man", + "맏": "mat", + "말": "mal", + "맑": "mak", + "맒": "mam", + "맓": "map", + "맔": "mat", + "맕": "mat", + "맖": "map", + "맗": "mal", + "맘": "mam", + "맙": "map", + "맚": "map", + "맛": "mat", + "맜": "mat", + "망": "mang", + "맞": "mat", + "맟": "mat", + "맠": "mak", + "맡": "mat", + "맢": "map", + "맣": "mat", + "매": "mae", + "맥": "maek", + "맦": "maekk", + "맧": "maek", + "맨": "maen", + "맩": "maen", + "맪": "maen", + "맫": "maet", + "맬": "mael", + "맭": "maek", + "맮": "maem", + "맯": "maep", + "맰": "maet", + "맱": "maet", + "맲": "maep", + "맳": "mael", + "맴": "maem", + "맵": "maep", + "맶": "maep", + "맷": "maet", + "맸": "maet", + "맹": "maeng", + "맺": "maet", + "맻": "maet", + "맼": "maek", + "맽": "maet", + "맾": "maep", + "맿": "maet", + "먀": "mya", + "먁": "myak", + "먂": "myakk", + "먃": "myak", + "먄": "myan", + "먅": "myan", + "먆": "myan", + "먇": "myat", + "먈": "myal", + "먉": "myak", + "먊": "myam", + "먋": "myap", + "먌": "myat", + "먍": "myat", + "먎": "myap", + "먏": "myal", + "먐": "myam", + "먑": "myap", + "먒": "myap", + "먓": "myat", + "먔": "myat", + "먕": "myang", + "먖": "myat", + "먗": "myat", + "먘": "myak", + "먙": "myat", + "먚": "myap", + "먛": "myat", + "먜": "myae", + "먝": "myaek", + "먞": "myaekk", + "먟": "myaek", + "먠": "myaen", + "먡": "myaen", + "먢": "myaen", + "먣": "myaet", + "먤": "myael", + "먥": "myaek", + "먦": "myaem", + "먧": "myaep", + "먨": "myaet", + "먩": "myaet", + "먪": "myaep", + "먫": "myael", + "먬": "myaem", + "먭": "myaep", + "먮": "myaep", + "먯": "myaet", + "먰": "myaet", + "먱": "myaeng", + "먲": "myaet", + "먳": "myaet", + "먴": "myaek", + "먵": "myaet", + "먶": "myaep", + "먷": "myaet", + "머": "meo", + "먹": "meok", + "먺": "meokk", + "먻": "meok", + "먼": "meon", + "먽": "meon", + "먾": "meon", + "먿": "meot", + "멀": "meol", + "멁": "meok", + "멂": "meom", + "멃": "meop", + "멄": "meot", + "멅": "meot", + "멆": "meop", + "멇": "meol", + "멈": "meom", + "멉": "meop", + "멊": "meop", + "멋": "meot", + "멌": "meot", + "멍": "meong", + "멎": "meot", + "멏": "meot", + "멐": "meok", + "멑": "meot", + "멒": "meop", + "멓": "meot", + "메": "me", + "멕": "mek", + "멖": "mekk", + "멗": "mek", + "멘": "men", + "멙": "men", + "멚": "men", + "멛": "met", + "멜": "mel", + "멝": "mek", + "멞": "mem", + "멟": "mep", + "멠": "met", + "멡": "met", + "멢": "mep", + "멣": "mel", + "멤": "mem", + "멥": "mep", + "멦": "mep", + "멧": "met", + "멨": "met", + "멩": "meng", + "멪": "met", + "멫": "met", + "멬": "mek", + "멭": "met", + "멮": "mep", + "멯": "met", + "며": "myeo", + "멱": "myeok", + "멲": "myeokk", + "멳": "myeok", + "면": "myeon", + "멵": "myeon", + "멶": "myeon", + "멷": "myeot", + "멸": "myeol", + "멹": "myeok", + "멺": "myeom", + "멻": "myeop", + "멼": "myeot", + "멽": "myeot", + "멾": "myeop", + "멿": "myeol", + "몀": "myeom", + "몁": "myeop", + "몂": "myeop", + "몃": "myeot", + "몄": "myeot", + "명": "myeong", + "몆": "myeot", + "몇": "myeot", + "몈": "myeok", + "몉": "myeot", + "몊": "myeop", + "몋": "myeot", + "몌": "mye", + "몍": "myek", + "몎": "myekk", + "몏": "myek", + "몐": "myen", + "몑": "myen", + "몒": "myen", + "몓": "myet", + "몔": "myel", + "몕": "myek", + "몖": "myem", + "몗": "myep", + "몘": "myet", + "몙": "myet", + "몚": "myep", + "몛": "myel", + "몜": "myem", + "몝": "myep", + "몞": "myep", + "몟": "myet", + "몠": "myet", + "몡": "myeng", + "몢": "myet", + "몣": "myet", + "몤": "myek", + "몥": "myet", + "몦": "myep", + "몧": "myet", + "모": "mo", + "목": "mok", + "몪": "mokk", + "몫": "mok", + "몬": "mon", + "몭": "mon", + "몮": "mon", + "몯": "mot", + "몰": "mol", + "몱": "mok", + "몲": "mom", + "몳": "mop", + "몴": "mot", + "몵": "mot", + "몶": "mop", + "몷": "mol", + "몸": "mom", + "몹": "mop", + "몺": "mop", + "못": "mot", + "몼": "mot", + "몽": "mong", + "몾": "mot", + "몿": "mot", + "뫀": "mok", + "뫁": "mot", + "뫂": "mop", + "뫃": "mot", + "뫄": "mwa", + "뫅": "mwak", + "뫆": "mwakk", + "뫇": "mwak", + "뫈": "mwan", + "뫉": "mwan", + "뫊": "mwan", + "뫋": "mwat", + "뫌": "mwal", + "뫍": "mwak", + "뫎": "mwam", + "뫏": "mwap", + "뫐": "mwat", + "뫑": "mwat", + "뫒": "mwap", + "뫓": "mwal", + "뫔": "mwam", + "뫕": "mwap", + "뫖": "mwap", + "뫗": "mwat", + "뫘": "mwat", + "뫙": "mwang", + "뫚": "mwat", + "뫛": "mwat", + "뫜": "mwak", + "뫝": "mwat", + "뫞": "mwap", + "뫟": "mwat", + "뫠": "mwae", + "뫡": "mwaek", + "뫢": "mwaekk", + "뫣": "mwaek", + "뫤": "mwaen", + "뫥": "mwaen", + "뫦": "mwaen", + "뫧": "mwaet", + "뫨": "mwael", + "뫩": "mwaek", + "뫪": "mwaem", + "뫫": "mwaep", + "뫬": "mwaet", + "뫭": "mwaet", + "뫮": "mwaep", + "뫯": "mwael", + "뫰": "mwaem", + "뫱": "mwaep", + "뫲": "mwaep", + "뫳": "mwaet", + "뫴": "mwaet", + "뫵": "mwaeng", + "뫶": "mwaet", + "뫷": "mwaet", + "뫸": "mwaek", + "뫹": "mwaet", + "뫺": "mwaep", + "뫻": "mwaet", + "뫼": "moe", + "뫽": "moek", + "뫾": "moekk", + "뫿": "moek", + "묀": "moen", + "묁": "moen", + "묂": "moen", + "묃": "moet", + "묄": "moel", + "묅": "moek", + "묆": "moem", + "묇": "moep", + "묈": "moet", + "묉": "moet", + "묊": "moep", + "묋": "moel", + "묌": "moem", + "묍": "moep", + "묎": "moep", + "묏": "moet", + "묐": "moet", + "묑": "moeng", + "묒": "moet", + "묓": "moet", + "묔": "moek", + "묕": "moet", + "묖": "moep", + "묗": "moet", + "묘": "myo", + "묙": "myok", + "묚": "myokk", + "묛": "myok", + "묜": "myon", + "묝": "myon", + "묞": "myon", + "묟": "myot", + "묠": "myol", + "묡": "myok", + "묢": "myom", + "묣": "myop", + "묤": "myot", + "묥": "myot", + "묦": "myop", + "묧": "myol", + "묨": "myom", + "묩": "myop", + "묪": "myop", + "묫": "myot", + "묬": "myot", + "묭": "myong", + "묮": "myot", + "묯": "myot", + "묰": "myok", + "묱": "myot", + "묲": "myop", + "묳": "myot", + "무": "mu", + "묵": "muk", + "묶": "mukk", + "묷": "muk", + "문": "mun", + "묹": "mun", + "묺": "mun", + "묻": "mut", + "물": "mul", + "묽": "muk", + "묾": "mum", + "묿": "mup", + "뭀": "mut", + "뭁": "mut", + "뭂": "mup", + "뭃": "mul", + "뭄": "mum", + "뭅": "mup", + "뭆": "mup", + "뭇": "mut", + "뭈": "mut", + "뭉": "mung", + "뭊": "mut", + "뭋": "mut", + "뭌": "muk", + "뭍": "mut", + "뭎": "mup", + "뭏": "mut", + "뭐": "mwo", + "뭑": "mwok", + "뭒": "mwokk", + "뭓": "mwok", + "뭔": "mwon", + "뭕": "mwon", + "뭖": "mwon", + "뭗": "mwot", + "뭘": "mwol", + "뭙": "mwok", + "뭚": "mwom", + "뭛": "mwop", + "뭜": "mwot", + "뭝": "mwot", + "뭞": "mwop", + "뭟": "mwol", + "뭠": "mwom", + "뭡": "mwop", + "뭢": "mwop", + "뭣": "mwot", + "뭤": "mwot", + "뭥": "mwong", + "뭦": "mwot", + "뭧": "mwot", + "뭨": "mwok", + "뭩": "mwot", + "뭪": "mwop", + "뭫": "mwot", + "뭬": "mwe", + "뭭": "mwek", + "뭮": "mwekk", + "뭯": "mwek", + "뭰": "mwen", + "뭱": "mwen", + "뭲": "mwen", + "뭳": "mwet", + "뭴": "mwel", + "뭵": "mwek", + "뭶": "mwem", + "뭷": "mwep", + "뭸": "mwet", + "뭹": "mwet", + "뭺": "mwep", + "뭻": "mwel", + "뭼": "mwem", + "뭽": "mwep", + "뭾": "mwep", + "뭿": "mwet", + "뮀": "mwet", + "뮁": "mweng", + "뮂": "mwet", + "뮃": "mwet", + "뮄": "mwek", + "뮅": "mwet", + "뮆": "mwep", + "뮇": "mwet", + "뮈": "mwi", + "뮉": "mwik", + "뮊": "mwikk", + "뮋": "mwik", + "뮌": "mwin", + "뮍": "mwin", + "뮎": "mwin", + "뮏": "mwit", + "뮐": "mwil", + "뮑": "mwik", + "뮒": "mwim", + "뮓": "mwip", + "뮔": "mwit", + "뮕": "mwit", + "뮖": "mwip", + "뮗": "mwil", + "뮘": "mwim", + "뮙": "mwip", + "뮚": "mwip", + "뮛": "mwit", + "뮜": "mwit", + "뮝": "mwing", + "뮞": "mwit", + "뮟": "mwit", + "뮠": "mwik", + "뮡": "mwit", + "뮢": "mwip", + "뮣": "mwit", + "뮤": "myu", + "뮥": "myuk", + "뮦": "myukk", + "뮧": "myuk", + "뮨": "myun", + "뮩": "myun", + "뮪": "myun", + "뮫": "myut", + "뮬": "myul", + "뮭": "myuk", + "뮮": "myum", + "뮯": "myup", + "뮰": "myut", + "뮱": "myut", + "뮲": "myup", + "뮳": "myul", + "뮴": "myum", + "뮵": "myup", + "뮶": "myup", + "뮷": "myut", + "뮸": "myut", + "뮹": "myung", + "뮺": "myut", + "뮻": "myut", + "뮼": "myuk", + "뮽": "myut", + "뮾": "myup", + "뮿": "myut", + "므": "meu", + "믁": "meuk", + "믂": "meukk", + "믃": "meuk", + "믄": "meun", + "믅": "meun", + "믆": "meun", + "믇": "meut", + "믈": "meul", + "믉": "meuk", + "믊": "meum", + "믋": "meup", + "믌": "meut", + "믍": "meut", + "믎": "meup", + "믏": "meul", + "믐": "meum", + "믑": "meup", + "믒": "meup", + "믓": "meut", + "믔": "meut", + "믕": "meung", + "믖": "meut", + "믗": "meut", + "믘": "meuk", + "믙": "meut", + "믚": "meup", + "믛": "meut", + "믜": "meui", + "믝": "meuik", + "믞": "meuikk", + "믟": "meuik", + "믠": "meuin", + "믡": "meuin", + "믢": "meuin", + "믣": "meuit", + "믤": "meuil", + "믥": "meuik", + "믦": "meuim", + "믧": "meuip", + "믨": "meuit", + "믩": "meuit", + "믪": "meuip", + "믫": "meuil", + "믬": "meuim", + "믭": "meuip", + "믮": "meuip", + "믯": "meuit", + "믰": "meuit", + "믱": "meuing", + "믲": "meuit", + "믳": "meuit", + "믴": "meuik", + "믵": "meuit", + "믶": "meuip", + "믷": "meuit", + "미": "mi", + "믹": "mik", + "믺": "mikk", + "믻": "mik", + "민": "min", + "믽": "min", + "믾": "min", + "믿": "mit", + "밀": "mil", + "밁": "mik", + "밂": "mim", + "밃": "mip", + "밄": "mit", + "밅": "mit", + "밆": "mip", + "밇": "mil", + "밈": "mim", + "밉": "mip", + "밊": "mip", + "밋": "mit", + "밌": "mit", + "밍": "ming", + "밎": "mit", + "및": "mit", + "밐": "mik", + "밑": "mit", + "밒": "mip", + "밓": "mit", + "바": "ba", + "박": "bak", + "밖": "bakk", + "밗": "bak", + "반": "ban", + "밙": "ban", + "밚": "ban", + "받": "bat", + "발": "bal", + "밝": "bak", + "밞": "bam", + "밟": "bap", + "밠": "bat", + "밡": "bat", + "밢": "bap", + "밣": "bal", + "밤": "bam", + "밥": "bap", + "밦": "bap", + "밧": "bat", + "밨": "bat", + "방": "bang", + "밪": "bat", + "밫": "bat", + "밬": "bak", + "밭": "bat", + "밮": "bap", + "밯": "bat", + "배": "bae", + "백": "baek", + "밲": "baekk", + "밳": "baek", + "밴": "baen", + "밵": "baen", + "밶": "baen", + "밷": "baet", + "밸": "bael", + "밹": "baek", + "밺": "baem", + "밻": "baep", + "밼": "baet", + "밽": "baet", + "밾": "baep", + "밿": "bael", + "뱀": "baem", + "뱁": "baep", + "뱂": "baep", + "뱃": "baet", + "뱄": "baet", + "뱅": "baeng", + "뱆": "baet", + "뱇": "baet", + "뱈": "baek", + "뱉": "baet", + "뱊": "baep", + "뱋": "baet", + "뱌": "bya", + "뱍": "byak", + "뱎": "byakk", + "뱏": "byak", + "뱐": "byan", + "뱑": "byan", + "뱒": "byan", + "뱓": "byat", + "뱔": "byal", + "뱕": "byak", + "뱖": "byam", + "뱗": "byap", + "뱘": "byat", + "뱙": "byat", + "뱚": "byap", + "뱛": "byal", + "뱜": "byam", + "뱝": "byap", + "뱞": "byap", + "뱟": "byat", + "뱠": "byat", + "뱡": "byang", + "뱢": "byat", + "뱣": "byat", + "뱤": "byak", + "뱥": "byat", + "뱦": "byap", + "뱧": "byat", + "뱨": "byae", + "뱩": "byaek", + "뱪": "byaekk", + "뱫": "byaek", + "뱬": "byaen", + "뱭": "byaen", + "뱮": "byaen", + "뱯": "byaet", + "뱰": "byael", + "뱱": "byaek", + "뱲": "byaem", + "뱳": "byaep", + "뱴": "byaet", + "뱵": "byaet", + "뱶": "byaep", + "뱷": "byael", + "뱸": "byaem", + "뱹": "byaep", + "뱺": "byaep", + "뱻": "byaet", + "뱼": "byaet", + "뱽": "byaeng", + "뱾": "byaet", + "뱿": "byaet", + "벀": "byaek", + "벁": "byaet", + "벂": "byaep", + "벃": "byaet", + "버": "beo", + "벅": "beok", + "벆": "beokk", + "벇": "beok", + "번": "beon", + "벉": "beon", + "벊": "beon", + "벋": "beot", + "벌": "beol", + "벍": "beok", + "벎": "beom", + "벏": "beop", + "벐": "beot", + "벑": "beot", + "벒": "beop", + "벓": "beol", + "범": "beom", + "법": "beop", + "벖": "beop", + "벗": "beot", + "벘": "beot", + "벙": "beong", + "벚": "beot", + "벛": "beot", + "벜": "beok", + "벝": "beot", + "벞": "beop", + "벟": "beot", + "베": "be", + "벡": "bek", + "벢": "bekk", + "벣": "bek", + "벤": "ben", + "벥": "ben", + "벦": "ben", + "벧": "bet", + "벨": "bel", + "벩": "bek", + "벪": "bem", + "벫": "bep", + "벬": "bet", + "벭": "bet", + "벮": "bep", + "벯": "bel", + "벰": "bem", + "벱": "bep", + "벲": "bep", + "벳": "bet", + "벴": "bet", + "벵": "beng", + "벶": "bet", + "벷": "bet", + "벸": "bek", + "벹": "bet", + "벺": "bep", + "벻": "bet", + "벼": "byeo", + "벽": "byeok", + "벾": "byeokk", + "벿": "byeok", + "변": "byeon", + "볁": "byeon", + "볂": "byeon", + "볃": "byeot", + "별": "byeol", + "볅": "byeok", + "볆": "byeom", + "볇": "byeop", + "볈": "byeot", + "볉": "byeot", + "볊": "byeop", + "볋": "byeol", + "볌": "byeom", + "볍": "byeop", + "볎": "byeop", + "볏": "byeot", + "볐": "byeot", + "병": "byeong", + "볒": "byeot", + "볓": "byeot", + "볔": "byeok", + "볕": "byeot", + "볖": "byeop", + "볗": "byeot", + "볘": "bye", + "볙": "byek", + "볚": "byekk", + "볛": "byek", + "볜": "byen", + "볝": "byen", + "볞": "byen", + "볟": "byet", + "볠": "byel", + "볡": "byek", + "볢": "byem", + "볣": "byep", + "볤": "byet", + "볥": "byet", + "볦": "byep", + "볧": "byel", + "볨": "byem", + "볩": "byep", + "볪": "byep", + "볫": "byet", + "볬": "byet", + "볭": "byeng", + "볮": "byet", + "볯": "byet", + "볰": "byek", + "볱": "byet", + "볲": "byep", + "볳": "byet", + "보": "bo", + "복": "bok", + "볶": "bokk", + "볷": "bok", + "본": "bon", + "볹": "bon", + "볺": "bon", + "볻": "bot", + "볼": "bol", + "볽": "bok", + "볾": "bom", + "볿": "bop", + "봀": "bot", + "봁": "bot", + "봂": "bop", + "봃": "bol", + "봄": "bom", + "봅": "bop", + "봆": "bop", + "봇": "bot", + "봈": "bot", + "봉": "bong", + "봊": "bot", + "봋": "bot", + "봌": "bok", + "봍": "bot", + "봎": "bop", + "봏": "bot", + "봐": "bwa", + "봑": "bwak", + "봒": "bwakk", + "봓": "bwak", + "봔": "bwan", + "봕": "bwan", + "봖": "bwan", + "봗": "bwat", + "봘": "bwal", + "봙": "bwak", + "봚": "bwam", + "봛": "bwap", + "봜": "bwat", + "봝": "bwat", + "봞": "bwap", + "봟": "bwal", + "봠": "bwam", + "봡": "bwap", + "봢": "bwap", + "봣": "bwat", + "봤": "bwat", + "봥": "bwang", + "봦": "bwat", + "봧": "bwat", + "봨": "bwak", + "봩": "bwat", + "봪": "bwap", + "봫": "bwat", + "봬": "bwae", + "봭": "bwaek", + "봮": "bwaekk", + "봯": "bwaek", + "봰": "bwaen", + "봱": "bwaen", + "봲": "bwaen", + "봳": "bwaet", + "봴": "bwael", + "봵": "bwaek", + "봶": "bwaem", + "봷": "bwaep", + "봸": "bwaet", + "봹": "bwaet", + "봺": "bwaep", + "봻": "bwael", + "봼": "bwaem", + "봽": "bwaep", + "봾": "bwaep", + "봿": "bwaet", + "뵀": "bwaet", + "뵁": "bwaeng", + "뵂": "bwaet", + "뵃": "bwaet", + "뵄": "bwaek", + "뵅": "bwaet", + "뵆": "bwaep", + "뵇": "bwaet", + "뵈": "boe", + "뵉": "boek", + "뵊": "boekk", + "뵋": "boek", + "뵌": "boen", + "뵍": "boen", + "뵎": "boen", + "뵏": "boet", + "뵐": "boel", + "뵑": "boek", + "뵒": "boem", + "뵓": "boep", + "뵔": "boet", + "뵕": "boet", + "뵖": "boep", + "뵗": "boel", + "뵘": "boem", + "뵙": "boep", + "뵚": "boep", + "뵛": "boet", + "뵜": "boet", + "뵝": "boeng", + "뵞": "boet", + "뵟": "boet", + "뵠": "boek", + "뵡": "boet", + "뵢": "boep", + "뵣": "boet", + "뵤": "byo", + "뵥": "byok", + "뵦": "byokk", + "뵧": "byok", + "뵨": "byon", + "뵩": "byon", + "뵪": "byon", + "뵫": "byot", + "뵬": "byol", + "뵭": "byok", + "뵮": "byom", + "뵯": "byop", + "뵰": "byot", + "뵱": "byot", + "뵲": "byop", + "뵳": "byol", + "뵴": "byom", + "뵵": "byop", + "뵶": "byop", + "뵷": "byot", + "뵸": "byot", + "뵹": "byong", + "뵺": "byot", + "뵻": "byot", + "뵼": "byok", + "뵽": "byot", + "뵾": "byop", + "뵿": "byot", + "부": "bu", + "북": "buk", + "붂": "bukk", + "붃": "buk", + "분": "bun", + "붅": "bun", + "붆": "bun", + "붇": "but", + "불": "bul", + "붉": "buk", + "붊": "bum", + "붋": "bup", + "붌": "but", + "붍": "but", + "붎": "bup", + "붏": "bul", + "붐": "bum", + "붑": "bup", + "붒": "bup", + "붓": "but", + "붔": "but", + "붕": "bung", + "붖": "but", + "붗": "but", + "붘": "buk", + "붙": "but", + "붚": "bup", + "붛": "but", + "붜": "bwo", + "붝": "bwok", + "붞": "bwokk", + "붟": "bwok", + "붠": "bwon", + "붡": "bwon", + "붢": "bwon", + "붣": "bwot", + "붤": "bwol", + "붥": "bwok", + "붦": "bwom", + "붧": "bwop", + "붨": "bwot", + "붩": "bwot", + "붪": "bwop", + "붫": "bwol", + "붬": "bwom", + "붭": "bwop", + "붮": "bwop", + "붯": "bwot", + "붰": "bwot", + "붱": "bwong", + "붲": "bwot", + "붳": "bwot", + "붴": "bwok", + "붵": "bwot", + "붶": "bwop", + "붷": "bwot", + "붸": "bwe", + "붹": "bwek", + "붺": "bwekk", + "붻": "bwek", + "붼": "bwen", + "붽": "bwen", + "붾": "bwen", + "붿": "bwet", + "뷀": "bwel", + "뷁": "bwek", + "뷂": "bwem", + "뷃": "bwep", + "뷄": "bwet", + "뷅": "bwet", + "뷆": "bwep", + "뷇": "bwel", + "뷈": "bwem", + "뷉": "bwep", + "뷊": "bwep", + "뷋": "bwet", + "뷌": "bwet", + "뷍": "bweng", + "뷎": "bwet", + "뷏": "bwet", + "뷐": "bwek", + "뷑": "bwet", + "뷒": "bwep", + "뷓": "bwet", + "뷔": "bwi", + "뷕": "bwik", + "뷖": "bwikk", + "뷗": "bwik", + "뷘": "bwin", + "뷙": "bwin", + "뷚": "bwin", + "뷛": "bwit", + "뷜": "bwil", + "뷝": "bwik", + "뷞": "bwim", + "뷟": "bwip", + "뷠": "bwit", + "뷡": "bwit", + "뷢": "bwip", + "뷣": "bwil", + "뷤": "bwim", + "뷥": "bwip", + "뷦": "bwip", + "뷧": "bwit", + "뷨": "bwit", + "뷩": "bwing", + "뷪": "bwit", + "뷫": "bwit", + "뷬": "bwik", + "뷭": "bwit", + "뷮": "bwip", + "뷯": "bwit", + "뷰": "byu", + "뷱": "byuk", + "뷲": "byukk", + "뷳": "byuk", + "뷴": "byun", + "뷵": "byun", + "뷶": "byun", + "뷷": "byut", + "뷸": "byul", + "뷹": "byuk", + "뷺": "byum", + "뷻": "byup", + "뷼": "byut", + "뷽": "byut", + "뷾": "byup", + "뷿": "byul", + "븀": "byum", + "븁": "byup", + "븂": "byup", + "븃": "byut", + "븄": "byut", + "븅": "byung", + "븆": "byut", + "븇": "byut", + "븈": "byuk", + "븉": "byut", + "븊": "byup", + "븋": "byut", + "브": "beu", + "븍": "beuk", + "븎": "beukk", + "븏": "beuk", + "븐": "beun", + "븑": "beun", + "븒": "beun", + "븓": "beut", + "블": "beul", + "븕": "beuk", + "븖": "beum", + "븗": "beup", + "븘": "beut", + "븙": "beut", + "븚": "beup", + "븛": "beul", + "븜": "beum", + "븝": "beup", + "븞": "beup", + "븟": "beut", + "븠": "beut", + "븡": "beung", + "븢": "beut", + "븣": "beut", + "븤": "beuk", + "븥": "beut", + "븦": "beup", + "븧": "beut", + "븨": "beui", + "븩": "beuik", + "븪": "beuikk", + "븫": "beuik", + "븬": "beuin", + "븭": "beuin", + "븮": "beuin", + "븯": "beuit", + "븰": "beuil", + "븱": "beuik", + "븲": "beuim", + "븳": "beuip", + "븴": "beuit", + "븵": "beuit", + "븶": "beuip", + "븷": "beuil", + "븸": "beuim", + "븹": "beuip", + "븺": "beuip", + "븻": "beuit", + "븼": "beuit", + "븽": "beuing", + "븾": "beuit", + "븿": "beuit", + "빀": "beuik", + "빁": "beuit", + "빂": "beuip", + "빃": "beuit", + "비": "bi", + "빅": "bik", + "빆": "bikk", + "빇": "bik", + "빈": "bin", + "빉": "bin", + "빊": "bin", + "빋": "bit", + "빌": "bil", + "빍": "bik", + "빎": "bim", + "빏": "bip", + "빐": "bit", + "빑": "bit", + "빒": "bip", + "빓": "bil", + "빔": "bim", + "빕": "bip", + "빖": "bip", + "빗": "bit", + "빘": "bit", + "빙": "bing", + "빚": "bit", + "빛": "bit", + "빜": "bik", + "빝": "bit", + "빞": "bip", + "빟": "bit", + "빠": "ppa", + "빡": "ppak", + "빢": "ppakk", + "빣": "ppak", + "빤": "ppan", + "빥": "ppan", + "빦": "ppan", + "빧": "ppat", + "빨": "ppal", + "빩": "ppak", + "빪": "ppam", + "빫": "ppap", + "빬": "ppat", + "빭": "ppat", + "빮": "ppap", + "빯": "ppal", + "빰": "ppam", + "빱": "ppap", + "빲": "ppap", + "빳": "ppat", + "빴": "ppat", + "빵": "ppang", + "빶": "ppat", + "빷": "ppat", + "빸": "ppak", + "빹": "ppat", + "빺": "ppap", + "빻": "ppat", + "빼": "ppae", + "빽": "ppaek", + "빾": "ppaekk", + "빿": "ppaek", + "뺀": "ppaen", + "뺁": "ppaen", + "뺂": "ppaen", + "뺃": "ppaet", + "뺄": "ppael", + "뺅": "ppaek", + "뺆": "ppaem", + "뺇": "ppaep", + "뺈": "ppaet", + "뺉": "ppaet", + "뺊": "ppaep", + "뺋": "ppael", + "뺌": "ppaem", + "뺍": "ppaep", + "뺎": "ppaep", + "뺏": "ppaet", + "뺐": "ppaet", + "뺑": "ppaeng", + "뺒": "ppaet", + "뺓": "ppaet", + "뺔": "ppaek", + "뺕": "ppaet", + "뺖": "ppaep", + "뺗": "ppaet", + "뺘": "ppya", + "뺙": "ppyak", + "뺚": "ppyakk", + "뺛": "ppyak", + "뺜": "ppyan", + "뺝": "ppyan", + "뺞": "ppyan", + "뺟": "ppyat", + "뺠": "ppyal", + "뺡": "ppyak", + "뺢": "ppyam", + "뺣": "ppyap", + "뺤": "ppyat", + "뺥": "ppyat", + "뺦": "ppyap", + "뺧": "ppyal", + "뺨": "ppyam", + "뺩": "ppyap", + "뺪": "ppyap", + "뺫": "ppyat", + "뺬": "ppyat", + "뺭": "ppyang", + "뺮": "ppyat", + "뺯": "ppyat", + "뺰": "ppyak", + "뺱": "ppyat", + "뺲": "ppyap", + "뺳": "ppyat", + "뺴": "ppyae", + "뺵": "ppyaek", + "뺶": "ppyaekk", + "뺷": "ppyaek", + "뺸": "ppyaen", + "뺹": "ppyaen", + "뺺": "ppyaen", + "뺻": "ppyaet", + "뺼": "ppyael", + "뺽": "ppyaek", + "뺾": "ppyaem", + "뺿": "ppyaep", + "뻀": "ppyaet", + "뻁": "ppyaet", + "뻂": "ppyaep", + "뻃": "ppyael", + "뻄": "ppyaem", + "뻅": "ppyaep", + "뻆": "ppyaep", + "뻇": "ppyaet", + "뻈": "ppyaet", + "뻉": "ppyaeng", + "뻊": "ppyaet", + "뻋": "ppyaet", + "뻌": "ppyaek", + "뻍": "ppyaet", + "뻎": "ppyaep", + "뻏": "ppyaet", + "뻐": "ppeo", + "뻑": "ppeok", + "뻒": "ppeokk", + "뻓": "ppeok", + "뻔": "ppeon", + "뻕": "ppeon", + "뻖": "ppeon", + "뻗": "ppeot", + "뻘": "ppeol", + "뻙": "ppeok", + "뻚": "ppeom", + "뻛": "ppeop", + "뻜": "ppeot", + "뻝": "ppeot", + "뻞": "ppeop", + "뻟": "ppeol", + "뻠": "ppeom", + "뻡": "ppeop", + "뻢": "ppeop", + "뻣": "ppeot", + "뻤": "ppeot", + "뻥": "ppeong", + "뻦": "ppeot", + "뻧": "ppeot", + "뻨": "ppeok", + "뻩": "ppeot", + "뻪": "ppeop", + "뻫": "ppeot", + "뻬": "ppe", + "뻭": "ppek", + "뻮": "ppekk", + "뻯": "ppek", + "뻰": "ppen", + "뻱": "ppen", + "뻲": "ppen", + "뻳": "ppet", + "뻴": "ppel", + "뻵": "ppek", + "뻶": "ppem", + "뻷": "ppep", + "뻸": "ppet", + "뻹": "ppet", + "뻺": "ppep", + "뻻": "ppel", + "뻼": "ppem", + "뻽": "ppep", + "뻾": "ppep", + "뻿": "ppet", + "뼀": "ppet", + "뼁": "ppeng", + "뼂": "ppet", + "뼃": "ppet", + "뼄": "ppek", + "뼅": "ppet", + "뼆": "ppep", + "뼇": "ppet", + "뼈": "ppyeo", + "뼉": "ppyeok", + "뼊": "ppyeokk", + "뼋": "ppyeok", + "뼌": "ppyeon", + "뼍": "ppyeon", + "뼎": "ppyeon", + "뼏": "ppyeot", + "뼐": "ppyeol", + "뼑": "ppyeok", + "뼒": "ppyeom", + "뼓": "ppyeop", + "뼔": "ppyeot", + "뼕": "ppyeot", + "뼖": "ppyeop", + "뼗": "ppyeol", + "뼘": "ppyeom", + "뼙": "ppyeop", + "뼚": "ppyeop", + "뼛": "ppyeot", + "뼜": "ppyeot", + "뼝": "ppyeong", + "뼞": "ppyeot", + "뼟": "ppyeot", + "뼠": "ppyeok", + "뼡": "ppyeot", + "뼢": "ppyeop", + "뼣": "ppyeot", + "뼤": "ppye", + "뼥": "ppyek", + "뼦": "ppyekk", + "뼧": "ppyek", + "뼨": "ppyen", + "뼩": "ppyen", + "뼪": "ppyen", + "뼫": "ppyet", + "뼬": "ppyel", + "뼭": "ppyek", + "뼮": "ppyem", + "뼯": "ppyep", + "뼰": "ppyet", + "뼱": "ppyet", + "뼲": "ppyep", + "뼳": "ppyel", + "뼴": "ppyem", + "뼵": "ppyep", + "뼶": "ppyep", + "뼷": "ppyet", + "뼸": "ppyet", + "뼹": "ppyeng", + "뼺": "ppyet", + "뼻": "ppyet", + "뼼": "ppyek", + "뼽": "ppyet", + "뼾": "ppyep", + "뼿": "ppyet", + "뽀": "ppo", + "뽁": "ppok", + "뽂": "ppokk", + "뽃": "ppok", + "뽄": "ppon", + "뽅": "ppon", + "뽆": "ppon", + "뽇": "ppot", + "뽈": "ppol", + "뽉": "ppok", + "뽊": "ppom", + "뽋": "ppop", + "뽌": "ppot", + "뽍": "ppot", + "뽎": "ppop", + "뽏": "ppol", + "뽐": "ppom", + "뽑": "ppop", + "뽒": "ppop", + "뽓": "ppot", + "뽔": "ppot", + "뽕": "ppong", + "뽖": "ppot", + "뽗": "ppot", + "뽘": "ppok", + "뽙": "ppot", + "뽚": "ppop", + "뽛": "ppot", + "뽜": "ppwa", + "뽝": "ppwak", + "뽞": "ppwakk", + "뽟": "ppwak", + "뽠": "ppwan", + "뽡": "ppwan", + "뽢": "ppwan", + "뽣": "ppwat", + "뽤": "ppwal", + "뽥": "ppwak", + "뽦": "ppwam", + "뽧": "ppwap", + "뽨": "ppwat", + "뽩": "ppwat", + "뽪": "ppwap", + "뽫": "ppwal", + "뽬": "ppwam", + "뽭": "ppwap", + "뽮": "ppwap", + "뽯": "ppwat", + "뽰": "ppwat", + "뽱": "ppwang", + "뽲": "ppwat", + "뽳": "ppwat", + "뽴": "ppwak", + "뽵": "ppwat", + "뽶": "ppwap", + "뽷": "ppwat", + "뽸": "ppwae", + "뽹": "ppwaek", + "뽺": "ppwaekk", + "뽻": "ppwaek", + "뽼": "ppwaen", + "뽽": "ppwaen", + "뽾": "ppwaen", + "뽿": "ppwaet", + "뾀": "ppwael", + "뾁": "ppwaek", + "뾂": "ppwaem", + "뾃": "ppwaep", + "뾄": "ppwaet", + "뾅": "ppwaet", + "뾆": "ppwaep", + "뾇": "ppwael", + "뾈": "ppwaem", + "뾉": "ppwaep", + "뾊": "ppwaep", + "뾋": "ppwaet", + "뾌": "ppwaet", + "뾍": "ppwaeng", + "뾎": "ppwaet", + "뾏": "ppwaet", + "뾐": "ppwaek", + "뾑": "ppwaet", + "뾒": "ppwaep", + "뾓": "ppwaet", + "뾔": "ppoe", + "뾕": "ppoek", + "뾖": "ppoekk", + "뾗": "ppoek", + "뾘": "ppoen", + "뾙": "ppoen", + "뾚": "ppoen", + "뾛": "ppoet", + "뾜": "ppoel", + "뾝": "ppoek", + "뾞": "ppoem", + "뾟": "ppoep", + "뾠": "ppoet", + "뾡": "ppoet", + "뾢": "ppoep", + "뾣": "ppoel", + "뾤": "ppoem", + "뾥": "ppoep", + "뾦": "ppoep", + "뾧": "ppoet", + "뾨": "ppoet", + "뾩": "ppoeng", + "뾪": "ppoet", + "뾫": "ppoet", + "뾬": "ppoek", + "뾭": "ppoet", + "뾮": "ppoep", + "뾯": "ppoet", + "뾰": "ppyo", + "뾱": "ppyok", + "뾲": "ppyokk", + "뾳": "ppyok", + "뾴": "ppyon", + "뾵": "ppyon", + "뾶": "ppyon", + "뾷": "ppyot", + "뾸": "ppyol", + "뾹": "ppyok", + "뾺": "ppyom", + "뾻": "ppyop", + "뾼": "ppyot", + "뾽": "ppyot", + "뾾": "ppyop", + "뾿": "ppyol", + "뿀": "ppyom", + "뿁": "ppyop", + "뿂": "ppyop", + "뿃": "ppyot", + "뿄": "ppyot", + "뿅": "ppyong", + "뿆": "ppyot", + "뿇": "ppyot", + "뿈": "ppyok", + "뿉": "ppyot", + "뿊": "ppyop", + "뿋": "ppyot", + "뿌": "ppu", + "뿍": "ppuk", + "뿎": "ppukk", + "뿏": "ppuk", + "뿐": "ppun", + "뿑": "ppun", + "뿒": "ppun", + "뿓": "pput", + "뿔": "ppul", + "뿕": "ppuk", + "뿖": "ppum", + "뿗": "ppup", + "뿘": "pput", + "뿙": "pput", + "뿚": "ppup", + "뿛": "ppul", + "뿜": "ppum", + "뿝": "ppup", + "뿞": "ppup", + "뿟": "pput", + "뿠": "pput", + "뿡": "ppung", + "뿢": "pput", + "뿣": "pput", + "뿤": "ppuk", + "뿥": "pput", + "뿦": "ppup", + "뿧": "pput", + "뿨": "ppwo", + "뿩": "ppwok", + "뿪": "ppwokk", + "뿫": "ppwok", + "뿬": "ppwon", + "뿭": "ppwon", + "뿮": "ppwon", + "뿯": "ppwot", + "뿰": "ppwol", + "뿱": "ppwok", + "뿲": "ppwom", + "뿳": "ppwop", + "뿴": "ppwot", + "뿵": "ppwot", + "뿶": "ppwop", + "뿷": "ppwol", + "뿸": "ppwom", + "뿹": "ppwop", + "뿺": "ppwop", + "뿻": "ppwot", + "뿼": "ppwot", + "뿽": "ppwong", + "뿾": "ppwot", + "뿿": "ppwot", + "쀀": "ppwok", + "쀁": "ppwot", + "쀂": "ppwop", + "쀃": "ppwot", + "쀄": "ppwe", + "쀅": "ppwek", + "쀆": "ppwekk", + "쀇": "ppwek", + "쀈": "ppwen", + "쀉": "ppwen", + "쀊": "ppwen", + "쀋": "ppwet", + "쀌": "ppwel", + "쀍": "ppwek", + "쀎": "ppwem", + "쀏": "ppwep", + "쀐": "ppwet", + "쀑": "ppwet", + "쀒": "ppwep", + "쀓": "ppwel", + "쀔": "ppwem", + "쀕": "ppwep", + "쀖": "ppwep", + "쀗": "ppwet", + "쀘": "ppwet", + "쀙": "ppweng", + "쀚": "ppwet", + "쀛": "ppwet", + "쀜": "ppwek", + "쀝": "ppwet", + "쀞": "ppwep", + "쀟": "ppwet", + "쀠": "ppwi", + "쀡": "ppwik", + "쀢": "ppwikk", + "쀣": "ppwik", + "쀤": "ppwin", + "쀥": "ppwin", + "쀦": "ppwin", + "쀧": "ppwit", + "쀨": "ppwil", + "쀩": "ppwik", + "쀪": "ppwim", + "쀫": "ppwip", + "쀬": "ppwit", + "쀭": "ppwit", + "쀮": "ppwip", + "쀯": "ppwil", + "쀰": "ppwim", + "쀱": "ppwip", + "쀲": "ppwip", + "쀳": "ppwit", + "쀴": "ppwit", + "쀵": "ppwing", + "쀶": "ppwit", + "쀷": "ppwit", + "쀸": "ppwik", + "쀹": "ppwit", + "쀺": "ppwip", + "쀻": "ppwit", + "쀼": "ppyu", + "쀽": "ppyuk", + "쀾": "ppyukk", + "쀿": "ppyuk", + "쁀": "ppyun", + "쁁": "ppyun", + "쁂": "ppyun", + "쁃": "ppyut", + "쁄": "ppyul", + "쁅": "ppyuk", + "쁆": "ppyum", + "쁇": "ppyup", + "쁈": "ppyut", + "쁉": "ppyut", + "쁊": "ppyup", + "쁋": "ppyul", + "쁌": "ppyum", + "쁍": "ppyup", + "쁎": "ppyup", + "쁏": "ppyut", + "쁐": "ppyut", + "쁑": "ppyung", + "쁒": "ppyut", + "쁓": "ppyut", + "쁔": "ppyuk", + "쁕": "ppyut", + "쁖": "ppyup", + "쁗": "ppyut", + "쁘": "ppeu", + "쁙": "ppeuk", + "쁚": "ppeukk", + "쁛": "ppeuk", + "쁜": "ppeun", + "쁝": "ppeun", + "쁞": "ppeun", + "쁟": "ppeut", + "쁠": "ppeul", + "쁡": "ppeuk", + "쁢": "ppeum", + "쁣": "ppeup", + "쁤": "ppeut", + "쁥": "ppeut", + "쁦": "ppeup", + "쁧": "ppeul", + "쁨": "ppeum", + "쁩": "ppeup", + "쁪": "ppeup", + "쁫": "ppeut", + "쁬": "ppeut", + "쁭": "ppeung", + "쁮": "ppeut", + "쁯": "ppeut", + "쁰": "ppeuk", + "쁱": "ppeut", + "쁲": "ppeup", + "쁳": "ppeut", + "쁴": "ppeui", + "쁵": "ppeuik", + "쁶": "ppeuikk", + "쁷": "ppeuik", + "쁸": "ppeuin", + "쁹": "ppeuin", + "쁺": "ppeuin", + "쁻": "ppeuit", + "쁼": "ppeuil", + "쁽": "ppeuik", + "쁾": "ppeuim", + "쁿": "ppeuip", + "삀": "ppeuit", + "삁": "ppeuit", + "삂": "ppeuip", + "삃": "ppeuil", + "삄": "ppeuim", + "삅": "ppeuip", + "삆": "ppeuip", + "삇": "ppeuit", + "삈": "ppeuit", + "삉": "ppeuing", + "삊": "ppeuit", + "삋": "ppeuit", + "삌": "ppeuik", + "삍": "ppeuit", + "삎": "ppeuip", + "삏": "ppeuit", + "삐": "ppi", + "삑": "ppik", + "삒": "ppikk", + "삓": "ppik", + "삔": "ppin", + "삕": "ppin", + "삖": "ppin", + "삗": "ppit", + "삘": "ppil", + "삙": "ppik", + "삚": "ppim", + "삛": "ppip", + "삜": "ppit", + "삝": "ppit", + "삞": "ppip", + "삟": "ppil", + "삠": "ppim", + "삡": "ppip", + "삢": "ppip", + "삣": "ppit", + "삤": "ppit", + "삥": "pping", + "삦": "ppit", + "삧": "ppit", + "삨": "ppik", + "삩": "ppit", + "삪": "ppip", + "삫": "ppit", + "사": "sa", + "삭": "sak", + "삮": "sakk", + "삯": "sak", + "산": "san", + "삱": "san", + "삲": "san", + "삳": "sat", + "살": "sal", + "삵": "sak", + "삶": "sam", + "삷": "sap", + "삸": "sat", + "삹": "sat", + "삺": "sap", + "삻": "sal", + "삼": "sam", + "삽": "sap", + "삾": "sap", + "삿": "sat", + "샀": "sat", + "상": "sang", + "샂": "sat", + "샃": "sat", + "샄": "sak", + "샅": "sat", + "샆": "sap", + "샇": "sat", + "새": "sae", + "색": "saek", + "샊": "saekk", + "샋": "saek", + "샌": "saen", + "샍": "saen", + "샎": "saen", + "샏": "saet", + "샐": "sael", + "샑": "saek", + "샒": "saem", + "샓": "saep", + "샔": "saet", + "샕": "saet", + "샖": "saep", + "샗": "sael", + "샘": "saem", + "샙": "saep", + "샚": "saep", + "샛": "saet", + "샜": "saet", + "생": "saeng", + "샞": "saet", + "샟": "saet", + "샠": "saek", + "샡": "saet", + "샢": "saep", + "샣": "saet", + "샤": "sya", + "샥": "syak", + "샦": "syakk", + "샧": "syak", + "샨": "syan", + "샩": "syan", + "샪": "syan", + "샫": "syat", + "샬": "syal", + "샭": "syak", + "샮": "syam", + "샯": "syap", + "샰": "syat", + "샱": "syat", + "샲": "syap", + "샳": "syal", + "샴": "syam", + "샵": "syap", + "샶": "syap", + "샷": "syat", + "샸": "syat", + "샹": "syang", + "샺": "syat", + "샻": "syat", + "샼": "syak", + "샽": "syat", + "샾": "syap", + "샿": "syat", + "섀": "syae", + "섁": "syaek", + "섂": "syaekk", + "섃": "syaek", + "섄": "syaen", + "섅": "syaen", + "섆": "syaen", + "섇": "syaet", + "섈": "syael", + "섉": "syaek", + "섊": "syaem", + "섋": "syaep", + "섌": "syaet", + "섍": "syaet", + "섎": "syaep", + "섏": "syael", + "섐": "syaem", + "섑": "syaep", + "섒": "syaep", + "섓": "syaet", + "섔": "syaet", + "섕": "syaeng", + "섖": "syaet", + "섗": "syaet", + "섘": "syaek", + "섙": "syaet", + "섚": "syaep", + "섛": "syaet", + "서": "seo", + "석": "seok", + "섞": "seokk", + "섟": "seok", + "선": "seon", + "섡": "seon", + "섢": "seon", + "섣": "seot", + "설": "seol", + "섥": "seok", + "섦": "seom", + "섧": "seop", + "섨": "seot", + "섩": "seot", + "섪": "seop", + "섫": "seol", + "섬": "seom", + "섭": "seop", + "섮": "seop", + "섯": "seot", + "섰": "seot", + "성": "seong", + "섲": "seot", + "섳": "seot", + "섴": "seok", + "섵": "seot", + "섶": "seop", + "섷": "seot", + "세": "se", + "섹": "sek", + "섺": "sekk", + "섻": "sek", + "센": "sen", + "섽": "sen", + "섾": "sen", + "섿": "set", + "셀": "sel", + "셁": "sek", + "셂": "sem", + "셃": "sep", + "셄": "set", + "셅": "set", + "셆": "sep", + "셇": "sel", + "셈": "sem", + "셉": "sep", + "셊": "sep", + "셋": "set", + "셌": "set", + "셍": "seng", + "셎": "set", + "셏": "set", + "셐": "sek", + "셑": "set", + "셒": "sep", + "셓": "set", + "셔": "syeo", + "셕": "syeok", + "셖": "syeokk", + "셗": "syeok", + "션": "syeon", + "셙": "syeon", + "셚": "syeon", + "셛": "syeot", + "셜": "syeol", + "셝": "syeok", + "셞": "syeom", + "셟": "syeop", + "셠": "syeot", + "셡": "syeot", + "셢": "syeop", + "셣": "syeol", + "셤": "syeom", + "셥": "syeop", + "셦": "syeop", + "셧": "syeot", + "셨": "syeot", + "셩": "syeong", + "셪": "syeot", + "셫": "syeot", + "셬": "syeok", + "셭": "syeot", + "셮": "syeop", + "셯": "syeot", + "셰": "sye", + "셱": "syek", + "셲": "syekk", + "셳": "syek", + "셴": "syen", + "셵": "syen", + "셶": "syen", + "셷": "syet", + "셸": "syel", + "셹": "syek", + "셺": "syem", + "셻": "syep", + "셼": "syet", + "셽": "syet", + "셾": "syep", + "셿": "syel", + "솀": "syem", + "솁": "syep", + "솂": "syep", + "솃": "syet", + "솄": "syet", + "솅": "syeng", + "솆": "syet", + "솇": "syet", + "솈": "syek", + "솉": "syet", + "솊": "syep", + "솋": "syet", + "소": "so", + "속": "sok", + "솎": "sokk", + "솏": "sok", + "손": "son", + "솑": "son", + "솒": "son", + "솓": "sot", + "솔": "sol", + "솕": "sok", + "솖": "som", + "솗": "sop", + "솘": "sot", + "솙": "sot", + "솚": "sop", + "솛": "sol", + "솜": "som", + "솝": "sop", + "솞": "sop", + "솟": "sot", + "솠": "sot", + "송": "song", + "솢": "sot", + "솣": "sot", + "솤": "sok", + "솥": "sot", + "솦": "sop", + "솧": "sot", + "솨": "swa", + "솩": "swak", + "솪": "swakk", + "솫": "swak", + "솬": "swan", + "솭": "swan", + "솮": "swan", + "솯": "swat", + "솰": "swal", + "솱": "swak", + "솲": "swam", + "솳": "swap", + "솴": "swat", + "솵": "swat", + "솶": "swap", + "솷": "swal", + "솸": "swam", + "솹": "swap", + "솺": "swap", + "솻": "swat", + "솼": "swat", + "솽": "swang", + "솾": "swat", + "솿": "swat", + "쇀": "swak", + "쇁": "swat", + "쇂": "swap", + "쇃": "swat", + "쇄": "swae", + "쇅": "swaek", + "쇆": "swaekk", + "쇇": "swaek", + "쇈": "swaen", + "쇉": "swaen", + "쇊": "swaen", + "쇋": "swaet", + "쇌": "swael", + "쇍": "swaek", + "쇎": "swaem", + "쇏": "swaep", + "쇐": "swaet", + "쇑": "swaet", + "쇒": "swaep", + "쇓": "swael", + "쇔": "swaem", + "쇕": "swaep", + "쇖": "swaep", + "쇗": "swaet", + "쇘": "swaet", + "쇙": "swaeng", + "쇚": "swaet", + "쇛": "swaet", + "쇜": "swaek", + "쇝": "swaet", + "쇞": "swaep", + "쇟": "swaet", + "쇠": "soe", + "쇡": "soek", + "쇢": "soekk", + "쇣": "soek", + "쇤": "soen", + "쇥": "soen", + "쇦": "soen", + "쇧": "soet", + "쇨": "soel", + "쇩": "soek", + "쇪": "soem", + "쇫": "soep", + "쇬": "soet", + "쇭": "soet", + "쇮": "soep", + "쇯": "soel", + "쇰": "soem", + "쇱": "soep", + "쇲": "soep", + "쇳": "soet", + "쇴": "soet", + "쇵": "soeng", + "쇶": "soet", + "쇷": "soet", + "쇸": "soek", + "쇹": "soet", + "쇺": "soep", + "쇻": "soet", + "쇼": "syo", + "쇽": "syok", + "쇾": "syokk", + "쇿": "syok", + "숀": "syon", + "숁": "syon", + "숂": "syon", + "숃": "syot", + "숄": "syol", + "숅": "syok", + "숆": "syom", + "숇": "syop", + "숈": "syot", + "숉": "syot", + "숊": "syop", + "숋": "syol", + "숌": "syom", + "숍": "syop", + "숎": "syop", + "숏": "syot", + "숐": "syot", + "숑": "syong", + "숒": "syot", + "숓": "syot", + "숔": "syok", + "숕": "syot", + "숖": "syop", + "숗": "syot", + "수": "su", + "숙": "suk", + "숚": "sukk", + "숛": "suk", + "순": "sun", + "숝": "sun", + "숞": "sun", + "숟": "sut", + "술": "sul", + "숡": "suk", + "숢": "sum", + "숣": "sup", + "숤": "sut", + "숥": "sut", + "숦": "sup", + "숧": "sul", + "숨": "sum", + "숩": "sup", + "숪": "sup", + "숫": "sut", + "숬": "sut", + "숭": "sung", + "숮": "sut", + "숯": "sut", + "숰": "suk", + "숱": "sut", + "숲": "sup", + "숳": "sut", + "숴": "swo", + "숵": "swok", + "숶": "swokk", + "숷": "swok", + "숸": "swon", + "숹": "swon", + "숺": "swon", + "숻": "swot", + "숼": "swol", + "숽": "swok", + "숾": "swom", + "숿": "swop", + "쉀": "swot", + "쉁": "swot", + "쉂": "swop", + "쉃": "swol", + "쉄": "swom", + "쉅": "swop", + "쉆": "swop", + "쉇": "swot", + "쉈": "swot", + "쉉": "swong", + "쉊": "swot", + "쉋": "swot", + "쉌": "swok", + "쉍": "swot", + "쉎": "swop", + "쉏": "swot", + "쉐": "swe", + "쉑": "swek", + "쉒": "swekk", + "쉓": "swek", + "쉔": "swen", + "쉕": "swen", + "쉖": "swen", + "쉗": "swet", + "쉘": "swel", + "쉙": "swek", + "쉚": "swem", + "쉛": "swep", + "쉜": "swet", + "쉝": "swet", + "쉞": "swep", + "쉟": "swel", + "쉠": "swem", + "쉡": "swep", + "쉢": "swep", + "쉣": "swet", + "쉤": "swet", + "쉥": "sweng", + "쉦": "swet", + "쉧": "swet", + "쉨": "swek", + "쉩": "swet", + "쉪": "swep", + "쉫": "swet", + "쉬": "swi", + "쉭": "swik", + "쉮": "swikk", + "쉯": "swik", + "쉰": "swin", + "쉱": "swin", + "쉲": "swin", + "쉳": "swit", + "쉴": "swil", + "쉵": "swik", + "쉶": "swim", + "쉷": "swip", + "쉸": "swit", + "쉹": "swit", + "쉺": "swip", + "쉻": "swil", + "쉼": "swim", + "쉽": "swip", + "쉾": "swip", + "쉿": "swit", + "슀": "swit", + "슁": "swing", + "슂": "swit", + "슃": "swit", + "슄": "swik", + "슅": "swit", + "슆": "swip", + "슇": "swit", + "슈": "syu", + "슉": "syuk", + "슊": "syukk", + "슋": "syuk", + "슌": "syun", + "슍": "syun", + "슎": "syun", + "슏": "syut", + "슐": "syul", + "슑": "syuk", + "슒": "syum", + "슓": "syup", + "슔": "syut", + "슕": "syut", + "슖": "syup", + "슗": "syul", + "슘": "syum", + "슙": "syup", + "슚": "syup", + "슛": "syut", + "슜": "syut", + "슝": "syung", + "슞": "syut", + "슟": "syut", + "슠": "syuk", + "슡": "syut", + "슢": "syup", + "슣": "syut", + "스": "seu", + "슥": "seuk", + "슦": "seukk", + "슧": "seuk", + "슨": "seun", + "슩": "seun", + "슪": "seun", + "슫": "seut", + "슬": "seul", + "슭": "seuk", + "슮": "seum", + "슯": "seup", + "슰": "seut", + "슱": "seut", + "슲": "seup", + "슳": "seul", + "슴": "seum", + "습": "seup", + "슶": "seup", + "슷": "seut", + "슸": "seut", + "승": "seung", + "슺": "seut", + "슻": "seut", + "슼": "seuk", + "슽": "seut", + "슾": "seup", + "슿": "seut", + "싀": "seui", + "싁": "seuik", + "싂": "seuikk", + "싃": "seuik", + "싄": "seuin", + "싅": "seuin", + "싆": "seuin", + "싇": "seuit", + "싈": "seuil", + "싉": "seuik", + "싊": "seuim", + "싋": "seuip", + "싌": "seuit", + "싍": "seuit", + "싎": "seuip", + "싏": "seuil", + "싐": "seuim", + "싑": "seuip", + "싒": "seuip", + "싓": "seuit", + "싔": "seuit", + "싕": "seuing", + "싖": "seuit", + "싗": "seuit", + "싘": "seuik", + "싙": "seuit", + "싚": "seuip", + "싛": "seuit", + "시": "si", + "식": "sik", + "싞": "sikk", + "싟": "sik", + "신": "sin", + "싡": "sin", + "싢": "sin", + "싣": "sit", + "실": "sil", + "싥": "sik", + "싦": "sim", + "싧": "sip", + "싨": "sit", + "싩": "sit", + "싪": "sip", + "싫": "sil", + "심": "sim", + "십": "sip", + "싮": "sip", + "싯": "sit", + "싰": "sit", + "싱": "sing", + "싲": "sit", + "싳": "sit", + "싴": "sik", + "싵": "sit", + "싶": "sip", + "싷": "sit", + "싸": "ssa", + "싹": "ssak", + "싺": "ssakk", + "싻": "ssak", + "싼": "ssan", + "싽": "ssan", + "싾": "ssan", + "싿": "ssat", + "쌀": "ssal", + "쌁": "ssak", + "쌂": "ssam", + "쌃": "ssap", + "쌄": "ssat", + "쌅": "ssat", + "쌆": "ssap", + "쌇": "ssal", + "쌈": "ssam", + "쌉": "ssap", + "쌊": "ssap", + "쌋": "ssat", + "쌌": "ssat", + "쌍": "ssang", + "쌎": "ssat", + "쌏": "ssat", + "쌐": "ssak", + "쌑": "ssat", + "쌒": "ssap", + "쌓": "ssat", + "쌔": "ssae", + "쌕": "ssaek", + "쌖": "ssaekk", + "쌗": "ssaek", + "쌘": "ssaen", + "쌙": "ssaen", + "쌚": "ssaen", + "쌛": "ssaet", + "쌜": "ssael", + "쌝": "ssaek", + "쌞": "ssaem", + "쌟": "ssaep", + "쌠": "ssaet", + "쌡": "ssaet", + "쌢": "ssaep", + "쌣": "ssael", + "쌤": "ssaem", + "쌥": "ssaep", + "쌦": "ssaep", + "쌧": "ssaet", + "쌨": "ssaet", + "쌩": "ssaeng", + "쌪": "ssaet", + "쌫": "ssaet", + "쌬": "ssaek", + "쌭": "ssaet", + "쌮": "ssaep", + "쌯": "ssaet", + "쌰": "ssya", + "쌱": "ssyak", + "쌲": "ssyakk", + "쌳": "ssyak", + "쌴": "ssyan", + "쌵": "ssyan", + "쌶": "ssyan", + "쌷": "ssyat", + "쌸": "ssyal", + "쌹": "ssyak", + "쌺": "ssyam", + "쌻": "ssyap", + "쌼": "ssyat", + "쌽": "ssyat", + "쌾": "ssyap", + "쌿": "ssyal", + "썀": "ssyam", + "썁": "ssyap", + "썂": "ssyap", + "썃": "ssyat", + "썄": "ssyat", + "썅": "ssyang", + "썆": "ssyat", + "썇": "ssyat", + "썈": "ssyak", + "썉": "ssyat", + "썊": "ssyap", + "썋": "ssyat", + "썌": "ssyae", + "썍": "ssyaek", + "썎": "ssyaekk", + "썏": "ssyaek", + "썐": "ssyaen", + "썑": "ssyaen", + "썒": "ssyaen", + "썓": "ssyaet", + "썔": "ssyael", + "썕": "ssyaek", + "썖": "ssyaem", + "썗": "ssyaep", + "썘": "ssyaet", + "썙": "ssyaet", + "썚": "ssyaep", + "썛": "ssyael", + "썜": "ssyaem", + "썝": "ssyaep", + "썞": "ssyaep", + "썟": "ssyaet", + "썠": "ssyaet", + "썡": "ssyaeng", + "썢": "ssyaet", + "썣": "ssyaet", + "썤": "ssyaek", + "썥": "ssyaet", + "썦": "ssyaep", + "썧": "ssyaet", + "써": "sseo", + "썩": "sseok", + "썪": "sseokk", + "썫": "sseok", + "썬": "sseon", + "썭": "sseon", + "썮": "sseon", + "썯": "sseot", + "썰": "sseol", + "썱": "sseok", + "썲": "sseom", + "썳": "sseop", + "썴": "sseot", + "썵": "sseot", + "썶": "sseop", + "썷": "sseol", + "썸": "sseom", + "썹": "sseop", + "썺": "sseop", + "썻": "sseot", + "썼": "sseot", + "썽": "sseong", + "썾": "sseot", + "썿": "sseot", + "쎀": "sseok", + "쎁": "sseot", + "쎂": "sseop", + "쎃": "sseot", + "쎄": "sse", + "쎅": "ssek", + "쎆": "ssekk", + "쎇": "ssek", + "쎈": "ssen", + "쎉": "ssen", + "쎊": "ssen", + "쎋": "sset", + "쎌": "ssel", + "쎍": "ssek", + "쎎": "ssem", + "쎏": "ssep", + "쎐": "sset", + "쎑": "sset", + "쎒": "ssep", + "쎓": "ssel", + "쎔": "ssem", + "쎕": "ssep", + "쎖": "ssep", + "쎗": "sset", + "쎘": "sset", + "쎙": "sseng", + "쎚": "sset", + "쎛": "sset", + "쎜": "ssek", + "쎝": "sset", + "쎞": "ssep", + "쎟": "sset", + "쎠": "ssyeo", + "쎡": "ssyeok", + "쎢": "ssyeokk", + "쎣": "ssyeok", + "쎤": "ssyeon", + "쎥": "ssyeon", + "쎦": "ssyeon", + "쎧": "ssyeot", + "쎨": "ssyeol", + "쎩": "ssyeok", + "쎪": "ssyeom", + "쎫": "ssyeop", + "쎬": "ssyeot", + "쎭": "ssyeot", + "쎮": "ssyeop", + "쎯": "ssyeol", + "쎰": "ssyeom", + "쎱": "ssyeop", + "쎲": "ssyeop", + "쎳": "ssyeot", + "쎴": "ssyeot", + "쎵": "ssyeong", + "쎶": "ssyeot", + "쎷": "ssyeot", + "쎸": "ssyeok", + "쎹": "ssyeot", + "쎺": "ssyeop", + "쎻": "ssyeot", + "쎼": "ssye", + "쎽": "ssyek", + "쎾": "ssyekk", + "쎿": "ssyek", + "쏀": "ssyen", + "쏁": "ssyen", + "쏂": "ssyen", + "쏃": "ssyet", + "쏄": "ssyel", + "쏅": "ssyek", + "쏆": "ssyem", + "쏇": "ssyep", + "쏈": "ssyet", + "쏉": "ssyet", + "쏊": "ssyep", + "쏋": "ssyel", + "쏌": "ssyem", + "쏍": "ssyep", + "쏎": "ssyep", + "쏏": "ssyet", + "쏐": "ssyet", + "쏑": "ssyeng", + "쏒": "ssyet", + "쏓": "ssyet", + "쏔": "ssyek", + "쏕": "ssyet", + "쏖": "ssyep", + "쏗": "ssyet", + "쏘": "sso", + "쏙": "ssok", + "쏚": "ssokk", + "쏛": "ssok", + "쏜": "sson", + "쏝": "sson", + "쏞": "sson", + "쏟": "ssot", + "쏠": "ssol", + "쏡": "ssok", + "쏢": "ssom", + "쏣": "ssop", + "쏤": "ssot", + "쏥": "ssot", + "쏦": "ssop", + "쏧": "ssol", + "쏨": "ssom", + "쏩": "ssop", + "쏪": "ssop", + "쏫": "ssot", + "쏬": "ssot", + "쏭": "ssong", + "쏮": "ssot", + "쏯": "ssot", + "쏰": "ssok", + "쏱": "ssot", + "쏲": "ssop", + "쏳": "ssot", + "쏴": "sswa", + "쏵": "sswak", + "쏶": "sswakk", + "쏷": "sswak", + "쏸": "sswan", + "쏹": "sswan", + "쏺": "sswan", + "쏻": "sswat", + "쏼": "sswal", + "쏽": "sswak", + "쏾": "sswam", + "쏿": "sswap", + "쐀": "sswat", + "쐁": "sswat", + "쐂": "sswap", + "쐃": "sswal", + "쐄": "sswam", + "쐅": "sswap", + "쐆": "sswap", + "쐇": "sswat", + "쐈": "sswat", + "쐉": "sswang", + "쐊": "sswat", + "쐋": "sswat", + "쐌": "sswak", + "쐍": "sswat", + "쐎": "sswap", + "쐏": "sswat", + "쐐": "sswae", + "쐑": "sswaek", + "쐒": "sswaekk", + "쐓": "sswaek", + "쐔": "sswaen", + "쐕": "sswaen", + "쐖": "sswaen", + "쐗": "sswaet", + "쐘": "sswael", + "쐙": "sswaek", + "쐚": "sswaem", + "쐛": "sswaep", + "쐜": "sswaet", + "쐝": "sswaet", + "쐞": "sswaep", + "쐟": "sswael", + "쐠": "sswaem", + "쐡": "sswaep", + "쐢": "sswaep", + "쐣": "sswaet", + "쐤": "sswaet", + "쐥": "sswaeng", + "쐦": "sswaet", + "쐧": "sswaet", + "쐨": "sswaek", + "쐩": "sswaet", + "쐪": "sswaep", + "쐫": "sswaet", + "쐬": "ssoe", + "쐭": "ssoek", + "쐮": "ssoekk", + "쐯": "ssoek", + "쐰": "ssoen", + "쐱": "ssoen", + "쐲": "ssoen", + "쐳": "ssoet", + "쐴": "ssoel", + "쐵": "ssoek", + "쐶": "ssoem", + "쐷": "ssoep", + "쐸": "ssoet", + "쐹": "ssoet", + "쐺": "ssoep", + "쐻": "ssoel", + "쐼": "ssoem", + "쐽": "ssoep", + "쐾": "ssoep", + "쐿": "ssoet", + "쑀": "ssoet", + "쑁": "ssoeng", + "쑂": "ssoet", + "쑃": "ssoet", + "쑄": "ssoek", + "쑅": "ssoet", + "쑆": "ssoep", + "쑇": "ssoet", + "쑈": "ssyo", + "쑉": "ssyok", + "쑊": "ssyokk", + "쑋": "ssyok", + "쑌": "ssyon", + "쑍": "ssyon", + "쑎": "ssyon", + "쑏": "ssyot", + "쑐": "ssyol", + "쑑": "ssyok", + "쑒": "ssyom", + "쑓": "ssyop", + "쑔": "ssyot", + "쑕": "ssyot", + "쑖": "ssyop", + "쑗": "ssyol", + "쑘": "ssyom", + "쑙": "ssyop", + "쑚": "ssyop", + "쑛": "ssyot", + "쑜": "ssyot", + "쑝": "ssyong", + "쑞": "ssyot", + "쑟": "ssyot", + "쑠": "ssyok", + "쑡": "ssyot", + "쑢": "ssyop", + "쑣": "ssyot", + "쑤": "ssu", + "쑥": "ssuk", + "쑦": "ssukk", + "쑧": "ssuk", + "쑨": "ssun", + "쑩": "ssun", + "쑪": "ssun", + "쑫": "ssut", + "쑬": "ssul", + "쑭": "ssuk", + "쑮": "ssum", + "쑯": "ssup", + "쑰": "ssut", + "쑱": "ssut", + "쑲": "ssup", + "쑳": "ssul", + "쑴": "ssum", + "쑵": "ssup", + "쑶": "ssup", + "쑷": "ssut", + "쑸": "ssut", + "쑹": "ssung", + "쑺": "ssut", + "쑻": "ssut", + "쑼": "ssuk", + "쑽": "ssut", + "쑾": "ssup", + "쑿": "ssut", + "쒀": "sswo", + "쒁": "sswok", + "쒂": "sswokk", + "쒃": "sswok", + "쒄": "sswon", + "쒅": "sswon", + "쒆": "sswon", + "쒇": "sswot", + "쒈": "sswol", + "쒉": "sswok", + "쒊": "sswom", + "쒋": "sswop", + "쒌": "sswot", + "쒍": "sswot", + "쒎": "sswop", + "쒏": "sswol", + "쒐": "sswom", + "쒑": "sswop", + "쒒": "sswop", + "쒓": "sswot", + "쒔": "sswot", + "쒕": "sswong", + "쒖": "sswot", + "쒗": "sswot", + "쒘": "sswok", + "쒙": "sswot", + "쒚": "sswop", + "쒛": "sswot", + "쒜": "sswe", + "쒝": "sswek", + "쒞": "sswekk", + "쒟": "sswek", + "쒠": "sswen", + "쒡": "sswen", + "쒢": "sswen", + "쒣": "sswet", + "쒤": "sswel", + "쒥": "sswek", + "쒦": "sswem", + "쒧": "sswep", + "쒨": "sswet", + "쒩": "sswet", + "쒪": "sswep", + "쒫": "sswel", + "쒬": "sswem", + "쒭": "sswep", + "쒮": "sswep", + "쒯": "sswet", + "쒰": "sswet", + "쒱": "ssweng", + "쒲": "sswet", + "쒳": "sswet", + "쒴": "sswek", + "쒵": "sswet", + "쒶": "sswep", + "쒷": "sswet", + "쒸": "sswi", + "쒹": "sswik", + "쒺": "sswikk", + "쒻": "sswik", + "쒼": "sswin", + "쒽": "sswin", + "쒾": "sswin", + "쒿": "sswit", + "쓀": "sswil", + "쓁": "sswik", + "쓂": "sswim", + "쓃": "sswip", + "쓄": "sswit", + "쓅": "sswit", + "쓆": "sswip", + "쓇": "sswil", + "쓈": "sswim", + "쓉": "sswip", + "쓊": "sswip", + "쓋": "sswit", + "쓌": "sswit", + "쓍": "sswing", + "쓎": "sswit", + "쓏": "sswit", + "쓐": "sswik", + "쓑": "sswit", + "쓒": "sswip", + "쓓": "sswit", + "쓔": "ssyu", + "쓕": "ssyuk", + "쓖": "ssyukk", + "쓗": "ssyuk", + "쓘": "ssyun", + "쓙": "ssyun", + "쓚": "ssyun", + "쓛": "ssyut", + "쓜": "ssyul", + "쓝": "ssyuk", + "쓞": "ssyum", + "쓟": "ssyup", + "쓠": "ssyut", + "쓡": "ssyut", + "쓢": "ssyup", + "쓣": "ssyul", + "쓤": "ssyum", + "쓥": "ssyup", + "쓦": "ssyup", + "쓧": "ssyut", + "쓨": "ssyut", + "쓩": "ssyung", + "쓪": "ssyut", + "쓫": "ssyut", + "쓬": "ssyuk", + "쓭": "ssyut", + "쓮": "ssyup", + "쓯": "ssyut", + "쓰": "sseu", + "쓱": "sseuk", + "쓲": "sseukk", + "쓳": "sseuk", + "쓴": "sseun", + "쓵": "sseun", + "쓶": "sseun", + "쓷": "sseut", + "쓸": "sseul", + "쓹": "sseuk", + "쓺": "sseum", + "쓻": "sseup", + "쓼": "sseut", + "쓽": "sseut", + "쓾": "sseup", + "쓿": "sseul", + "씀": "sseum", + "씁": "sseup", + "씂": "sseup", + "씃": "sseut", + "씄": "sseut", + "씅": "sseung", + "씆": "sseut", + "씇": "sseut", + "씈": "sseuk", + "씉": "sseut", + "씊": "sseup", + "씋": "sseut", + "씌": "sseui", + "씍": "sseuik", + "씎": "sseuikk", + "씏": "sseuik", + "씐": "sseuin", + "씑": "sseuin", + "씒": "sseuin", + "씓": "sseuit", + "씔": "sseuil", + "씕": "sseuik", + "씖": "sseuim", + "씗": "sseuip", + "씘": "sseuit", + "씙": "sseuit", + "씚": "sseuip", + "씛": "sseuil", + "씜": "sseuim", + "씝": "sseuip", + "씞": "sseuip", + "씟": "sseuit", + "씠": "sseuit", + "씡": "sseuing", + "씢": "sseuit", + "씣": "sseuit", + "씤": "sseuik", + "씥": "sseuit", + "씦": "sseuip", + "씧": "sseuit", + "씨": "ssi", + "씩": "ssik", + "씪": "ssikk", + "씫": "ssik", + "씬": "ssin", + "씭": "ssin", + "씮": "ssin", + "씯": "ssit", + "씰": "ssil", + "씱": "ssik", + "씲": "ssim", + "씳": "ssip", + "씴": "ssit", + "씵": "ssit", + "씶": "ssip", + "씷": "ssil", + "씸": "ssim", + "씹": "ssip", + "씺": "ssip", + "씻": "ssit", + "씼": "ssit", + "씽": "ssing", + "씾": "ssit", + "씿": "ssit", + "앀": "ssik", + "앁": "ssit", + "앂": "ssip", + "앃": "ssit", + "아": "a", + "악": "ak", + "앆": "akk", + "앇": "ak", + "안": "an", + "앉": "an", + "않": "an", + "앋": "at", + "알": "al", + "앍": "ak", + "앎": "am", + "앏": "ap", + "앐": "at", + "앑": "at", + "앒": "ap", + "앓": "al", + "암": "am", + "압": "ap", + "앖": "ap", + "앗": "at", + "았": "at", + "앙": "ang", + "앚": "at", + "앛": "at", + "앜": "ak", + "앝": "at", + "앞": "ap", + "앟": "at", + "애": "ae", + "액": "aek", + "앢": "aekk", + "앣": "aek", + "앤": "aen", + "앥": "aen", + "앦": "aen", + "앧": "aet", + "앨": "ael", + "앩": "aek", + "앪": "aem", + "앫": "aep", + "앬": "aet", + "앭": "aet", + "앮": "aep", + "앯": "ael", + "앰": "aem", + "앱": "aep", + "앲": "aep", + "앳": "aet", + "앴": "aet", + "앵": "aeng", + "앶": "aet", + "앷": "aet", + "앸": "aek", + "앹": "aet", + "앺": "aep", + "앻": "aet", + "야": "ya", + "약": "yak", + "앾": "yakk", + "앿": "yak", + "얀": "yan", + "얁": "yan", + "얂": "yan", + "얃": "yat", + "얄": "yal", + "얅": "yak", + "얆": "yam", + "얇": "yap", + "얈": "yat", + "얉": "yat", + "얊": "yap", + "얋": "yal", + "얌": "yam", + "얍": "yap", + "얎": "yap", + "얏": "yat", + "얐": "yat", + "양": "yang", + "얒": "yat", + "얓": "yat", + "얔": "yak", + "얕": "yat", + "얖": "yap", + "얗": "yat", + "얘": "yae", + "얙": "yaek", + "얚": "yaekk", + "얛": "yaek", + "얜": "yaen", + "얝": "yaen", + "얞": "yaen", + "얟": "yaet", + "얠": "yael", + "얡": "yaek", + "얢": "yaem", + "얣": "yaep", + "얤": "yaet", + "얥": "yaet", + "얦": "yaep", + "얧": "yael", + "얨": "yaem", + "얩": "yaep", + "얪": "yaep", + "얫": "yaet", + "얬": "yaet", + "얭": "yaeng", + "얮": "yaet", + "얯": "yaet", + "얰": "yaek", + "얱": "yaet", + "얲": "yaep", + "얳": "yaet", + "어": "eo", + "억": "eok", + "얶": "eokk", + "얷": "eok", + "언": "eon", + "얹": "eon", + "얺": "eon", + "얻": "eot", + "얼": "eol", + "얽": "eok", + "얾": "eom", + "얿": "eop", + "엀": "eot", + "엁": "eot", + "엂": "eop", + "엃": "eol", + "엄": "eom", + "업": "eop", + "없": "eop", + "엇": "eot", + "었": "eot", + "엉": "eong", + "엊": "eot", + "엋": "eot", + "엌": "eok", + "엍": "eot", + "엎": "eop", + "엏": "eot", + "에": "e", + "엑": "ek", + "엒": "ekk", + "엓": "ek", + "엔": "en", + "엕": "en", + "엖": "en", + "엗": "et", + "엘": "el", + "엙": "ek", + "엚": "em", + "엛": "ep", + "엜": "et", + "엝": "et", + "엞": "ep", + "엟": "el", + "엠": "em", + "엡": "ep", + "엢": "ep", + "엣": "et", + "엤": "et", + "엥": "eng", + "엦": "et", + "엧": "et", + "엨": "ek", + "엩": "et", + "엪": "ep", + "엫": "et", + "여": "yeo", + "역": "yeok", + "엮": "yeokk", + "엯": "yeok", + "연": "yeon", + "엱": "yeon", + "엲": "yeon", + "엳": "yeot", + "열": "yeol", + "엵": "yeok", + "엶": "yeom", + "엷": "yeop", + "엸": "yeot", + "엹": "yeot", + "엺": "yeop", + "엻": "yeol", + "염": "yeom", + "엽": "yeop", + "엾": "yeop", + "엿": "yeot", + "였": "yeot", + "영": "yeong", + "옂": "yeot", + "옃": "yeot", + "옄": "yeok", + "옅": "yeot", + "옆": "yeop", + "옇": "yeot", + "예": "ye", + "옉": "yek", + "옊": "yekk", + "옋": "yek", + "옌": "yen", + "옍": "yen", + "옎": "yen", + "옏": "yet", + "옐": "yel", + "옑": "yek", + "옒": "yem", + "옓": "yep", + "옔": "yet", + "옕": "yet", + "옖": "yep", + "옗": "yel", + "옘": "yem", + "옙": "yep", + "옚": "yep", + "옛": "yet", + "옜": "yet", + "옝": "yeng", + "옞": "yet", + "옟": "yet", + "옠": "yek", + "옡": "yet", + "옢": "yep", + "옣": "yet", + "오": "o", + "옥": "ok", + "옦": "okk", + "옧": "ok", + "온": "on", + "옩": "on", + "옪": "on", + "옫": "ot", + "올": "ol", + "옭": "ok", + "옮": "om", + "옯": "op", + "옰": "ot", + "옱": "ot", + "옲": "op", + "옳": "ol", + "옴": "om", + "옵": "op", + "옶": "op", + "옷": "ot", + "옸": "ot", + "옹": "ong", + "옺": "ot", + "옻": "ot", + "옼": "ok", + "옽": "ot", + "옾": "op", + "옿": "ot", + "와": "wa", + "왁": "wak", + "왂": "wakk", + "왃": "wak", + "완": "wan", + "왅": "wan", + "왆": "wan", + "왇": "wat", + "왈": "wal", + "왉": "wak", + "왊": "wam", + "왋": "wap", + "왌": "wat", + "왍": "wat", + "왎": "wap", + "왏": "wal", + "왐": "wam", + "왑": "wap", + "왒": "wap", + "왓": "wat", + "왔": "wat", + "왕": "wang", + "왖": "wat", + "왗": "wat", + "왘": "wak", + "왙": "wat", + "왚": "wap", + "왛": "wat", + "왜": "wae", + "왝": "waek", + "왞": "waekk", + "왟": "waek", + "왠": "waen", + "왡": "waen", + "왢": "waen", + "왣": "waet", + "왤": "wael", + "왥": "waek", + "왦": "waem", + "왧": "waep", + "왨": "waet", + "왩": "waet", + "왪": "waep", + "왫": "wael", + "왬": "waem", + "왭": "waep", + "왮": "waep", + "왯": "waet", + "왰": "waet", + "왱": "waeng", + "왲": "waet", + "왳": "waet", + "왴": "waek", + "왵": "waet", + "왶": "waep", + "왷": "waet", + "외": "oe", + "왹": "oek", + "왺": "oekk", + "왻": "oek", + "왼": "oen", + "왽": "oen", + "왾": "oen", + "왿": "oet", + "욀": "oel", + "욁": "oek", + "욂": "oem", + "욃": "oep", + "욄": "oet", + "욅": "oet", + "욆": "oep", + "욇": "oel", + "욈": "oem", + "욉": "oep", + "욊": "oep", + "욋": "oet", + "욌": "oet", + "욍": "oeng", + "욎": "oet", + "욏": "oet", + "욐": "oek", + "욑": "oet", + "욒": "oep", + "욓": "oet", + "요": "yo", + "욕": "yok", + "욖": "yokk", + "욗": "yok", + "욘": "yon", + "욙": "yon", + "욚": "yon", + "욛": "yot", + "욜": "yol", + "욝": "yok", + "욞": "yom", + "욟": "yop", + "욠": "yot", + "욡": "yot", + "욢": "yop", + "욣": "yol", + "욤": "yom", + "욥": "yop", + "욦": "yop", + "욧": "yot", + "욨": "yot", + "용": "yong", + "욪": "yot", + "욫": "yot", + "욬": "yok", + "욭": "yot", + "욮": "yop", + "욯": "yot", + "우": "u", + "욱": "uk", + "욲": "ukk", + "욳": "uk", + "운": "un", + "욵": "un", + "욶": "un", + "욷": "ut", + "울": "ul", + "욹": "uk", + "욺": "um", + "욻": "up", + "욼": "ut", + "욽": "ut", + "욾": "up", + "욿": "ul", + "움": "um", + "웁": "up", + "웂": "up", + "웃": "ut", + "웄": "ut", + "웅": "ung", + "웆": "ut", + "웇": "ut", + "웈": "uk", + "웉": "ut", + "웊": "up", + "웋": "ut", + "워": "wo", + "웍": "wok", + "웎": "wokk", + "웏": "wok", + "원": "won", + "웑": "won", + "웒": "won", + "웓": "wot", + "월": "wol", + "웕": "wok", + "웖": "wom", + "웗": "wop", + "웘": "wot", + "웙": "wot", + "웚": "wop", + "웛": "wol", + "웜": "wom", + "웝": "wop", + "웞": "wop", + "웟": "wot", + "웠": "wot", + "웡": "wong", + "웢": "wot", + "웣": "wot", + "웤": "wok", + "웥": "wot", + "웦": "wop", + "웧": "wot", + "웨": "we", + "웩": "wek", + "웪": "wekk", + "웫": "wek", + "웬": "wen", + "웭": "wen", + "웮": "wen", + "웯": "wet", + "웰": "wel", + "웱": "wek", + "웲": "wem", + "웳": "wep", + "웴": "wet", + "웵": "wet", + "웶": "wep", + "웷": "wel", + "웸": "wem", + "웹": "wep", + "웺": "wep", + "웻": "wet", + "웼": "wet", + "웽": "weng", + "웾": "wet", + "웿": "wet", + "윀": "wek", + "윁": "wet", + "윂": "wep", + "윃": "wet", + "위": "wi", + "윅": "wik", + "윆": "wikk", + "윇": "wik", + "윈": "win", + "윉": "win", + "윊": "win", + "윋": "wit", + "윌": "wil", + "윍": "wik", + "윎": "wim", + "윏": "wip", + "윐": "wit", + "윑": "wit", + "윒": "wip", + "윓": "wil", + "윔": "wim", + "윕": "wip", + "윖": "wip", + "윗": "wit", + "윘": "wit", + "윙": "wing", + "윚": "wit", + "윛": "wit", + "윜": "wik", + "윝": "wit", + "윞": "wip", + "윟": "wit", + "유": "yu", + "육": "yuk", + "윢": "yukk", + "윣": "yuk", + "윤": "yun", + "윥": "yun", + "윦": "yun", + "윧": "yut", + "율": "yul", + "윩": "yuk", + "윪": "yum", + "윫": "yup", + "윬": "yut", + "윭": "yut", + "윮": "yup", + "윯": "yul", + "윰": "yum", + "윱": "yup", + "윲": "yup", + "윳": "yut", + "윴": "yut", + "융": "yung", + "윶": "yut", + "윷": "yut", + "윸": "yuk", + "윹": "yut", + "윺": "yup", + "윻": "yut", + "으": "eu", + "윽": "euk", + "윾": "eukk", + "윿": "euk", + "은": "eun", + "읁": "eun", + "읂": "eun", + "읃": "eut", + "을": "eul", + "읅": "euk", + "읆": "eum", + "읇": "eup", + "읈": "eut", + "읉": "eut", + "읊": "eup", + "읋": "eul", + "음": "eum", + "읍": "eup", + "읎": "eup", + "읏": "eut", + "읐": "eut", + "응": "eung", + "읒": "eut", + "읓": "eut", + "읔": "euk", + "읕": "eut", + "읖": "eup", + "읗": "eut", + "의": "eui", + "읙": "euik", + "읚": "euikk", + "읛": "euik", + "읜": "euin", + "읝": "euin", + "읞": "euin", + "읟": "euit", + "읠": "euil", + "읡": "euik", + "읢": "euim", + "읣": "euip", + "읤": "euit", + "읥": "euit", + "읦": "euip", + "읧": "euil", + "읨": "euim", + "읩": "euip", + "읪": "euip", + "읫": "euit", + "읬": "euit", + "읭": "euing", + "읮": "euit", + "읯": "euit", + "읰": "euik", + "읱": "euit", + "읲": "euip", + "읳": "euit", + "이": "i", + "익": "ik", + "읶": "ikk", + "읷": "ik", + "인": "in", + "읹": "in", + "읺": "in", + "읻": "it", + "일": "il", + "읽": "ik", + "읾": "im", + "읿": "ip", + "잀": "it", + "잁": "it", + "잂": "ip", + "잃": "il", + "임": "im", + "입": "ip", + "잆": "ip", + "잇": "it", + "있": "it", + "잉": "ing", + "잊": "it", + "잋": "it", + "잌": "ik", + "잍": "it", + "잎": "ip", + "잏": "it", + "자": "ja", + "작": "jak", + "잒": "jakk", + "잓": "jak", + "잔": "jan", + "잕": "jan", + "잖": "jan", + "잗": "jat", + "잘": "jal", + "잙": "jak", + "잚": "jam", + "잛": "jap", + "잜": "jat", + "잝": "jat", + "잞": "jap", + "잟": "jal", + "잠": "jam", + "잡": "jap", + "잢": "jap", + "잣": "jat", + "잤": "jat", + "장": "jang", + "잦": "jat", + "잧": "jat", + "잨": "jak", + "잩": "jat", + "잪": "jap", + "잫": "jat", + "재": "jae", + "잭": "jaek", + "잮": "jaekk", + "잯": "jaek", + "잰": "jaen", + "잱": "jaen", + "잲": "jaen", + "잳": "jaet", + "잴": "jael", + "잵": "jaek", + "잶": "jaem", + "잷": "jaep", + "잸": "jaet", + "잹": "jaet", + "잺": "jaep", + "잻": "jael", + "잼": "jaem", + "잽": "jaep", + "잾": "jaep", + "잿": "jaet", + "쟀": "jaet", + "쟁": "jaeng", + "쟂": "jaet", + "쟃": "jaet", + "쟄": "jaek", + "쟅": "jaet", + "쟆": "jaep", + "쟇": "jaet", + "쟈": "jya", + "쟉": "jyak", + "쟊": "jyakk", + "쟋": "jyak", + "쟌": "jyan", + "쟍": "jyan", + "쟎": "jyan", + "쟏": "jyat", + "쟐": "jyal", + "쟑": "jyak", + "쟒": "jyam", + "쟓": "jyap", + "쟔": "jyat", + "쟕": "jyat", + "쟖": "jyap", + "쟗": "jyal", + "쟘": "jyam", + "쟙": "jyap", + "쟚": "jyap", + "쟛": "jyat", + "쟜": "jyat", + "쟝": "jyang", + "쟞": "jyat", + "쟟": "jyat", + "쟠": "jyak", + "쟡": "jyat", + "쟢": "jyap", + "쟣": "jyat", + "쟤": "jyae", + "쟥": "jyaek", + "쟦": "jyaekk", + "쟧": "jyaek", + "쟨": "jyaen", + "쟩": "jyaen", + "쟪": "jyaen", + "쟫": "jyaet", + "쟬": "jyael", + "쟭": "jyaek", + "쟮": "jyaem", + "쟯": "jyaep", + "쟰": "jyaet", + "쟱": "jyaet", + "쟲": "jyaep", + "쟳": "jyael", + "쟴": "jyaem", + "쟵": "jyaep", + "쟶": "jyaep", + "쟷": "jyaet", + "쟸": "jyaet", + "쟹": "jyaeng", + "쟺": "jyaet", + "쟻": "jyaet", + "쟼": "jyaek", + "쟽": "jyaet", + "쟾": "jyaep", + "쟿": "jyaet", + "저": "jeo", + "적": "jeok", + "젂": "jeokk", + "젃": "jeok", + "전": "jeon", + "젅": "jeon", + "젆": "jeon", + "젇": "jeot", + "절": "jeol", + "젉": "jeok", + "젊": "jeom", + "젋": "jeop", + "젌": "jeot", + "젍": "jeot", + "젎": "jeop", + "젏": "jeol", + "점": "jeom", + "접": "jeop", + "젒": "jeop", + "젓": "jeot", + "젔": "jeot", + "정": "jeong", + "젖": "jeot", + "젗": "jeot", + "젘": "jeok", + "젙": "jeot", + "젚": "jeop", + "젛": "jeot", + "제": "je", + "젝": "jek", + "젞": "jekk", + "젟": "jek", + "젠": "jen", + "젡": "jen", + "젢": "jen", + "젣": "jet", + "젤": "jel", + "젥": "jek", + "젦": "jem", + "젧": "jep", + "젨": "jet", + "젩": "jet", + "젪": "jep", + "젫": "jel", + "젬": "jem", + "젭": "jep", + "젮": "jep", + "젯": "jet", + "젰": "jet", + "젱": "jeng", + "젲": "jet", + "젳": "jet", + "젴": "jek", + "젵": "jet", + "젶": "jep", + "젷": "jet", + "져": "jyeo", + "젹": "jyeok", + "젺": "jyeokk", + "젻": "jyeok", + "젼": "jyeon", + "젽": "jyeon", + "젾": "jyeon", + "젿": "jyeot", + "졀": "jyeol", + "졁": "jyeok", + "졂": "jyeom", + "졃": "jyeop", + "졄": "jyeot", + "졅": "jyeot", + "졆": "jyeop", + "졇": "jyeol", + "졈": "jyeom", + "졉": "jyeop", + "졊": "jyeop", + "졋": "jyeot", + "졌": "jyeot", + "졍": "jyeong", + "졎": "jyeot", + "졏": "jyeot", + "졐": "jyeok", + "졑": "jyeot", + "졒": "jyeop", + "졓": "jyeot", + "졔": "jye", + "졕": "jyek", + "졖": "jyekk", + "졗": "jyek", + "졘": "jyen", + "졙": "jyen", + "졚": "jyen", + "졛": "jyet", + "졜": "jyel", + "졝": "jyek", + "졞": "jyem", + "졟": "jyep", + "졠": "jyet", + "졡": "jyet", + "졢": "jyep", + "졣": "jyel", + "졤": "jyem", + "졥": "jyep", + "졦": "jyep", + "졧": "jyet", + "졨": "jyet", + "졩": "jyeng", + "졪": "jyet", + "졫": "jyet", + "졬": "jyek", + "졭": "jyet", + "졮": "jyep", + "졯": "jyet", + "조": "jo", + "족": "jok", + "졲": "jokk", + "졳": "jok", + "존": "jon", + "졵": "jon", + "졶": "jon", + "졷": "jot", + "졸": "jol", + "졹": "jok", + "졺": "jom", + "졻": "jop", + "졼": "jot", + "졽": "jot", + "졾": "jop", + "졿": "jol", + "좀": "jom", + "좁": "jop", + "좂": "jop", + "좃": "jot", + "좄": "jot", + "종": "jong", + "좆": "jot", + "좇": "jot", + "좈": "jok", + "좉": "jot", + "좊": "jop", + "좋": "jot", + "좌": "jwa", + "좍": "jwak", + "좎": "jwakk", + "좏": "jwak", + "좐": "jwan", + "좑": "jwan", + "좒": "jwan", + "좓": "jwat", + "좔": "jwal", + "좕": "jwak", + "좖": "jwam", + "좗": "jwap", + "좘": "jwat", + "좙": "jwat", + "좚": "jwap", + "좛": "jwal", + "좜": "jwam", + "좝": "jwap", + "좞": "jwap", + "좟": "jwat", + "좠": "jwat", + "좡": "jwang", + "좢": "jwat", + "좣": "jwat", + "좤": "jwak", + "좥": "jwat", + "좦": "jwap", + "좧": "jwat", + "좨": "jwae", + "좩": "jwaek", + "좪": "jwaekk", + "좫": "jwaek", + "좬": "jwaen", + "좭": "jwaen", + "좮": "jwaen", + "좯": "jwaet", + "좰": "jwael", + "좱": "jwaek", + "좲": "jwaem", + "좳": "jwaep", + "좴": "jwaet", + "좵": "jwaet", + "좶": "jwaep", + "좷": "jwael", + "좸": "jwaem", + "좹": "jwaep", + "좺": "jwaep", + "좻": "jwaet", + "좼": "jwaet", + "좽": "jwaeng", + "좾": "jwaet", + "좿": "jwaet", + "죀": "jwaek", + "죁": "jwaet", + "죂": "jwaep", + "죃": "jwaet", + "죄": "joe", + "죅": "joek", + "죆": "joekk", + "죇": "joek", + "죈": "joen", + "죉": "joen", + "죊": "joen", + "죋": "joet", + "죌": "joel", + "죍": "joek", + "죎": "joem", + "죏": "joep", + "죐": "joet", + "죑": "joet", + "죒": "joep", + "죓": "joel", + "죔": "joem", + "죕": "joep", + "죖": "joep", + "죗": "joet", + "죘": "joet", + "죙": "joeng", + "죚": "joet", + "죛": "joet", + "죜": "joek", + "죝": "joet", + "죞": "joep", + "죟": "joet", + "죠": "jyo", + "죡": "jyok", + "죢": "jyokk", + "죣": "jyok", + "죤": "jyon", + "죥": "jyon", + "죦": "jyon", + "죧": "jyot", + "죨": "jyol", + "죩": "jyok", + "죪": "jyom", + "죫": "jyop", + "죬": "jyot", + "죭": "jyot", + "죮": "jyop", + "죯": "jyol", + "죰": "jyom", + "죱": "jyop", + "죲": "jyop", + "죳": "jyot", + "죴": "jyot", + "죵": "jyong", + "죶": "jyot", + "죷": "jyot", + "죸": "jyok", + "죹": "jyot", + "죺": "jyop", + "죻": "jyot", + "주": "ju", + "죽": "juk", + "죾": "jukk", + "죿": "juk", + "준": "jun", + "줁": "jun", + "줂": "jun", + "줃": "jut", + "줄": "jul", + "줅": "juk", + "줆": "jum", + "줇": "jup", + "줈": "jut", + "줉": "jut", + "줊": "jup", + "줋": "jul", + "줌": "jum", + "줍": "jup", + "줎": "jup", + "줏": "jut", + "줐": "jut", + "중": "jung", + "줒": "jut", + "줓": "jut", + "줔": "juk", + "줕": "jut", + "줖": "jup", + "줗": "jut", + "줘": "jwo", + "줙": "jwok", + "줚": "jwokk", + "줛": "jwok", + "줜": "jwon", + "줝": "jwon", + "줞": "jwon", + "줟": "jwot", + "줠": "jwol", + "줡": "jwok", + "줢": "jwom", + "줣": "jwop", + "줤": "jwot", + "줥": "jwot", + "줦": "jwop", + "줧": "jwol", + "줨": "jwom", + "줩": "jwop", + "줪": "jwop", + "줫": "jwot", + "줬": "jwot", + "줭": "jwong", + "줮": "jwot", + "줯": "jwot", + "줰": "jwok", + "줱": "jwot", + "줲": "jwop", + "줳": "jwot", + "줴": "jwe", + "줵": "jwek", + "줶": "jwekk", + "줷": "jwek", + "줸": "jwen", + "줹": "jwen", + "줺": "jwen", + "줻": "jwet", + "줼": "jwel", + "줽": "jwek", + "줾": "jwem", + "줿": "jwep", + "쥀": "jwet", + "쥁": "jwet", + "쥂": "jwep", + "쥃": "jwel", + "쥄": "jwem", + "쥅": "jwep", + "쥆": "jwep", + "쥇": "jwet", + "쥈": "jwet", + "쥉": "jweng", + "쥊": "jwet", + "쥋": "jwet", + "쥌": "jwek", + "쥍": "jwet", + "쥎": "jwep", + "쥏": "jwet", + "쥐": "jwi", + "쥑": "jwik", + "쥒": "jwikk", + "쥓": "jwik", + "쥔": "jwin", + "쥕": "jwin", + "쥖": "jwin", + "쥗": "jwit", + "쥘": "jwil", + "쥙": "jwik", + "쥚": "jwim", + "쥛": "jwip", + "쥜": "jwit", + "쥝": "jwit", + "쥞": "jwip", + "쥟": "jwil", + "쥠": "jwim", + "쥡": "jwip", + "쥢": "jwip", + "쥣": "jwit", + "쥤": "jwit", + "쥥": "jwing", + "쥦": "jwit", + "쥧": "jwit", + "쥨": "jwik", + "쥩": "jwit", + "쥪": "jwip", + "쥫": "jwit", + "쥬": "jyu", + "쥭": "jyuk", + "쥮": "jyukk", + "쥯": "jyuk", + "쥰": "jyun", + "쥱": "jyun", + "쥲": "jyun", + "쥳": "jyut", + "쥴": "jyul", + "쥵": "jyuk", + "쥶": "jyum", + "쥷": "jyup", + "쥸": "jyut", + "쥹": "jyut", + "쥺": "jyup", + "쥻": "jyul", + "쥼": "jyum", + "쥽": "jyup", + "쥾": "jyup", + "쥿": "jyut", + "즀": "jyut", + "즁": "jyung", + "즂": "jyut", + "즃": "jyut", + "즄": "jyuk", + "즅": "jyut", + "즆": "jyup", + "즇": "jyut", + "즈": "jeu", + "즉": "jeuk", + "즊": "jeukk", + "즋": "jeuk", + "즌": "jeun", + "즍": "jeun", + "즎": "jeun", + "즏": "jeut", + "즐": "jeul", + "즑": "jeuk", + "즒": "jeum", + "즓": "jeup", + "즔": "jeut", + "즕": "jeut", + "즖": "jeup", + "즗": "jeul", + "즘": "jeum", + "즙": "jeup", + "즚": "jeup", + "즛": "jeut", + "즜": "jeut", + "증": "jeung", + "즞": "jeut", + "즟": "jeut", + "즠": "jeuk", + "즡": "jeut", + "즢": "jeup", + "즣": "jeut", + "즤": "jeui", + "즥": "jeuik", + "즦": "jeuikk", + "즧": "jeuik", + "즨": "jeuin", + "즩": "jeuin", + "즪": "jeuin", + "즫": "jeuit", + "즬": "jeuil", + "즭": "jeuik", + "즮": "jeuim", + "즯": "jeuip", + "즰": "jeuit", + "즱": "jeuit", + "즲": "jeuip", + "즳": "jeuil", + "즴": "jeuim", + "즵": "jeuip", + "즶": "jeuip", + "즷": "jeuit", + "즸": "jeuit", + "즹": "jeuing", + "즺": "jeuit", + "즻": "jeuit", + "즼": "jeuik", + "즽": "jeuit", + "즾": "jeuip", + "즿": "jeuit", + "지": "ji", + "직": "jik", + "짂": "jikk", + "짃": "jik", + "진": "jin", + "짅": "jin", + "짆": "jin", + "짇": "jit", + "질": "jil", + "짉": "jik", + "짊": "jim", + "짋": "jip", + "짌": "jit", + "짍": "jit", + "짎": "jip", + "짏": "jil", + "짐": "jim", + "집": "jip", + "짒": "jip", + "짓": "jit", + "짔": "jit", + "징": "jing", + "짖": "jit", + "짗": "jit", + "짘": "jik", + "짙": "jit", + "짚": "jip", + "짛": "jit", + "짜": "jja", + "짝": "jjak", + "짞": "jjakk", + "짟": "jjak", + "짠": "jjan", + "짡": "jjan", + "짢": "jjan", + "짣": "jjat", + "짤": "jjal", + "짥": "jjak", + "짦": "jjam", + "짧": "jjap", + "짨": "jjat", + "짩": "jjat", + "짪": "jjap", + "짫": "jjal", + "짬": "jjam", + "짭": "jjap", + "짮": "jjap", + "짯": "jjat", + "짰": "jjat", + "짱": "jjang", + "짲": "jjat", + "짳": "jjat", + "짴": "jjak", + "짵": "jjat", + "짶": "jjap", + "짷": "jjat", + "째": "jjae", + "짹": "jjaek", + "짺": "jjaekk", + "짻": "jjaek", + "짼": "jjaen", + "짽": "jjaen", + "짾": "jjaen", + "짿": "jjaet", + "쨀": "jjael", + "쨁": "jjaek", + "쨂": "jjaem", + "쨃": "jjaep", + "쨄": "jjaet", + "쨅": "jjaet", + "쨆": "jjaep", + "쨇": "jjael", + "쨈": "jjaem", + "쨉": "jjaep", + "쨊": "jjaep", + "쨋": "jjaet", + "쨌": "jjaet", + "쨍": "jjaeng", + "쨎": "jjaet", + "쨏": "jjaet", + "쨐": "jjaek", + "쨑": "jjaet", + "쨒": "jjaep", + "쨓": "jjaet", + "쨔": "jjya", + "쨕": "jjyak", + "쨖": "jjyakk", + "쨗": "jjyak", + "쨘": "jjyan", + "쨙": "jjyan", + "쨚": "jjyan", + "쨛": "jjyat", + "쨜": "jjyal", + "쨝": "jjyak", + "쨞": "jjyam", + "쨟": "jjyap", + "쨠": "jjyat", + "쨡": "jjyat", + "쨢": "jjyap", + "쨣": "jjyal", + "쨤": "jjyam", + "쨥": "jjyap", + "쨦": "jjyap", + "쨧": "jjyat", + "쨨": "jjyat", + "쨩": "jjyang", + "쨪": "jjyat", + "쨫": "jjyat", + "쨬": "jjyak", + "쨭": "jjyat", + "쨮": "jjyap", + "쨯": "jjyat", + "쨰": "jjyae", + "쨱": "jjyaek", + "쨲": "jjyaekk", + "쨳": "jjyaek", + "쨴": "jjyaen", + "쨵": "jjyaen", + "쨶": "jjyaen", + "쨷": "jjyaet", + "쨸": "jjyael", + "쨹": "jjyaek", + "쨺": "jjyaem", + "쨻": "jjyaep", + "쨼": "jjyaet", + "쨽": "jjyaet", + "쨾": "jjyaep", + "쨿": "jjyael", + "쩀": "jjyaem", + "쩁": "jjyaep", + "쩂": "jjyaep", + "쩃": "jjyaet", + "쩄": "jjyaet", + "쩅": "jjyaeng", + "쩆": "jjyaet", + "쩇": "jjyaet", + "쩈": "jjyaek", + "쩉": "jjyaet", + "쩊": "jjyaep", + "쩋": "jjyaet", + "쩌": "jjeo", + "쩍": "jjeok", + "쩎": "jjeokk", + "쩏": "jjeok", + "쩐": "jjeon", + "쩑": "jjeon", + "쩒": "jjeon", + "쩓": "jjeot", + "쩔": "jjeol", + "쩕": "jjeok", + "쩖": "jjeom", + "쩗": "jjeop", + "쩘": "jjeot", + "쩙": "jjeot", + "쩚": "jjeop", + "쩛": "jjeol", + "쩜": "jjeom", + "쩝": "jjeop", + "쩞": "jjeop", + "쩟": "jjeot", + "쩠": "jjeot", + "쩡": "jjeong", + "쩢": "jjeot", + "쩣": "jjeot", + "쩤": "jjeok", + "쩥": "jjeot", + "쩦": "jjeop", + "쩧": "jjeot", + "쩨": "jje", + "쩩": "jjek", + "쩪": "jjekk", + "쩫": "jjek", + "쩬": "jjen", + "쩭": "jjen", + "쩮": "jjen", + "쩯": "jjet", + "쩰": "jjel", + "쩱": "jjek", + "쩲": "jjem", + "쩳": "jjep", + "쩴": "jjet", + "쩵": "jjet", + "쩶": "jjep", + "쩷": "jjel", + "쩸": "jjem", + "쩹": "jjep", + "쩺": "jjep", + "쩻": "jjet", + "쩼": "jjet", + "쩽": "jjeng", + "쩾": "jjet", + "쩿": "jjet", + "쪀": "jjek", + "쪁": "jjet", + "쪂": "jjep", + "쪃": "jjet", + "쪄": "jjyeo", + "쪅": "jjyeok", + "쪆": "jjyeokk", + "쪇": "jjyeok", + "쪈": "jjyeon", + "쪉": "jjyeon", + "쪊": "jjyeon", + "쪋": "jjyeot", + "쪌": "jjyeol", + "쪍": "jjyeok", + "쪎": "jjyeom", + "쪏": "jjyeop", + "쪐": "jjyeot", + "쪑": "jjyeot", + "쪒": "jjyeop", + "쪓": "jjyeol", + "쪔": "jjyeom", + "쪕": "jjyeop", + "쪖": "jjyeop", + "쪗": "jjyeot", + "쪘": "jjyeot", + "쪙": "jjyeong", + "쪚": "jjyeot", + "쪛": "jjyeot", + "쪜": "jjyeok", + "쪝": "jjyeot", + "쪞": "jjyeop", + "쪟": "jjyeot", + "쪠": "jjye", + "쪡": "jjyek", + "쪢": "jjyekk", + "쪣": "jjyek", + "쪤": "jjyen", + "쪥": "jjyen", + "쪦": "jjyen", + "쪧": "jjyet", + "쪨": "jjyel", + "쪩": "jjyek", + "쪪": "jjyem", + "쪫": "jjyep", + "쪬": "jjyet", + "쪭": "jjyet", + "쪮": "jjyep", + "쪯": "jjyel", + "쪰": "jjyem", + "쪱": "jjyep", + "쪲": "jjyep", + "쪳": "jjyet", + "쪴": "jjyet", + "쪵": "jjyeng", + "쪶": "jjyet", + "쪷": "jjyet", + "쪸": "jjyek", + "쪹": "jjyet", + "쪺": "jjyep", + "쪻": "jjyet", + "쪼": "jjo", + "쪽": "jjok", + "쪾": "jjokk", + "쪿": "jjok", + "쫀": "jjon", + "쫁": "jjon", + "쫂": "jjon", + "쫃": "jjot", + "쫄": "jjol", + "쫅": "jjok", + "쫆": "jjom", + "쫇": "jjop", + "쫈": "jjot", + "쫉": "jjot", + "쫊": "jjop", + "쫋": "jjol", + "쫌": "jjom", + "쫍": "jjop", + "쫎": "jjop", + "쫏": "jjot", + "쫐": "jjot", + "쫑": "jjong", + "쫒": "jjot", + "쫓": "jjot", + "쫔": "jjok", + "쫕": "jjot", + "쫖": "jjop", + "쫗": "jjot", + "쫘": "jjwa", + "쫙": "jjwak", + "쫚": "jjwakk", + "쫛": "jjwak", + "쫜": "jjwan", + "쫝": "jjwan", + "쫞": "jjwan", + "쫟": "jjwat", + "쫠": "jjwal", + "쫡": "jjwak", + "쫢": "jjwam", + "쫣": "jjwap", + "쫤": "jjwat", + "쫥": "jjwat", + "쫦": "jjwap", + "쫧": "jjwal", + "쫨": "jjwam", + "쫩": "jjwap", + "쫪": "jjwap", + "쫫": "jjwat", + "쫬": "jjwat", + "쫭": "jjwang", + "쫮": "jjwat", + "쫯": "jjwat", + "쫰": "jjwak", + "쫱": "jjwat", + "쫲": "jjwap", + "쫳": "jjwat", + "쫴": "jjwae", + "쫵": "jjwaek", + "쫶": "jjwaekk", + "쫷": "jjwaek", + "쫸": "jjwaen", + "쫹": "jjwaen", + "쫺": "jjwaen", + "쫻": "jjwaet", + "쫼": "jjwael", + "쫽": "jjwaek", + "쫾": "jjwaem", + "쫿": "jjwaep", + "쬀": "jjwaet", + "쬁": "jjwaet", + "쬂": "jjwaep", + "쬃": "jjwael", + "쬄": "jjwaem", + "쬅": "jjwaep", + "쬆": "jjwaep", + "쬇": "jjwaet", + "쬈": "jjwaet", + "쬉": "jjwaeng", + "쬊": "jjwaet", + "쬋": "jjwaet", + "쬌": "jjwaek", + "쬍": "jjwaet", + "쬎": "jjwaep", + "쬏": "jjwaet", + "쬐": "jjoe", + "쬑": "jjoek", + "쬒": "jjoekk", + "쬓": "jjoek", + "쬔": "jjoen", + "쬕": "jjoen", + "쬖": "jjoen", + "쬗": "jjoet", + "쬘": "jjoel", + "쬙": "jjoek", + "쬚": "jjoem", + "쬛": "jjoep", + "쬜": "jjoet", + "쬝": "jjoet", + "쬞": "jjoep", + "쬟": "jjoel", + "쬠": "jjoem", + "쬡": "jjoep", + "쬢": "jjoep", + "쬣": "jjoet", + "쬤": "jjoet", + "쬥": "jjoeng", + "쬦": "jjoet", + "쬧": "jjoet", + "쬨": "jjoek", + "쬩": "jjoet", + "쬪": "jjoep", + "쬫": "jjoet", + "쬬": "jjyo", + "쬭": "jjyok", + "쬮": "jjyokk", + "쬯": "jjyok", + "쬰": "jjyon", + "쬱": "jjyon", + "쬲": "jjyon", + "쬳": "jjyot", + "쬴": "jjyol", + "쬵": "jjyok", + "쬶": "jjyom", + "쬷": "jjyop", + "쬸": "jjyot", + "쬹": "jjyot", + "쬺": "jjyop", + "쬻": "jjyol", + "쬼": "jjyom", + "쬽": "jjyop", + "쬾": "jjyop", + "쬿": "jjyot", + "쭀": "jjyot", + "쭁": "jjyong", + "쭂": "jjyot", + "쭃": "jjyot", + "쭄": "jjyok", + "쭅": "jjyot", + "쭆": "jjyop", + "쭇": "jjyot", + "쭈": "jju", + "쭉": "jjuk", + "쭊": "jjukk", + "쭋": "jjuk", + "쭌": "jjun", + "쭍": "jjun", + "쭎": "jjun", + "쭏": "jjut", + "쭐": "jjul", + "쭑": "jjuk", + "쭒": "jjum", + "쭓": "jjup", + "쭔": "jjut", + "쭕": "jjut", + "쭖": "jjup", + "쭗": "jjul", + "쭘": "jjum", + "쭙": "jjup", + "쭚": "jjup", + "쭛": "jjut", + "쭜": "jjut", + "쭝": "jjung", + "쭞": "jjut", + "쭟": "jjut", + "쭠": "jjuk", + "쭡": "jjut", + "쭢": "jjup", + "쭣": "jjut", + "쭤": "jjwo", + "쭥": "jjwok", + "쭦": "jjwokk", + "쭧": "jjwok", + "쭨": "jjwon", + "쭩": "jjwon", + "쭪": "jjwon", + "쭫": "jjwot", + "쭬": "jjwol", + "쭭": "jjwok", + "쭮": "jjwom", + "쭯": "jjwop", + "쭰": "jjwot", + "쭱": "jjwot", + "쭲": "jjwop", + "쭳": "jjwol", + "쭴": "jjwom", + "쭵": "jjwop", + "쭶": "jjwop", + "쭷": "jjwot", + "쭸": "jjwot", + "쭹": "jjwong", + "쭺": "jjwot", + "쭻": "jjwot", + "쭼": "jjwok", + "쭽": "jjwot", + "쭾": "jjwop", + "쭿": "jjwot", + "쮀": "jjwe", + "쮁": "jjwek", + "쮂": "jjwekk", + "쮃": "jjwek", + "쮄": "jjwen", + "쮅": "jjwen", + "쮆": "jjwen", + "쮇": "jjwet", + "쮈": "jjwel", + "쮉": "jjwek", + "쮊": "jjwem", + "쮋": "jjwep", + "쮌": "jjwet", + "쮍": "jjwet", + "쮎": "jjwep", + "쮏": "jjwel", + "쮐": "jjwem", + "쮑": "jjwep", + "쮒": "jjwep", + "쮓": "jjwet", + "쮔": "jjwet", + "쮕": "jjweng", + "쮖": "jjwet", + "쮗": "jjwet", + "쮘": "jjwek", + "쮙": "jjwet", + "쮚": "jjwep", + "쮛": "jjwet", + "쮜": "jjwi", + "쮝": "jjwik", + "쮞": "jjwikk", + "쮟": "jjwik", + "쮠": "jjwin", + "쮡": "jjwin", + "쮢": "jjwin", + "쮣": "jjwit", + "쮤": "jjwil", + "쮥": "jjwik", + "쮦": "jjwim", + "쮧": "jjwip", + "쮨": "jjwit", + "쮩": "jjwit", + "쮪": "jjwip", + "쮫": "jjwil", + "쮬": "jjwim", + "쮭": "jjwip", + "쮮": "jjwip", + "쮯": "jjwit", + "쮰": "jjwit", + "쮱": "jjwing", + "쮲": "jjwit", + "쮳": "jjwit", + "쮴": "jjwik", + "쮵": "jjwit", + "쮶": "jjwip", + "쮷": "jjwit", + "쮸": "jjyu", + "쮹": "jjyuk", + "쮺": "jjyukk", + "쮻": "jjyuk", + "쮼": "jjyun", + "쮽": "jjyun", + "쮾": "jjyun", + "쮿": "jjyut", + "쯀": "jjyul", + "쯁": "jjyuk", + "쯂": "jjyum", + "쯃": "jjyup", + "쯄": "jjyut", + "쯅": "jjyut", + "쯆": "jjyup", + "쯇": "jjyul", + "쯈": "jjyum", + "쯉": "jjyup", + "쯊": "jjyup", + "쯋": "jjyut", + "쯌": "jjyut", + "쯍": "jjyung", + "쯎": "jjyut", + "쯏": "jjyut", + "쯐": "jjyuk", + "쯑": "jjyut", + "쯒": "jjyup", + "쯓": "jjyut", + "쯔": "jjeu", + "쯕": "jjeuk", + "쯖": "jjeukk", + "쯗": "jjeuk", + "쯘": "jjeun", + "쯙": "jjeun", + "쯚": "jjeun", + "쯛": "jjeut", + "쯜": "jjeul", + "쯝": "jjeuk", + "쯞": "jjeum", + "쯟": "jjeup", + "쯠": "jjeut", + "쯡": "jjeut", + "쯢": "jjeup", + "쯣": "jjeul", + "쯤": "jjeum", + "쯥": "jjeup", + "쯦": "jjeup", + "쯧": "jjeut", + "쯨": "jjeut", + "쯩": "jjeung", + "쯪": "jjeut", + "쯫": "jjeut", + "쯬": "jjeuk", + "쯭": "jjeut", + "쯮": "jjeup", + "쯯": "jjeut", + "쯰": "jjeui", + "쯱": "jjeuik", + "쯲": "jjeuikk", + "쯳": "jjeuik", + "쯴": "jjeuin", + "쯵": "jjeuin", + "쯶": "jjeuin", + "쯷": "jjeuit", + "쯸": "jjeuil", + "쯹": "jjeuik", + "쯺": "jjeuim", + "쯻": "jjeuip", + "쯼": "jjeuit", + "쯽": "jjeuit", + "쯾": "jjeuip", + "쯿": "jjeuil", + "찀": "jjeuim", + "찁": "jjeuip", + "찂": "jjeuip", + "찃": "jjeuit", + "찄": "jjeuit", + "찅": "jjeuing", + "찆": "jjeuit", + "찇": "jjeuit", + "찈": "jjeuik", + "찉": "jjeuit", + "찊": "jjeuip", + "찋": "jjeuit", + "찌": "jji", + "찍": "jjik", + "찎": "jjikk", + "찏": "jjik", + "찐": "jjin", + "찑": "jjin", + "찒": "jjin", + "찓": "jjit", + "찔": "jjil", + "찕": "jjik", + "찖": "jjim", + "찗": "jjip", + "찘": "jjit", + "찙": "jjit", + "찚": "jjip", + "찛": "jjil", + "찜": "jjim", + "찝": "jjip", + "찞": "jjip", + "찟": "jjit", + "찠": "jjit", + "찡": "jjing", + "찢": "jjit", + "찣": "jjit", + "찤": "jjik", + "찥": "jjit", + "찦": "jjip", + "찧": "jjit", + "차": "cha", + "착": "chak", + "찪": "chakk", + "찫": "chak", + "찬": "chan", + "찭": "chan", + "찮": "chan", + "찯": "chat", + "찰": "chal", + "찱": "chak", + "찲": "cham", + "찳": "chap", + "찴": "chat", + "찵": "chat", + "찶": "chap", + "찷": "chal", + "참": "cham", + "찹": "chap", + "찺": "chap", + "찻": "chat", + "찼": "chat", + "창": "chang", + "찾": "chat", + "찿": "chat", + "챀": "chak", + "챁": "chat", + "챂": "chap", + "챃": "chat", + "채": "chae", + "책": "chaek", + "챆": "chaekk", + "챇": "chaek", + "챈": "chaen", + "챉": "chaen", + "챊": "chaen", + "챋": "chaet", + "챌": "chael", + "챍": "chaek", + "챎": "chaem", + "챏": "chaep", + "챐": "chaet", + "챑": "chaet", + "챒": "chaep", + "챓": "chael", + "챔": "chaem", + "챕": "chaep", + "챖": "chaep", + "챗": "chaet", + "챘": "chaet", + "챙": "chaeng", + "챚": "chaet", + "챛": "chaet", + "챜": "chaek", + "챝": "chaet", + "챞": "chaep", + "챟": "chaet", + "챠": "chya", + "챡": "chyak", + "챢": "chyakk", + "챣": "chyak", + "챤": "chyan", + "챥": "chyan", + "챦": "chyan", + "챧": "chyat", + "챨": "chyal", + "챩": "chyak", + "챪": "chyam", + "챫": "chyap", + "챬": "chyat", + "챭": "chyat", + "챮": "chyap", + "챯": "chyal", + "챰": "chyam", + "챱": "chyap", + "챲": "chyap", + "챳": "chyat", + "챴": "chyat", + "챵": "chyang", + "챶": "chyat", + "챷": "chyat", + "챸": "chyak", + "챹": "chyat", + "챺": "chyap", + "챻": "chyat", + "챼": "chyae", + "챽": "chyaek", + "챾": "chyaekk", + "챿": "chyaek", + "첀": "chyaen", + "첁": "chyaen", + "첂": "chyaen", + "첃": "chyaet", + "첄": "chyael", + "첅": "chyaek", + "첆": "chyaem", + "첇": "chyaep", + "첈": "chyaet", + "첉": "chyaet", + "첊": "chyaep", + "첋": "chyael", + "첌": "chyaem", + "첍": "chyaep", + "첎": "chyaep", + "첏": "chyaet", + "첐": "chyaet", + "첑": "chyaeng", + "첒": "chyaet", + "첓": "chyaet", + "첔": "chyaek", + "첕": "chyaet", + "첖": "chyaep", + "첗": "chyaet", + "처": "cheo", + "척": "cheok", + "첚": "cheokk", + "첛": "cheok", + "천": "cheon", + "첝": "cheon", + "첞": "cheon", + "첟": "cheot", + "철": "cheol", + "첡": "cheok", + "첢": "cheom", + "첣": "cheop", + "첤": "cheot", + "첥": "cheot", + "첦": "cheop", + "첧": "cheol", + "첨": "cheom", + "첩": "cheop", + "첪": "cheop", + "첫": "cheot", + "첬": "cheot", + "청": "cheong", + "첮": "cheot", + "첯": "cheot", + "첰": "cheok", + "첱": "cheot", + "첲": "cheop", + "첳": "cheot", + "체": "che", + "첵": "chek", + "첶": "chekk", + "첷": "chek", + "첸": "chen", + "첹": "chen", + "첺": "chen", + "첻": "chet", + "첼": "chel", + "첽": "chek", + "첾": "chem", + "첿": "chep", + "쳀": "chet", + "쳁": "chet", + "쳂": "chep", + "쳃": "chel", + "쳄": "chem", + "쳅": "chep", + "쳆": "chep", + "쳇": "chet", + "쳈": "chet", + "쳉": "cheng", + "쳊": "chet", + "쳋": "chet", + "쳌": "chek", + "쳍": "chet", + "쳎": "chep", + "쳏": "chet", + "쳐": "chyeo", + "쳑": "chyeok", + "쳒": "chyeokk", + "쳓": "chyeok", + "쳔": "chyeon", + "쳕": "chyeon", + "쳖": "chyeon", + "쳗": "chyeot", + "쳘": "chyeol", + "쳙": "chyeok", + "쳚": "chyeom", + "쳛": "chyeop", + "쳜": "chyeot", + "쳝": "chyeot", + "쳞": "chyeop", + "쳟": "chyeol", + "쳠": "chyeom", + "쳡": "chyeop", + "쳢": "chyeop", + "쳣": "chyeot", + "쳤": "chyeot", + "쳥": "chyeong", + "쳦": "chyeot", + "쳧": "chyeot", + "쳨": "chyeok", + "쳩": "chyeot", + "쳪": "chyeop", + "쳫": "chyeot", + "쳬": "chye", + "쳭": "chyek", + "쳮": "chyekk", + "쳯": "chyek", + "쳰": "chyen", + "쳱": "chyen", + "쳲": "chyen", + "쳳": "chyet", + "쳴": "chyel", + "쳵": "chyek", + "쳶": "chyem", + "쳷": "chyep", + "쳸": "chyet", + "쳹": "chyet", + "쳺": "chyep", + "쳻": "chyel", + "쳼": "chyem", + "쳽": "chyep", + "쳾": "chyep", + "쳿": "chyet", + "촀": "chyet", + "촁": "chyeng", + "촂": "chyet", + "촃": "chyet", + "촄": "chyek", + "촅": "chyet", + "촆": "chyep", + "촇": "chyet", + "초": "cho", + "촉": "chok", + "촊": "chokk", + "촋": "chok", + "촌": "chon", + "촍": "chon", + "촎": "chon", + "촏": "chot", + "촐": "chol", + "촑": "chok", + "촒": "chom", + "촓": "chop", + "촔": "chot", + "촕": "chot", + "촖": "chop", + "촗": "chol", + "촘": "chom", + "촙": "chop", + "촚": "chop", + "촛": "chot", + "촜": "chot", + "총": "chong", + "촞": "chot", + "촟": "chot", + "촠": "chok", + "촡": "chot", + "촢": "chop", + "촣": "chot", + "촤": "chwa", + "촥": "chwak", + "촦": "chwakk", + "촧": "chwak", + "촨": "chwan", + "촩": "chwan", + "촪": "chwan", + "촫": "chwat", + "촬": "chwal", + "촭": "chwak", + "촮": "chwam", + "촯": "chwap", + "촰": "chwat", + "촱": "chwat", + "촲": "chwap", + "촳": "chwal", + "촴": "chwam", + "촵": "chwap", + "촶": "chwap", + "촷": "chwat", + "촸": "chwat", + "촹": "chwang", + "촺": "chwat", + "촻": "chwat", + "촼": "chwak", + "촽": "chwat", + "촾": "chwap", + "촿": "chwat", + "쵀": "chwae", + "쵁": "chwaek", + "쵂": "chwaekk", + "쵃": "chwaek", + "쵄": "chwaen", + "쵅": "chwaen", + "쵆": "chwaen", + "쵇": "chwaet", + "쵈": "chwael", + "쵉": "chwaek", + "쵊": "chwaem", + "쵋": "chwaep", + "쵌": "chwaet", + "쵍": "chwaet", + "쵎": "chwaep", + "쵏": "chwael", + "쵐": "chwaem", + "쵑": "chwaep", + "쵒": "chwaep", + "쵓": "chwaet", + "쵔": "chwaet", + "쵕": "chwaeng", + "쵖": "chwaet", + "쵗": "chwaet", + "쵘": "chwaek", + "쵙": "chwaet", + "쵚": "chwaep", + "쵛": "chwaet", + "최": "choe", + "쵝": "choek", + "쵞": "choekk", + "쵟": "choek", + "쵠": "choen", + "쵡": "choen", + "쵢": "choen", + "쵣": "choet", + "쵤": "choel", + "쵥": "choek", + "쵦": "choem", + "쵧": "choep", + "쵨": "choet", + "쵩": "choet", + "쵪": "choep", + "쵫": "choel", + "쵬": "choem", + "쵭": "choep", + "쵮": "choep", + "쵯": "choet", + "쵰": "choet", + "쵱": "choeng", + "쵲": "choet", + "쵳": "choet", + "쵴": "choek", + "쵵": "choet", + "쵶": "choep", + "쵷": "choet", + "쵸": "chyo", + "쵹": "chyok", + "쵺": "chyokk", + "쵻": "chyok", + "쵼": "chyon", + "쵽": "chyon", + "쵾": "chyon", + "쵿": "chyot", + "춀": "chyol", + "춁": "chyok", + "춂": "chyom", + "춃": "chyop", + "춄": "chyot", + "춅": "chyot", + "춆": "chyop", + "춇": "chyol", + "춈": "chyom", + "춉": "chyop", + "춊": "chyop", + "춋": "chyot", + "춌": "chyot", + "춍": "chyong", + "춎": "chyot", + "춏": "chyot", + "춐": "chyok", + "춑": "chyot", + "춒": "chyop", + "춓": "chyot", + "추": "chu", + "축": "chuk", + "춖": "chukk", + "춗": "chuk", + "춘": "chun", + "춙": "chun", + "춚": "chun", + "춛": "chut", + "출": "chul", + "춝": "chuk", + "춞": "chum", + "춟": "chup", + "춠": "chut", + "춡": "chut", + "춢": "chup", + "춣": "chul", + "춤": "chum", + "춥": "chup", + "춦": "chup", + "춧": "chut", + "춨": "chut", + "충": "chung", + "춪": "chut", + "춫": "chut", + "춬": "chuk", + "춭": "chut", + "춮": "chup", + "춯": "chut", + "춰": "chwo", + "춱": "chwok", + "춲": "chwokk", + "춳": "chwok", + "춴": "chwon", + "춵": "chwon", + "춶": "chwon", + "춷": "chwot", + "춸": "chwol", + "춹": "chwok", + "춺": "chwom", + "춻": "chwop", + "춼": "chwot", + "춽": "chwot", + "춾": "chwop", + "춿": "chwol", + "췀": "chwom", + "췁": "chwop", + "췂": "chwop", + "췃": "chwot", + "췄": "chwot", + "췅": "chwong", + "췆": "chwot", + "췇": "chwot", + "췈": "chwok", + "췉": "chwot", + "췊": "chwop", + "췋": "chwot", + "췌": "chwe", + "췍": "chwek", + "췎": "chwekk", + "췏": "chwek", + "췐": "chwen", + "췑": "chwen", + "췒": "chwen", + "췓": "chwet", + "췔": "chwel", + "췕": "chwek", + "췖": "chwem", + "췗": "chwep", + "췘": "chwet", + "췙": "chwet", + "췚": "chwep", + "췛": "chwel", + "췜": "chwem", + "췝": "chwep", + "췞": "chwep", + "췟": "chwet", + "췠": "chwet", + "췡": "chweng", + "췢": "chwet", + "췣": "chwet", + "췤": "chwek", + "췥": "chwet", + "췦": "chwep", + "췧": "chwet", + "취": "chwi", + "췩": "chwik", + "췪": "chwikk", + "췫": "chwik", + "췬": "chwin", + "췭": "chwin", + "췮": "chwin", + "췯": "chwit", + "췰": "chwil", + "췱": "chwik", + "췲": "chwim", + "췳": "chwip", + "췴": "chwit", + "췵": "chwit", + "췶": "chwip", + "췷": "chwil", + "췸": "chwim", + "췹": "chwip", + "췺": "chwip", + "췻": "chwit", + "췼": "chwit", + "췽": "chwing", + "췾": "chwit", + "췿": "chwit", + "츀": "chwik", + "츁": "chwit", + "츂": "chwip", + "츃": "chwit", + "츄": "chyu", + "츅": "chyuk", + "츆": "chyukk", + "츇": "chyuk", + "츈": "chyun", + "츉": "chyun", + "츊": "chyun", + "츋": "chyut", + "츌": "chyul", + "츍": "chyuk", + "츎": "chyum", + "츏": "chyup", + "츐": "chyut", + "츑": "chyut", + "츒": "chyup", + "츓": "chyul", + "츔": "chyum", + "츕": "chyup", + "츖": "chyup", + "츗": "chyut", + "츘": "chyut", + "츙": "chyung", + "츚": "chyut", + "츛": "chyut", + "츜": "chyuk", + "츝": "chyut", + "츞": "chyup", + "츟": "chyut", + "츠": "cheu", + "측": "cheuk", + "츢": "cheukk", + "츣": "cheuk", + "츤": "cheun", + "츥": "cheun", + "츦": "cheun", + "츧": "cheut", + "츨": "cheul", + "츩": "cheuk", + "츪": "cheum", + "츫": "cheup", + "츬": "cheut", + "츭": "cheut", + "츮": "cheup", + "츯": "cheul", + "츰": "cheum", + "츱": "cheup", + "츲": "cheup", + "츳": "cheut", + "츴": "cheut", + "층": "cheung", + "츶": "cheut", + "츷": "cheut", + "츸": "cheuk", + "츹": "cheut", + "츺": "cheup", + "츻": "cheut", + "츼": "cheui", + "츽": "cheuik", + "츾": "cheuikk", + "츿": "cheuik", + "칀": "cheuin", + "칁": "cheuin", + "칂": "cheuin", + "칃": "cheuit", + "칄": "cheuil", + "칅": "cheuik", + "칆": "cheuim", + "칇": "cheuip", + "칈": "cheuit", + "칉": "cheuit", + "칊": "cheuip", + "칋": "cheuil", + "칌": "cheuim", + "칍": "cheuip", + "칎": "cheuip", + "칏": "cheuit", + "칐": "cheuit", + "칑": "cheuing", + "칒": "cheuit", + "칓": "cheuit", + "칔": "cheuik", + "칕": "cheuit", + "칖": "cheuip", + "칗": "cheuit", + "치": "chi", + "칙": "chik", + "칚": "chikk", + "칛": "chik", + "친": "chin", + "칝": "chin", + "칞": "chin", + "칟": "chit", + "칠": "chil", + "칡": "chik", + "칢": "chim", + "칣": "chip", + "칤": "chit", + "칥": "chit", + "칦": "chip", + "칧": "chil", + "침": "chim", + "칩": "chip", + "칪": "chip", + "칫": "chit", + "칬": "chit", + "칭": "ching", + "칮": "chit", + "칯": "chit", + "칰": "chik", + "칱": "chit", + "칲": "chip", + "칳": "chit", + "카": "ka", + "칵": "kak", + "칶": "kakk", + "칷": "kak", + "칸": "kan", + "칹": "kan", + "칺": "kan", + "칻": "kat", + "칼": "kal", + "칽": "kak", + "칾": "kam", + "칿": "kap", + "캀": "kat", + "캁": "kat", + "캂": "kap", + "캃": "kal", + "캄": "kam", + "캅": "kap", + "캆": "kap", + "캇": "kat", + "캈": "kat", + "캉": "kang", + "캊": "kat", + "캋": "kat", + "캌": "kak", + "캍": "kat", + "캎": "kap", + "캏": "kat", + "캐": "kae", + "캑": "kaek", + "캒": "kaekk", + "캓": "kaek", + "캔": "kaen", + "캕": "kaen", + "캖": "kaen", + "캗": "kaet", + "캘": "kael", + "캙": "kaek", + "캚": "kaem", + "캛": "kaep", + "캜": "kaet", + "캝": "kaet", + "캞": "kaep", + "캟": "kael", + "캠": "kaem", + "캡": "kaep", + "캢": "kaep", + "캣": "kaet", + "캤": "kaet", + "캥": "kaeng", + "캦": "kaet", + "캧": "kaet", + "캨": "kaek", + "캩": "kaet", + "캪": "kaep", + "캫": "kaet", + "캬": "kya", + "캭": "kyak", + "캮": "kyakk", + "캯": "kyak", + "캰": "kyan", + "캱": "kyan", + "캲": "kyan", + "캳": "kyat", + "캴": "kyal", + "캵": "kyak", + "캶": "kyam", + "캷": "kyap", + "캸": "kyat", + "캹": "kyat", + "캺": "kyap", + "캻": "kyal", + "캼": "kyam", + "캽": "kyap", + "캾": "kyap", + "캿": "kyat", + "컀": "kyat", + "컁": "kyang", + "컂": "kyat", + "컃": "kyat", + "컄": "kyak", + "컅": "kyat", + "컆": "kyap", + "컇": "kyat", + "컈": "kyae", + "컉": "kyaek", + "컊": "kyaekk", + "컋": "kyaek", + "컌": "kyaen", + "컍": "kyaen", + "컎": "kyaen", + "컏": "kyaet", + "컐": "kyael", + "컑": "kyaek", + "컒": "kyaem", + "컓": "kyaep", + "컔": "kyaet", + "컕": "kyaet", + "컖": "kyaep", + "컗": "kyael", + "컘": "kyaem", + "컙": "kyaep", + "컚": "kyaep", + "컛": "kyaet", + "컜": "kyaet", + "컝": "kyaeng", + "컞": "kyaet", + "컟": "kyaet", + "컠": "kyaek", + "컡": "kyaet", + "컢": "kyaep", + "컣": "kyaet", + "커": "keo", + "컥": "keok", + "컦": "keokk", + "컧": "keok", + "컨": "keon", + "컩": "keon", + "컪": "keon", + "컫": "keot", + "컬": "keol", + "컭": "keok", + "컮": "keom", + "컯": "keop", + "컰": "keot", + "컱": "keot", + "컲": "keop", + "컳": "keol", + "컴": "keom", + "컵": "keop", + "컶": "keop", + "컷": "keot", + "컸": "keot", + "컹": "keong", + "컺": "keot", + "컻": "keot", + "컼": "keok", + "컽": "keot", + "컾": "keop", + "컿": "keot", + "케": "ke", + "켁": "kek", + "켂": "kekk", + "켃": "kek", + "켄": "ken", + "켅": "ken", + "켆": "ken", + "켇": "ket", + "켈": "kel", + "켉": "kek", + "켊": "kem", + "켋": "kep", + "켌": "ket", + "켍": "ket", + "켎": "kep", + "켏": "kel", + "켐": "kem", + "켑": "kep", + "켒": "kep", + "켓": "ket", + "켔": "ket", + "켕": "keng", + "켖": "ket", + "켗": "ket", + "켘": "kek", + "켙": "ket", + "켚": "kep", + "켛": "ket", + "켜": "kyeo", + "켝": "kyeok", + "켞": "kyeokk", + "켟": "kyeok", + "켠": "kyeon", + "켡": "kyeon", + "켢": "kyeon", + "켣": "kyeot", + "켤": "kyeol", + "켥": "kyeok", + "켦": "kyeom", + "켧": "kyeop", + "켨": "kyeot", + "켩": "kyeot", + "켪": "kyeop", + "켫": "kyeol", + "켬": "kyeom", + "켭": "kyeop", + "켮": "kyeop", + "켯": "kyeot", + "켰": "kyeot", + "켱": "kyeong", + "켲": "kyeot", + "켳": "kyeot", + "켴": "kyeok", + "켵": "kyeot", + "켶": "kyeop", + "켷": "kyeot", + "켸": "kye", + "켹": "kyek", + "켺": "kyekk", + "켻": "kyek", + "켼": "kyen", + "켽": "kyen", + "켾": "kyen", + "켿": "kyet", + "콀": "kyel", + "콁": "kyek", + "콂": "kyem", + "콃": "kyep", + "콄": "kyet", + "콅": "kyet", + "콆": "kyep", + "콇": "kyel", + "콈": "kyem", + "콉": "kyep", + "콊": "kyep", + "콋": "kyet", + "콌": "kyet", + "콍": "kyeng", + "콎": "kyet", + "콏": "kyet", + "콐": "kyek", + "콑": "kyet", + "콒": "kyep", + "콓": "kyet", + "코": "ko", + "콕": "kok", + "콖": "kokk", + "콗": "kok", + "콘": "kon", + "콙": "kon", + "콚": "kon", + "콛": "kot", + "콜": "kol", + "콝": "kok", + "콞": "kom", + "콟": "kop", + "콠": "kot", + "콡": "kot", + "콢": "kop", + "콣": "kol", + "콤": "kom", + "콥": "kop", + "콦": "kop", + "콧": "kot", + "콨": "kot", + "콩": "kong", + "콪": "kot", + "콫": "kot", + "콬": "kok", + "콭": "kot", + "콮": "kop", + "콯": "kot", + "콰": "kwa", + "콱": "kwak", + "콲": "kwakk", + "콳": "kwak", + "콴": "kwan", + "콵": "kwan", + "콶": "kwan", + "콷": "kwat", + "콸": "kwal", + "콹": "kwak", + "콺": "kwam", + "콻": "kwap", + "콼": "kwat", + "콽": "kwat", + "콾": "kwap", + "콿": "kwal", + "쾀": "kwam", + "쾁": "kwap", + "쾂": "kwap", + "쾃": "kwat", + "쾄": "kwat", + "쾅": "kwang", + "쾆": "kwat", + "쾇": "kwat", + "쾈": "kwak", + "쾉": "kwat", + "쾊": "kwap", + "쾋": "kwat", + "쾌": "kwae", + "쾍": "kwaek", + "쾎": "kwaekk", + "쾏": "kwaek", + "쾐": "kwaen", + "쾑": "kwaen", + "쾒": "kwaen", + "쾓": "kwaet", + "쾔": "kwael", + "쾕": "kwaek", + "쾖": "kwaem", + "쾗": "kwaep", + "쾘": "kwaet", + "쾙": "kwaet", + "쾚": "kwaep", + "쾛": "kwael", + "쾜": "kwaem", + "쾝": "kwaep", + "쾞": "kwaep", + "쾟": "kwaet", + "쾠": "kwaet", + "쾡": "kwaeng", + "쾢": "kwaet", + "쾣": "kwaet", + "쾤": "kwaek", + "쾥": "kwaet", + "쾦": "kwaep", + "쾧": "kwaet", + "쾨": "koe", + "쾩": "koek", + "쾪": "koekk", + "쾫": "koek", + "쾬": "koen", + "쾭": "koen", + "쾮": "koen", + "쾯": "koet", + "쾰": "koel", + "쾱": "koek", + "쾲": "koem", + "쾳": "koep", + "쾴": "koet", + "쾵": "koet", + "쾶": "koep", + "쾷": "koel", + "쾸": "koem", + "쾹": "koep", + "쾺": "koep", + "쾻": "koet", + "쾼": "koet", + "쾽": "koeng", + "쾾": "koet", + "쾿": "koet", + "쿀": "koek", + "쿁": "koet", + "쿂": "koep", + "쿃": "koet", + "쿄": "kyo", + "쿅": "kyok", + "쿆": "kyokk", + "쿇": "kyok", + "쿈": "kyon", + "쿉": "kyon", + "쿊": "kyon", + "쿋": "kyot", + "쿌": "kyol", + "쿍": "kyok", + "쿎": "kyom", + "쿏": "kyop", + "쿐": "kyot", + "쿑": "kyot", + "쿒": "kyop", + "쿓": "kyol", + "쿔": "kyom", + "쿕": "kyop", + "쿖": "kyop", + "쿗": "kyot", + "쿘": "kyot", + "쿙": "kyong", + "쿚": "kyot", + "쿛": "kyot", + "쿜": "kyok", + "쿝": "kyot", + "쿞": "kyop", + "쿟": "kyot", + "쿠": "ku", + "쿡": "kuk", + "쿢": "kukk", + "쿣": "kuk", + "쿤": "kun", + "쿥": "kun", + "쿦": "kun", + "쿧": "kut", + "쿨": "kul", + "쿩": "kuk", + "쿪": "kum", + "쿫": "kup", + "쿬": "kut", + "쿭": "kut", + "쿮": "kup", + "쿯": "kul", + "쿰": "kum", + "쿱": "kup", + "쿲": "kup", + "쿳": "kut", + "쿴": "kut", + "쿵": "kung", + "쿶": "kut", + "쿷": "kut", + "쿸": "kuk", + "쿹": "kut", + "쿺": "kup", + "쿻": "kut", + "쿼": "kwo", + "쿽": "kwok", + "쿾": "kwokk", + "쿿": "kwok", + "퀀": "kwon", + "퀁": "kwon", + "퀂": "kwon", + "퀃": "kwot", + "퀄": "kwol", + "퀅": "kwok", + "퀆": "kwom", + "퀇": "kwop", + "퀈": "kwot", + "퀉": "kwot", + "퀊": "kwop", + "퀋": "kwol", + "퀌": "kwom", + "퀍": "kwop", + "퀎": "kwop", + "퀏": "kwot", + "퀐": "kwot", + "퀑": "kwong", + "퀒": "kwot", + "퀓": "kwot", + "퀔": "kwok", + "퀕": "kwot", + "퀖": "kwop", + "퀗": "kwot", + "퀘": "kwe", + "퀙": "kwek", + "퀚": "kwekk", + "퀛": "kwek", + "퀜": "kwen", + "퀝": "kwen", + "퀞": "kwen", + "퀟": "kwet", + "퀠": "kwel", + "퀡": "kwek", + "퀢": "kwem", + "퀣": "kwep", + "퀤": "kwet", + "퀥": "kwet", + "퀦": "kwep", + "퀧": "kwel", + "퀨": "kwem", + "퀩": "kwep", + "퀪": "kwep", + "퀫": "kwet", + "퀬": "kwet", + "퀭": "kweng", + "퀮": "kwet", + "퀯": "kwet", + "퀰": "kwek", + "퀱": "kwet", + "퀲": "kwep", + "퀳": "kwet", + "퀴": "kwi", + "퀵": "kwik", + "퀶": "kwikk", + "퀷": "kwik", + "퀸": "kwin", + "퀹": "kwin", + "퀺": "kwin", + "퀻": "kwit", + "퀼": "kwil", + "퀽": "kwik", + "퀾": "kwim", + "퀿": "kwip", + "큀": "kwit", + "큁": "kwit", + "큂": "kwip", + "큃": "kwil", + "큄": "kwim", + "큅": "kwip", + "큆": "kwip", + "큇": "kwit", + "큈": "kwit", + "큉": "kwing", + "큊": "kwit", + "큋": "kwit", + "큌": "kwik", + "큍": "kwit", + "큎": "kwip", + "큏": "kwit", + "큐": "kyu", + "큑": "kyuk", + "큒": "kyukk", + "큓": "kyuk", + "큔": "kyun", + "큕": "kyun", + "큖": "kyun", + "큗": "kyut", + "큘": "kyul", + "큙": "kyuk", + "큚": "kyum", + "큛": "kyup", + "큜": "kyut", + "큝": "kyut", + "큞": "kyup", + "큟": "kyul", + "큠": "kyum", + "큡": "kyup", + "큢": "kyup", + "큣": "kyut", + "큤": "kyut", + "큥": "kyung", + "큦": "kyut", + "큧": "kyut", + "큨": "kyuk", + "큩": "kyut", + "큪": "kyup", + "큫": "kyut", + "크": "keu", + "큭": "keuk", + "큮": "keukk", + "큯": "keuk", + "큰": "keun", + "큱": "keun", + "큲": "keun", + "큳": "keut", + "클": "keul", + "큵": "keuk", + "큶": "keum", + "큷": "keup", + "큸": "keut", + "큹": "keut", + "큺": "keup", + "큻": "keul", + "큼": "keum", + "큽": "keup", + "큾": "keup", + "큿": "keut", + "킀": "keut", + "킁": "keung", + "킂": "keut", + "킃": "keut", + "킄": "keuk", + "킅": "keut", + "킆": "keup", + "킇": "keut", + "킈": "keui", + "킉": "keuik", + "킊": "keuikk", + "킋": "keuik", + "킌": "keuin", + "킍": "keuin", + "킎": "keuin", + "킏": "keuit", + "킐": "keuil", + "킑": "keuik", + "킒": "keuim", + "킓": "keuip", + "킔": "keuit", + "킕": "keuit", + "킖": "keuip", + "킗": "keuil", + "킘": "keuim", + "킙": "keuip", + "킚": "keuip", + "킛": "keuit", + "킜": "keuit", + "킝": "keuing", + "킞": "keuit", + "킟": "keuit", + "킠": "keuik", + "킡": "keuit", + "킢": "keuip", + "킣": "keuit", + "키": "ki", + "킥": "kik", + "킦": "kikk", + "킧": "kik", + "킨": "kin", + "킩": "kin", + "킪": "kin", + "킫": "kit", + "킬": "kil", + "킭": "kik", + "킮": "kim", + "킯": "kip", + "킰": "kit", + "킱": "kit", + "킲": "kip", + "킳": "kil", + "킴": "kim", + "킵": "kip", + "킶": "kip", + "킷": "kit", + "킸": "kit", + "킹": "king", + "킺": "kit", + "킻": "kit", + "킼": "kik", + "킽": "kit", + "킾": "kip", + "킿": "kit", + "타": "ta", + "탁": "tak", + "탂": "takk", + "탃": "tak", + "탄": "tan", + "탅": "tan", + "탆": "tan", + "탇": "tat", + "탈": "tal", + "탉": "tak", + "탊": "tam", + "탋": "tap", + "탌": "tat", + "탍": "tat", + "탎": "tap", + "탏": "tal", + "탐": "tam", + "탑": "tap", + "탒": "tap", + "탓": "tat", + "탔": "tat", + "탕": "tang", + "탖": "tat", + "탗": "tat", + "탘": "tak", + "탙": "tat", + "탚": "tap", + "탛": "tat", + "태": "tae", + "택": "taek", + "탞": "taekk", + "탟": "taek", + "탠": "taen", + "탡": "taen", + "탢": "taen", + "탣": "taet", + "탤": "tael", + "탥": "taek", + "탦": "taem", + "탧": "taep", + "탨": "taet", + "탩": "taet", + "탪": "taep", + "탫": "tael", + "탬": "taem", + "탭": "taep", + "탮": "taep", + "탯": "taet", + "탰": "taet", + "탱": "taeng", + "탲": "taet", + "탳": "taet", + "탴": "taek", + "탵": "taet", + "탶": "taep", + "탷": "taet", + "탸": "tya", + "탹": "tyak", + "탺": "tyakk", + "탻": "tyak", + "탼": "tyan", + "탽": "tyan", + "탾": "tyan", + "탿": "tyat", + "턀": "tyal", + "턁": "tyak", + "턂": "tyam", + "턃": "tyap", + "턄": "tyat", + "턅": "tyat", + "턆": "tyap", + "턇": "tyal", + "턈": "tyam", + "턉": "tyap", + "턊": "tyap", + "턋": "tyat", + "턌": "tyat", + "턍": "tyang", + "턎": "tyat", + "턏": "tyat", + "턐": "tyak", + "턑": "tyat", + "턒": "tyap", + "턓": "tyat", + "턔": "tyae", + "턕": "tyaek", + "턖": "tyaekk", + "턗": "tyaek", + "턘": "tyaen", + "턙": "tyaen", + "턚": "tyaen", + "턛": "tyaet", + "턜": "tyael", + "턝": "tyaek", + "턞": "tyaem", + "턟": "tyaep", + "턠": "tyaet", + "턡": "tyaet", + "턢": "tyaep", + "턣": "tyael", + "턤": "tyaem", + "턥": "tyaep", + "턦": "tyaep", + "턧": "tyaet", + "턨": "tyaet", + "턩": "tyaeng", + "턪": "tyaet", + "턫": "tyaet", + "턬": "tyaek", + "턭": "tyaet", + "턮": "tyaep", + "턯": "tyaet", + "터": "teo", + "턱": "teok", + "턲": "teokk", + "턳": "teok", + "턴": "teon", + "턵": "teon", + "턶": "teon", + "턷": "teot", + "털": "teol", + "턹": "teok", + "턺": "teom", + "턻": "teop", + "턼": "teot", + "턽": "teot", + "턾": "teop", + "턿": "teol", + "텀": "teom", + "텁": "teop", + "텂": "teop", + "텃": "teot", + "텄": "teot", + "텅": "teong", + "텆": "teot", + "텇": "teot", + "텈": "teok", + "텉": "teot", + "텊": "teop", + "텋": "teot", + "테": "te", + "텍": "tek", + "텎": "tekk", + "텏": "tek", + "텐": "ten", + "텑": "ten", + "텒": "ten", + "텓": "tet", + "텔": "tel", + "텕": "tek", + "텖": "tem", + "텗": "tep", + "텘": "tet", + "텙": "tet", + "텚": "tep", + "텛": "tel", + "템": "tem", + "텝": "tep", + "텞": "tep", + "텟": "tet", + "텠": "tet", + "텡": "teng", + "텢": "tet", + "텣": "tet", + "텤": "tek", + "텥": "tet", + "텦": "tep", + "텧": "tet", + "텨": "tyeo", + "텩": "tyeok", + "텪": "tyeokk", + "텫": "tyeok", + "텬": "tyeon", + "텭": "tyeon", + "텮": "tyeon", + "텯": "tyeot", + "텰": "tyeol", + "텱": "tyeok", + "텲": "tyeom", + "텳": "tyeop", + "텴": "tyeot", + "텵": "tyeot", + "텶": "tyeop", + "텷": "tyeol", + "텸": "tyeom", + "텹": "tyeop", + "텺": "tyeop", + "텻": "tyeot", + "텼": "tyeot", + "텽": "tyeong", + "텾": "tyeot", + "텿": "tyeot", + "톀": "tyeok", + "톁": "tyeot", + "톂": "tyeop", + "톃": "tyeot", + "톄": "tye", + "톅": "tyek", + "톆": "tyekk", + "톇": "tyek", + "톈": "tyen", + "톉": "tyen", + "톊": "tyen", + "톋": "tyet", + "톌": "tyel", + "톍": "tyek", + "톎": "tyem", + "톏": "tyep", + "톐": "tyet", + "톑": "tyet", + "톒": "tyep", + "톓": "tyel", + "톔": "tyem", + "톕": "tyep", + "톖": "tyep", + "톗": "tyet", + "톘": "tyet", + "톙": "tyeng", + "톚": "tyet", + "톛": "tyet", + "톜": "tyek", + "톝": "tyet", + "톞": "tyep", + "톟": "tyet", + "토": "to", + "톡": "tok", + "톢": "tokk", + "톣": "tok", + "톤": "ton", + "톥": "ton", + "톦": "ton", + "톧": "tot", + "톨": "tol", + "톩": "tok", + "톪": "tom", + "톫": "top", + "톬": "tot", + "톭": "tot", + "톮": "top", + "톯": "tol", + "톰": "tom", + "톱": "top", + "톲": "top", + "톳": "tot", + "톴": "tot", + "통": "tong", + "톶": "tot", + "톷": "tot", + "톸": "tok", + "톹": "tot", + "톺": "top", + "톻": "tot", + "톼": "twa", + "톽": "twak", + "톾": "twakk", + "톿": "twak", + "퇀": "twan", + "퇁": "twan", + "퇂": "twan", + "퇃": "twat", + "퇄": "twal", + "퇅": "twak", + "퇆": "twam", + "퇇": "twap", + "퇈": "twat", + "퇉": "twat", + "퇊": "twap", + "퇋": "twal", + "퇌": "twam", + "퇍": "twap", + "퇎": "twap", + "퇏": "twat", + "퇐": "twat", + "퇑": "twang", + "퇒": "twat", + "퇓": "twat", + "퇔": "twak", + "퇕": "twat", + "퇖": "twap", + "퇗": "twat", + "퇘": "twae", + "퇙": "twaek", + "퇚": "twaekk", + "퇛": "twaek", + "퇜": "twaen", + "퇝": "twaen", + "퇞": "twaen", + "퇟": "twaet", + "퇠": "twael", + "퇡": "twaek", + "퇢": "twaem", + "퇣": "twaep", + "퇤": "twaet", + "퇥": "twaet", + "퇦": "twaep", + "퇧": "twael", + "퇨": "twaem", + "퇩": "twaep", + "퇪": "twaep", + "퇫": "twaet", + "퇬": "twaet", + "퇭": "twaeng", + "퇮": "twaet", + "퇯": "twaet", + "퇰": "twaek", + "퇱": "twaet", + "퇲": "twaep", + "퇳": "twaet", + "퇴": "toe", + "퇵": "toek", + "퇶": "toekk", + "퇷": "toek", + "퇸": "toen", + "퇹": "toen", + "퇺": "toen", + "퇻": "toet", + "퇼": "toel", + "퇽": "toek", + "퇾": "toem", + "퇿": "toep", + "툀": "toet", + "툁": "toet", + "툂": "toep", + "툃": "toel", + "툄": "toem", + "툅": "toep", + "툆": "toep", + "툇": "toet", + "툈": "toet", + "툉": "toeng", + "툊": "toet", + "툋": "toet", + "툌": "toek", + "툍": "toet", + "툎": "toep", + "툏": "toet", + "툐": "tyo", + "툑": "tyok", + "툒": "tyokk", + "툓": "tyok", + "툔": "tyon", + "툕": "tyon", + "툖": "tyon", + "툗": "tyot", + "툘": "tyol", + "툙": "tyok", + "툚": "tyom", + "툛": "tyop", + "툜": "tyot", + "툝": "tyot", + "툞": "tyop", + "툟": "tyol", + "툠": "tyom", + "툡": "tyop", + "툢": "tyop", + "툣": "tyot", + "툤": "tyot", + "툥": "tyong", + "툦": "tyot", + "툧": "tyot", + "툨": "tyok", + "툩": "tyot", + "툪": "tyop", + "툫": "tyot", + "투": "tu", + "툭": "tuk", + "툮": "tukk", + "툯": "tuk", + "툰": "tun", + "툱": "tun", + "툲": "tun", + "툳": "tut", + "툴": "tul", + "툵": "tuk", + "툶": "tum", + "툷": "tup", + "툸": "tut", + "툹": "tut", + "툺": "tup", + "툻": "tul", + "툼": "tum", + "툽": "tup", + "툾": "tup", + "툿": "tut", + "퉀": "tut", + "퉁": "tung", + "퉂": "tut", + "퉃": "tut", + "퉄": "tuk", + "퉅": "tut", + "퉆": "tup", + "퉇": "tut", + "퉈": "two", + "퉉": "twok", + "퉊": "twokk", + "퉋": "twok", + "퉌": "twon", + "퉍": "twon", + "퉎": "twon", + "퉏": "twot", + "퉐": "twol", + "퉑": "twok", + "퉒": "twom", + "퉓": "twop", + "퉔": "twot", + "퉕": "twot", + "퉖": "twop", + "퉗": "twol", + "퉘": "twom", + "퉙": "twop", + "퉚": "twop", + "퉛": "twot", + "퉜": "twot", + "퉝": "twong", + "퉞": "twot", + "퉟": "twot", + "퉠": "twok", + "퉡": "twot", + "퉢": "twop", + "퉣": "twot", + "퉤": "twe", + "퉥": "twek", + "퉦": "twekk", + "퉧": "twek", + "퉨": "twen", + "퉩": "twen", + "퉪": "twen", + "퉫": "twet", + "퉬": "twel", + "퉭": "twek", + "퉮": "twem", + "퉯": "twep", + "퉰": "twet", + "퉱": "twet", + "퉲": "twep", + "퉳": "twel", + "퉴": "twem", + "퉵": "twep", + "퉶": "twep", + "퉷": "twet", + "퉸": "twet", + "퉹": "tweng", + "퉺": "twet", + "퉻": "twet", + "퉼": "twek", + "퉽": "twet", + "퉾": "twep", + "퉿": "twet", + "튀": "twi", + "튁": "twik", + "튂": "twikk", + "튃": "twik", + "튄": "twin", + "튅": "twin", + "튆": "twin", + "튇": "twit", + "튈": "twil", + "튉": "twik", + "튊": "twim", + "튋": "twip", + "튌": "twit", + "튍": "twit", + "튎": "twip", + "튏": "twil", + "튐": "twim", + "튑": "twip", + "튒": "twip", + "튓": "twit", + "튔": "twit", + "튕": "twing", + "튖": "twit", + "튗": "twit", + "튘": "twik", + "튙": "twit", + "튚": "twip", + "튛": "twit", + "튜": "tyu", + "튝": "tyuk", + "튞": "tyukk", + "튟": "tyuk", + "튠": "tyun", + "튡": "tyun", + "튢": "tyun", + "튣": "tyut", + "튤": "tyul", + "튥": "tyuk", + "튦": "tyum", + "튧": "tyup", + "튨": "tyut", + "튩": "tyut", + "튪": "tyup", + "튫": "tyul", + "튬": "tyum", + "튭": "tyup", + "튮": "tyup", + "튯": "tyut", + "튰": "tyut", + "튱": "tyung", + "튲": "tyut", + "튳": "tyut", + "튴": "tyuk", + "튵": "tyut", + "튶": "tyup", + "튷": "tyut", + "트": "teu", + "특": "teuk", + "튺": "teukk", + "튻": "teuk", + "튼": "teun", + "튽": "teun", + "튾": "teun", + "튿": "teut", + "틀": "teul", + "틁": "teuk", + "틂": "teum", + "틃": "teup", + "틄": "teut", + "틅": "teut", + "틆": "teup", + "틇": "teul", + "틈": "teum", + "틉": "teup", + "틊": "teup", + "틋": "teut", + "틌": "teut", + "틍": "teung", + "틎": "teut", + "틏": "teut", + "틐": "teuk", + "틑": "teut", + "틒": "teup", + "틓": "teut", + "틔": "teui", + "틕": "teuik", + "틖": "teuikk", + "틗": "teuik", + "틘": "teuin", + "틙": "teuin", + "틚": "teuin", + "틛": "teuit", + "틜": "teuil", + "틝": "teuik", + "틞": "teuim", + "틟": "teuip", + "틠": "teuit", + "틡": "teuit", + "틢": "teuip", + "틣": "teuil", + "틤": "teuim", + "틥": "teuip", + "틦": "teuip", + "틧": "teuit", + "틨": "teuit", + "틩": "teuing", + "틪": "teuit", + "틫": "teuit", + "틬": "teuik", + "틭": "teuit", + "틮": "teuip", + "틯": "teuit", + "티": "ti", + "틱": "tik", + "틲": "tikk", + "틳": "tik", + "틴": "tin", + "틵": "tin", + "틶": "tin", + "틷": "tit", + "틸": "til", + "틹": "tik", + "틺": "tim", + "틻": "tip", + "틼": "tit", + "틽": "tit", + "틾": "tip", + "틿": "til", + "팀": "tim", + "팁": "tip", + "팂": "tip", + "팃": "tit", + "팄": "tit", + "팅": "ting", + "팆": "tit", + "팇": "tit", + "팈": "tik", + "팉": "tit", + "팊": "tip", + "팋": "tit", + "파": "pa", + "팍": "pak", + "팎": "pakk", + "팏": "pak", + "판": "pan", + "팑": "pan", + "팒": "pan", + "팓": "pat", + "팔": "pal", + "팕": "pak", + "팖": "pam", + "팗": "pap", + "팘": "pat", + "팙": "pat", + "팚": "pap", + "팛": "pal", + "팜": "pam", + "팝": "pap", + "팞": "pap", + "팟": "pat", + "팠": "pat", + "팡": "pang", + "팢": "pat", + "팣": "pat", + "팤": "pak", + "팥": "pat", + "팦": "pap", + "팧": "pat", + "패": "pae", + "팩": "paek", + "팪": "paekk", + "팫": "paek", + "팬": "paen", + "팭": "paen", + "팮": "paen", + "팯": "paet", + "팰": "pael", + "팱": "paek", + "팲": "paem", + "팳": "paep", + "팴": "paet", + "팵": "paet", + "팶": "paep", + "팷": "pael", + "팸": "paem", + "팹": "paep", + "팺": "paep", + "팻": "paet", + "팼": "paet", + "팽": "paeng", + "팾": "paet", + "팿": "paet", + "퍀": "paek", + "퍁": "paet", + "퍂": "paep", + "퍃": "paet", + "퍄": "pya", + "퍅": "pyak", + "퍆": "pyakk", + "퍇": "pyak", + "퍈": "pyan", + "퍉": "pyan", + "퍊": "pyan", + "퍋": "pyat", + "퍌": "pyal", + "퍍": "pyak", + "퍎": "pyam", + "퍏": "pyap", + "퍐": "pyat", + "퍑": "pyat", + "퍒": "pyap", + "퍓": "pyal", + "퍔": "pyam", + "퍕": "pyap", + "퍖": "pyap", + "퍗": "pyat", + "퍘": "pyat", + "퍙": "pyang", + "퍚": "pyat", + "퍛": "pyat", + "퍜": "pyak", + "퍝": "pyat", + "퍞": "pyap", + "퍟": "pyat", + "퍠": "pyae", + "퍡": "pyaek", + "퍢": "pyaekk", + "퍣": "pyaek", + "퍤": "pyaen", + "퍥": "pyaen", + "퍦": "pyaen", + "퍧": "pyaet", + "퍨": "pyael", + "퍩": "pyaek", + "퍪": "pyaem", + "퍫": "pyaep", + "퍬": "pyaet", + "퍭": "pyaet", + "퍮": "pyaep", + "퍯": "pyael", + "퍰": "pyaem", + "퍱": "pyaep", + "퍲": "pyaep", + "퍳": "pyaet", + "퍴": "pyaet", + "퍵": "pyaeng", + "퍶": "pyaet", + "퍷": "pyaet", + "퍸": "pyaek", + "퍹": "pyaet", + "퍺": "pyaep", + "퍻": "pyaet", + "퍼": "peo", + "퍽": "peok", + "퍾": "peokk", + "퍿": "peok", + "펀": "peon", + "펁": "peon", + "펂": "peon", + "펃": "peot", + "펄": "peol", + "펅": "peok", + "펆": "peom", + "펇": "peop", + "펈": "peot", + "펉": "peot", + "펊": "peop", + "펋": "peol", + "펌": "peom", + "펍": "peop", + "펎": "peop", + "펏": "peot", + "펐": "peot", + "펑": "peong", + "펒": "peot", + "펓": "peot", + "펔": "peok", + "펕": "peot", + "펖": "peop", + "펗": "peot", + "페": "pe", + "펙": "pek", + "펚": "pekk", + "펛": "pek", + "펜": "pen", + "펝": "pen", + "펞": "pen", + "펟": "pet", + "펠": "pel", + "펡": "pek", + "펢": "pem", + "펣": "pep", + "펤": "pet", + "펥": "pet", + "펦": "pep", + "펧": "pel", + "펨": "pem", + "펩": "pep", + "펪": "pep", + "펫": "pet", + "펬": "pet", + "펭": "peng", + "펮": "pet", + "펯": "pet", + "펰": "pek", + "펱": "pet", + "펲": "pep", + "펳": "pet", + "펴": "pyeo", + "펵": "pyeok", + "펶": "pyeokk", + "펷": "pyeok", + "편": "pyeon", + "펹": "pyeon", + "펺": "pyeon", + "펻": "pyeot", + "펼": "pyeol", + "펽": "pyeok", + "펾": "pyeom", + "펿": "pyeop", + "폀": "pyeot", + "폁": "pyeot", + "폂": "pyeop", + "폃": "pyeol", + "폄": "pyeom", + "폅": "pyeop", + "폆": "pyeop", + "폇": "pyeot", + "폈": "pyeot", + "평": "pyeong", + "폊": "pyeot", + "폋": "pyeot", + "폌": "pyeok", + "폍": "pyeot", + "폎": "pyeop", + "폏": "pyeot", + "폐": "pye", + "폑": "pyek", + "폒": "pyekk", + "폓": "pyek", + "폔": "pyen", + "폕": "pyen", + "폖": "pyen", + "폗": "pyet", + "폘": "pyel", + "폙": "pyek", + "폚": "pyem", + "폛": "pyep", + "폜": "pyet", + "폝": "pyet", + "폞": "pyep", + "폟": "pyel", + "폠": "pyem", + "폡": "pyep", + "폢": "pyep", + "폣": "pyet", + "폤": "pyet", + "폥": "pyeng", + "폦": "pyet", + "폧": "pyet", + "폨": "pyek", + "폩": "pyet", + "폪": "pyep", + "폫": "pyet", + "포": "po", + "폭": "pok", + "폮": "pokk", + "폯": "pok", + "폰": "pon", + "폱": "pon", + "폲": "pon", + "폳": "pot", + "폴": "pol", + "폵": "pok", + "폶": "pom", + "폷": "pop", + "폸": "pot", + "폹": "pot", + "폺": "pop", + "폻": "pol", + "폼": "pom", + "폽": "pop", + "폾": "pop", + "폿": "pot", + "퐀": "pot", + "퐁": "pong", + "퐂": "pot", + "퐃": "pot", + "퐄": "pok", + "퐅": "pot", + "퐆": "pop", + "퐇": "pot", + "퐈": "pwa", + "퐉": "pwak", + "퐊": "pwakk", + "퐋": "pwak", + "퐌": "pwan", + "퐍": "pwan", + "퐎": "pwan", + "퐏": "pwat", + "퐐": "pwal", + "퐑": "pwak", + "퐒": "pwam", + "퐓": "pwap", + "퐔": "pwat", + "퐕": "pwat", + "퐖": "pwap", + "퐗": "pwal", + "퐘": "pwam", + "퐙": "pwap", + "퐚": "pwap", + "퐛": "pwat", + "퐜": "pwat", + "퐝": "pwang", + "퐞": "pwat", + "퐟": "pwat", + "퐠": "pwak", + "퐡": "pwat", + "퐢": "pwap", + "퐣": "pwat", + "퐤": "pwae", + "퐥": "pwaek", + "퐦": "pwaekk", + "퐧": "pwaek", + "퐨": "pwaen", + "퐩": "pwaen", + "퐪": "pwaen", + "퐫": "pwaet", + "퐬": "pwael", + "퐭": "pwaek", + "퐮": "pwaem", + "퐯": "pwaep", + "퐰": "pwaet", + "퐱": "pwaet", + "퐲": "pwaep", + "퐳": "pwael", + "퐴": "pwaem", + "퐵": "pwaep", + "퐶": "pwaep", + "퐷": "pwaet", + "퐸": "pwaet", + "퐹": "pwaeng", + "퐺": "pwaet", + "퐻": "pwaet", + "퐼": "pwaek", + "퐽": "pwaet", + "퐾": "pwaep", + "퐿": "pwaet", + "푀": "poe", + "푁": "poek", + "푂": "poekk", + "푃": "poek", + "푄": "poen", + "푅": "poen", + "푆": "poen", + "푇": "poet", + "푈": "poel", + "푉": "poek", + "푊": "poem", + "푋": "poep", + "푌": "poet", + "푍": "poet", + "푎": "poep", + "푏": "poel", + "푐": "poem", + "푑": "poep", + "푒": "poep", + "푓": "poet", + "푔": "poet", + "푕": "poeng", + "푖": "poet", + "푗": "poet", + "푘": "poek", + "푙": "poet", + "푚": "poep", + "푛": "poet", + "표": "pyo", + "푝": "pyok", + "푞": "pyokk", + "푟": "pyok", + "푠": "pyon", + "푡": "pyon", + "푢": "pyon", + "푣": "pyot", + "푤": "pyol", + "푥": "pyok", + "푦": "pyom", + "푧": "pyop", + "푨": "pyot", + "푩": "pyot", + "푪": "pyop", + "푫": "pyol", + "푬": "pyom", + "푭": "pyop", + "푮": "pyop", + "푯": "pyot", + "푰": "pyot", + "푱": "pyong", + "푲": "pyot", + "푳": "pyot", + "푴": "pyok", + "푵": "pyot", + "푶": "pyop", + "푷": "pyot", + "푸": "pu", + "푹": "puk", + "푺": "pukk", + "푻": "puk", + "푼": "pun", + "푽": "pun", + "푾": "pun", + "푿": "put", + "풀": "pul", + "풁": "puk", + "풂": "pum", + "풃": "pup", + "풄": "put", + "풅": "put", + "풆": "pup", + "풇": "pul", + "품": "pum", + "풉": "pup", + "풊": "pup", + "풋": "put", + "풌": "put", + "풍": "pung", + "풎": "put", + "풏": "put", + "풐": "puk", + "풑": "put", + "풒": "pup", + "풓": "put", + "풔": "pwo", + "풕": "pwok", + "풖": "pwokk", + "풗": "pwok", + "풘": "pwon", + "풙": "pwon", + "풚": "pwon", + "풛": "pwot", + "풜": "pwol", + "풝": "pwok", + "풞": "pwom", + "풟": "pwop", + "풠": "pwot", + "풡": "pwot", + "풢": "pwop", + "풣": "pwol", + "풤": "pwom", + "풥": "pwop", + "풦": "pwop", + "풧": "pwot", + "풨": "pwot", + "풩": "pwong", + "풪": "pwot", + "풫": "pwot", + "풬": "pwok", + "풭": "pwot", + "풮": "pwop", + "풯": "pwot", + "풰": "pwe", + "풱": "pwek", + "풲": "pwekk", + "풳": "pwek", + "풴": "pwen", + "풵": "pwen", + "풶": "pwen", + "풷": "pwet", + "풸": "pwel", + "풹": "pwek", + "풺": "pwem", + "풻": "pwep", + "풼": "pwet", + "풽": "pwet", + "풾": "pwep", + "풿": "pwel", + "퓀": "pwem", + "퓁": "pwep", + "퓂": "pwep", + "퓃": "pwet", + "퓄": "pwet", + "퓅": "pweng", + "퓆": "pwet", + "퓇": "pwet", + "퓈": "pwek", + "퓉": "pwet", + "퓊": "pwep", + "퓋": "pwet", + "퓌": "pwi", + "퓍": "pwik", + "퓎": "pwikk", + "퓏": "pwik", + "퓐": "pwin", + "퓑": "pwin", + "퓒": "pwin", + "퓓": "pwit", + "퓔": "pwil", + "퓕": "pwik", + "퓖": "pwim", + "퓗": "pwip", + "퓘": "pwit", + "퓙": "pwit", + "퓚": "pwip", + "퓛": "pwil", + "퓜": "pwim", + "퓝": "pwip", + "퓞": "pwip", + "퓟": "pwit", + "퓠": "pwit", + "퓡": "pwing", + "퓢": "pwit", + "퓣": "pwit", + "퓤": "pwik", + "퓥": "pwit", + "퓦": "pwip", + "퓧": "pwit", + "퓨": "pyu", + "퓩": "pyuk", + "퓪": "pyukk", + "퓫": "pyuk", + "퓬": "pyun", + "퓭": "pyun", + "퓮": "pyun", + "퓯": "pyut", + "퓰": "pyul", + "퓱": "pyuk", + "퓲": "pyum", + "퓳": "pyup", + "퓴": "pyut", + "퓵": "pyut", + "퓶": "pyup", + "퓷": "pyul", + "퓸": "pyum", + "퓹": "pyup", + "퓺": "pyup", + "퓻": "pyut", + "퓼": "pyut", + "퓽": "pyung", + "퓾": "pyut", + "퓿": "pyut", + "픀": "pyuk", + "픁": "pyut", + "픂": "pyup", + "픃": "pyut", + "프": "peu", + "픅": "peuk", + "픆": "peukk", + "픇": "peuk", + "픈": "peun", + "픉": "peun", + "픊": "peun", + "픋": "peut", + "플": "peul", + "픍": "peuk", + "픎": "peum", + "픏": "peup", + "픐": "peut", + "픑": "peut", + "픒": "peup", + "픓": "peul", + "픔": "peum", + "픕": "peup", + "픖": "peup", + "픗": "peut", + "픘": "peut", + "픙": "peung", + "픚": "peut", + "픛": "peut", + "픜": "peuk", + "픝": "peut", + "픞": "peup", + "픟": "peut", + "픠": "peui", + "픡": "peuik", + "픢": "peuikk", + "픣": "peuik", + "픤": "peuin", + "픥": "peuin", + "픦": "peuin", + "픧": "peuit", + "픨": "peuil", + "픩": "peuik", + "픪": "peuim", + "픫": "peuip", + "픬": "peuit", + "픭": "peuit", + "픮": "peuip", + "픯": "peuil", + "픰": "peuim", + "픱": "peuip", + "픲": "peuip", + "픳": "peuit", + "픴": "peuit", + "픵": "peuing", + "픶": "peuit", + "픷": "peuit", + "픸": "peuik", + "픹": "peuit", + "픺": "peuip", + "픻": "peuit", + "피": "pi", + "픽": "pik", + "픾": "pikk", + "픿": "pik", + "핀": "pin", + "핁": "pin", + "핂": "pin", + "핃": "pit", + "필": "pil", + "핅": "pik", + "핆": "pim", + "핇": "pip", + "핈": "pit", + "핉": "pit", + "핊": "pip", + "핋": "pil", + "핌": "pim", + "핍": "pip", + "핎": "pip", + "핏": "pit", + "핐": "pit", + "핑": "ping", + "핒": "pit", + "핓": "pit", + "핔": "pik", + "핕": "pit", + "핖": "pip", + "핗": "pit", + "하": "ha", + "학": "hak", + "핚": "hakk", + "핛": "hak", + "한": "han", + "핝": "han", + "핞": "han", + "핟": "hat", + "할": "hal", + "핡": "hak", + "핢": "ham", + "핣": "hap", + "핤": "hat", + "핥": "hat", + "핦": "hap", + "핧": "hal", + "함": "ham", + "합": "hap", + "핪": "hap", + "핫": "hat", + "핬": "hat", + "항": "hang", + "핮": "hat", + "핯": "hat", + "핰": "hak", + "핱": "hat", + "핲": "hap", + "핳": "hat", + "해": "hae", + "핵": "haek", + "핶": "haekk", + "핷": "haek", + "핸": "haen", + "핹": "haen", + "핺": "haen", + "핻": "haet", + "핼": "hael", + "핽": "haek", + "핾": "haem", + "핿": "haep", + "햀": "haet", + "햁": "haet", + "햂": "haep", + "햃": "hael", + "햄": "haem", + "햅": "haep", + "햆": "haep", + "햇": "haet", + "했": "haet", + "행": "haeng", + "햊": "haet", + "햋": "haet", + "햌": "haek", + "햍": "haet", + "햎": "haep", + "햏": "haet", + "햐": "hya", + "햑": "hyak", + "햒": "hyakk", + "햓": "hyak", + "햔": "hyan", + "햕": "hyan", + "햖": "hyan", + "햗": "hyat", + "햘": "hyal", + "햙": "hyak", + "햚": "hyam", + "햛": "hyap", + "햜": "hyat", + "햝": "hyat", + "햞": "hyap", + "햟": "hyal", + "햠": "hyam", + "햡": "hyap", + "햢": "hyap", + "햣": "hyat", + "햤": "hyat", + "향": "hyang", + "햦": "hyat", + "햧": "hyat", + "햨": "hyak", + "햩": "hyat", + "햪": "hyap", + "햫": "hyat", + "햬": "hyae", + "햭": "hyaek", + "햮": "hyaekk", + "햯": "hyaek", + "햰": "hyaen", + "햱": "hyaen", + "햲": "hyaen", + "햳": "hyaet", + "햴": "hyael", + "햵": "hyaek", + "햶": "hyaem", + "햷": "hyaep", + "햸": "hyaet", + "햹": "hyaet", + "햺": "hyaep", + "햻": "hyael", + "햼": "hyaem", + "햽": "hyaep", + "햾": "hyaep", + "햿": "hyaet", + "헀": "hyaet", + "헁": "hyaeng", + "헂": "hyaet", + "헃": "hyaet", + "헄": "hyaek", + "헅": "hyaet", + "헆": "hyaep", + "헇": "hyaet", + "허": "heo", + "헉": "heok", + "헊": "heokk", + "헋": "heok", + "헌": "heon", + "헍": "heon", + "헎": "heon", + "헏": "heot", + "헐": "heol", + "헑": "heok", + "헒": "heom", + "헓": "heop", + "헔": "heot", + "헕": "heot", + "헖": "heop", + "헗": "heol", + "험": "heom", + "헙": "heop", + "헚": "heop", + "헛": "heot", + "헜": "heot", + "헝": "heong", + "헞": "heot", + "헟": "heot", + "헠": "heok", + "헡": "heot", + "헢": "heop", + "헣": "heot", + "헤": "he", + "헥": "hek", + "헦": "hekk", + "헧": "hek", + "헨": "hen", + "헩": "hen", + "헪": "hen", + "헫": "het", + "헬": "hel", + "헭": "hek", + "헮": "hem", + "헯": "hep", + "헰": "het", + "헱": "het", + "헲": "hep", + "헳": "hel", + "헴": "hem", + "헵": "hep", + "헶": "hep", + "헷": "het", + "헸": "het", + "헹": "heng", + "헺": "het", + "헻": "het", + "헼": "hek", + "헽": "het", + "헾": "hep", + "헿": "het", + "혀": "hyeo", + "혁": "hyeok", + "혂": "hyeokk", + "혃": "hyeok", + "현": "hyeon", + "혅": "hyeon", + "혆": "hyeon", + "혇": "hyeot", + "혈": "hyeol", + "혉": "hyeok", + "혊": "hyeom", + "혋": "hyeop", + "혌": "hyeot", + "혍": "hyeot", + "혎": "hyeop", + "혏": "hyeol", + "혐": "hyeom", + "협": "hyeop", + "혒": "hyeop", + "혓": "hyeot", + "혔": "hyeot", + "형": "hyeong", + "혖": "hyeot", + "혗": "hyeot", + "혘": "hyeok", + "혙": "hyeot", + "혚": "hyeop", + "혛": "hyeot", + "혜": "hye", + "혝": "hyek", + "혞": "hyekk", + "혟": "hyek", + "혠": "hyen", + "혡": "hyen", + "혢": "hyen", + "혣": "hyet", + "혤": "hyel", + "혥": "hyek", + "혦": "hyem", + "혧": "hyep", + "혨": "hyet", + "혩": "hyet", + "혪": "hyep", + "혫": "hyel", + "혬": "hyem", + "혭": "hyep", + "혮": "hyep", + "혯": "hyet", + "혰": "hyet", + "혱": "hyeng", + "혲": "hyet", + "혳": "hyet", + "혴": "hyek", + "혵": "hyet", + "혶": "hyep", + "혷": "hyet", + "호": "ho", + "혹": "hok", + "혺": "hokk", + "혻": "hok", + "혼": "hon", + "혽": "hon", + "혾": "hon", + "혿": "hot", + "홀": "hol", + "홁": "hok", + "홂": "hom", + "홃": "hop", + "홄": "hot", + "홅": "hot", + "홆": "hop", + "홇": "hol", + "홈": "hom", + "홉": "hop", + "홊": "hop", + "홋": "hot", + "홌": "hot", + "홍": "hong", + "홎": "hot", + "홏": "hot", + "홐": "hok", + "홑": "hot", + "홒": "hop", + "홓": "hot", + "화": "hwa", + "확": "hwak", + "홖": "hwakk", + "홗": "hwak", + "환": "hwan", + "홙": "hwan", + "홚": "hwan", + "홛": "hwat", + "활": "hwal", + "홝": "hwak", + "홞": "hwam", + "홟": "hwap", + "홠": "hwat", + "홡": "hwat", + "홢": "hwap", + "홣": "hwal", + "홤": "hwam", + "홥": "hwap", + "홦": "hwap", + "홧": "hwat", + "홨": "hwat", + "황": "hwang", + "홪": "hwat", + "홫": "hwat", + "홬": "hwak", + "홭": "hwat", + "홮": "hwap", + "홯": "hwat", + "홰": "hwae", + "홱": "hwaek", + "홲": "hwaekk", + "홳": "hwaek", + "홴": "hwaen", + "홵": "hwaen", + "홶": "hwaen", + "홷": "hwaet", + "홸": "hwael", + "홹": "hwaek", + "홺": "hwaem", + "홻": "hwaep", + "홼": "hwaet", + "홽": "hwaet", + "홾": "hwaep", + "홿": "hwael", + "횀": "hwaem", + "횁": "hwaep", + "횂": "hwaep", + "횃": "hwaet", + "횄": "hwaet", + "횅": "hwaeng", + "횆": "hwaet", + "횇": "hwaet", + "횈": "hwaek", + "횉": "hwaet", + "횊": "hwaep", + "횋": "hwaet", + "회": "hoe", + "획": "hoek", + "횎": "hoekk", + "횏": "hoek", + "횐": "hoen", + "횑": "hoen", + "횒": "hoen", + "횓": "hoet", + "횔": "hoel", + "횕": "hoek", + "횖": "hoem", + "횗": "hoep", + "횘": "hoet", + "횙": "hoet", + "횚": "hoep", + "횛": "hoel", + "횜": "hoem", + "횝": "hoep", + "횞": "hoep", + "횟": "hoet", + "횠": "hoet", + "횡": "hoeng", + "횢": "hoet", + "횣": "hoet", + "횤": "hoek", + "횥": "hoet", + "횦": "hoep", + "횧": "hoet", + "효": "hyo", + "횩": "hyok", + "횪": "hyokk", + "횫": "hyok", + "횬": "hyon", + "횭": "hyon", + "횮": "hyon", + "횯": "hyot", + "횰": "hyol", + "횱": "hyok", + "횲": "hyom", + "횳": "hyop", + "횴": "hyot", + "횵": "hyot", + "횶": "hyop", + "횷": "hyol", + "횸": "hyom", + "횹": "hyop", + "횺": "hyop", + "횻": "hyot", + "횼": "hyot", + "횽": "hyong", + "횾": "hyot", + "횿": "hyot", + "훀": "hyok", + "훁": "hyot", + "훂": "hyop", + "훃": "hyot", + "후": "hu", + "훅": "huk", + "훆": "hukk", + "훇": "huk", + "훈": "hun", + "훉": "hun", + "훊": "hun", + "훋": "hut", + "훌": "hul", + "훍": "huk", + "훎": "hum", + "훏": "hup", + "훐": "hut", + "훑": "hut", + "훒": "hup", + "훓": "hul", + "훔": "hum", + "훕": "hup", + "훖": "hup", + "훗": "hut", + "훘": "hut", + "훙": "hung", + "훚": "hut", + "훛": "hut", + "훜": "huk", + "훝": "hut", + "훞": "hup", + "훟": "hut", + "훠": "hwo", + "훡": "hwok", + "훢": "hwokk", + "훣": "hwok", + "훤": "hwon", + "훥": "hwon", + "훦": "hwon", + "훧": "hwot", + "훨": "hwol", + "훩": "hwok", + "훪": "hwom", + "훫": "hwop", + "훬": "hwot", + "훭": "hwot", + "훮": "hwop", + "훯": "hwol", + "훰": "hwom", + "훱": "hwop", + "훲": "hwop", + "훳": "hwot", + "훴": "hwot", + "훵": "hwong", + "훶": "hwot", + "훷": "hwot", + "훸": "hwok", + "훹": "hwot", + "훺": "hwop", + "훻": "hwot", + "훼": "hwe", + "훽": "hwek", + "훾": "hwekk", + "훿": "hwek", + "휀": "hwen", + "휁": "hwen", + "휂": "hwen", + "휃": "hwet", + "휄": "hwel", + "휅": "hwek", + "휆": "hwem", + "휇": "hwep", + "휈": "hwet", + "휉": "hwet", + "휊": "hwep", + "휋": "hwel", + "휌": "hwem", + "휍": "hwep", + "휎": "hwep", + "휏": "hwet", + "휐": "hwet", + "휑": "hweng", + "휒": "hwet", + "휓": "hwet", + "휔": "hwek", + "휕": "hwet", + "휖": "hwep", + "휗": "hwet", + "휘": "hwi", + "휙": "hwik", + "휚": "hwikk", + "휛": "hwik", + "휜": "hwin", + "휝": "hwin", + "휞": "hwin", + "휟": "hwit", + "휠": "hwil", + "휡": "hwik", + "휢": "hwim", + "휣": "hwip", + "휤": "hwit", + "휥": "hwit", + "휦": "hwip", + "휧": "hwil", + "휨": "hwim", + "휩": "hwip", + "휪": "hwip", + "휫": "hwit", + "휬": "hwit", + "휭": "hwing", + "휮": "hwit", + "휯": "hwit", + "휰": "hwik", + "휱": "hwit", + "휲": "hwip", + "휳": "hwit", + "휴": "hyu", + "휵": "hyuk", + "휶": "hyukk", + "휷": "hyuk", + "휸": "hyun", + "휹": "hyun", + "휺": "hyun", + "휻": "hyut", + "휼": "hyul", + "휽": "hyuk", + "휾": "hyum", + "휿": "hyup", + "흀": "hyut", + "흁": "hyut", + "흂": "hyup", + "흃": "hyul", + "흄": "hyum", + "흅": "hyup", + "흆": "hyup", + "흇": "hyut", + "흈": "hyut", + "흉": "hyung", + "흊": "hyut", + "흋": "hyut", + "흌": "hyuk", + "흍": "hyut", + "흎": "hyup", + "흏": "hyut", + "흐": "heu", + "흑": "heuk", + "흒": "heukk", + "흓": "heuk", + "흔": "heun", + "흕": "heun", + "흖": "heun", + "흗": "heut", + "흘": "heul", + "흙": "heuk", + "흚": "heum", + "흛": "heup", + "흜": "heut", + "흝": "heut", + "흞": "heup", + "흟": "heul", + "흠": "heum", + "흡": "heup", + "흢": "heup", + "흣": "heut", + "흤": "heut", + "흥": "heung", + "흦": "heut", + "흧": "heut", + "흨": "heuk", + "흩": "heut", + "흪": "heup", + "흫": "heut", + "희": "heui", + "흭": "heuik", + "흮": "heuikk", + "흯": "heuik", + "흰": "heuin", + "흱": "heuin", + "흲": "heuin", + "흳": "heuit", + "흴": "heuil", + "흵": "heuik", + "흶": "heuim", + "흷": "heuip", + "흸": "heuit", + "흹": "heuit", + "흺": "heuip", + "흻": "heuil", + "흼": "heuim", + "흽": "heuip", + "흾": "heuip", + "흿": "heuit", + "힀": "heuit", + "힁": "heuing", + "힂": "heuit", + "힃": "heuit", + "힄": "heuik", + "힅": "heuit", + "힆": "heuip", + "힇": "heuit", + "히": "hi", + "힉": "hik", + "힊": "hikk", + "힋": "hik", + "힌": "hin", + "힍": "hin", + "힎": "hin", + "힏": "hit", + "힐": "hil", + "힑": "hik", + "힒": "him", + "힓": "hip", + "힔": "hit", + "힕": "hit", + "힖": "hip", + "힗": "hil", + "힘": "him", + "힙": "hip", + "힚": "hip", + "힛": "hit", + "힜": "hit", + "힝": "hing", + "힞": "hit", + "힟": "hit", + "힠": "hik", + "힡": "hit", + "힢": "hip", + "힣": "hit" +} \ No newline at end of file diff --git a/public/kirby/i18n/rules/lt.json b/public/kirby/i18n/rules/lt.json new file mode 100644 index 0000000..23e0d70 --- /dev/null +++ b/public/kirby/i18n/rules/lt.json @@ -0,0 +1,20 @@ +{ + "Ą": "A", + "Č": "C", + "Ę": "E", + "Ė": "E", + "Į": "I", + "Š": "S", + "Ų": "U", + "Ū": "U", + "Ž": "Z", + "ą": "a", + "č": "c", + "ę": "e", + "ė": "e", + "į": "i", + "š": "s", + "ų": "u", + "ū": "u", + "ž": "z" +} diff --git a/public/kirby/i18n/rules/lv.json b/public/kirby/i18n/rules/lv.json new file mode 100644 index 0000000..d5b0010 --- /dev/null +++ b/public/kirby/i18n/rules/lv.json @@ -0,0 +1,18 @@ +{ + "Ā": "A", + "Ē": "E", + "Ģ": "G", + "Ī": "I", + "Ķ": "K", + "Ļ": "L", + "Ņ": "N", + "Ū": "U", + "ā": "a", + "ē": "e", + "ģ": "g", + "ī": "i", + "ķ": "k", + "ļ": "l", + "ņ": "n", + "ū": "u" +} diff --git a/public/kirby/i18n/rules/mk.json b/public/kirby/i18n/rules/mk.json new file mode 100644 index 0000000..7a87f46 --- /dev/null +++ b/public/kirby/i18n/rules/mk.json @@ -0,0 +1,64 @@ +{ + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Ѓ": "Gj", + "Е": "E", + "Ж": "Zh", + "З": "Z", + "Ѕ": "Dz", + "И": "I", + "Ј": "J", + "К": "K", + "Л": "L", + "Љ": "Lj", + "М": "M", + "Н": "N", + "Њ": "Nj", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "Ќ": "Kj", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "C", + "Ч": "Ch", + "Џ": "Dj", + "Ш": "Sh", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "ѓ": "gj", + "е": "e", + "ж": "zh", + "з": "z", + "ѕ": "dz", + "и": "i", + "ј": "j", + "к": "k", + "л": "l", + "љ": "lj", + "м": "m", + "н": "n", + "њ": "nj", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "ќ": "kj", + "у": "u", + "ф": "f", + "х": "h", + "ц": "c", + "ч": "ch", + "џ": "dj", + "ш": "sh" +} diff --git a/public/kirby/i18n/rules/my.json b/public/kirby/i18n/rules/my.json new file mode 100644 index 0000000..08f5a0a --- /dev/null +++ b/public/kirby/i18n/rules/my.json @@ -0,0 +1,121 @@ +{ + "က": "k", + "ခ": "kh", + "ဂ": "g", + "ဃ": "ga", + "င": "ng", + "စ": "s", + "ဆ": "sa", + "ဇ": "z", + "စျ" : "za", + "ည": "ny", + "ဋ": "t", + "ဌ": "ta", + "ဍ": "d", + "ဎ": "da", + "ဏ": "na", + "တ": "t", + "ထ": "ta", + "ဒ": "d", + "ဓ": "da", + "န": "n", + "ပ": "p", + "ဖ": "pa", + "ဗ": "b", + "ဘ": "ba", + "မ": "m", + "ယ": "y", + "ရ": "ya", + "လ": "l", + "ဝ": "w", + "သ": "th", + "ဟ": "h", + "ဠ": "la", + "အ": "a", + + "ြ": "y", + "ျ": "ya", + "ွ": "w", + "ြွ": "yw", + "ျွ": "ywa", + "ှ": "h", + + "ဧ": "e", + "၏": "-e", + "ဣ": "i", + "ဤ": "-i", + "ဉ": "u", + "ဦ": "-u", + "ဩ": "aw", + "သြော" : "aw", + "ဪ": "aw", + "၍": "ywae", + "၌": "hnaik", + + "၀": "0", + "၁": "1", + "၂": "2", + "၃": "3", + "၄": "4", + "၅": "5", + "၆": "6", + "၇": "7", + "၈": "8", + "၉": "9", + + "္": "", + "့": "", + "း": "", + + "ာ": "a", + "ါ": "a", + "ေ": "e", + "ဲ": "e", + "ိ": "i", + "ီ": "i", + "ို": "o", + "ု": "u", + "ူ": "u", + "ေါင်": "aung", + "ော": "aw", + "ော်": "aw", + "ေါ": "aw", + "ေါ်": "aw", + "်": "at", + "က်": "et", + "ိုက်" : "aik", + "ောက်" : "auk", + "င်" : "in", + "ိုင်" : "aing", + "ောင်" : "aung", + "စ်" : "it", + "ည်" : "i", + "တ်" : "at", + "ိတ်" : "eik", + "ုတ်" : "ok", + "ွတ်" : "ut", + "ေတ်" : "it", + "ဒ်" : "d", + "ိုဒ်" : "ok", + "ုဒ်" : "ait", + "န်" : "an", + "ာန်" : "an", + "ိန်" : "ein", + "ုန်" : "on", + "ွန်" : "un", + "ပ်" : "at", + "ိပ်" : "eik", + "ုပ်" : "ok", + "ွပ်" : "ut", + "န်ုပ်" : "nub", + "မ်" : "an", + "ိမ်" : "ein", + "ုမ်" : "on", + "ွမ်" : "un", + "ယ်" : "e", + "ိုလ်" : "ol", + "ဉ်" : "in", + "ံ": "an", + "ိံ" : "ein", + "ုံ" : "on" +} diff --git a/public/kirby/i18n/rules/nb.json b/public/kirby/i18n/rules/nb.json new file mode 100644 index 0000000..66000ba --- /dev/null +++ b/public/kirby/i18n/rules/nb.json @@ -0,0 +1,8 @@ +{ + "Æ": "AE", + "Ø": "OE", + "Å": "AA", + "æ": "ae", + "ø": "oe", + "å": "aa" +} diff --git a/public/kirby/i18n/rules/pl.json b/public/kirby/i18n/rules/pl.json new file mode 100644 index 0000000..5d0c123 --- /dev/null +++ b/public/kirby/i18n/rules/pl.json @@ -0,0 +1,20 @@ +{ + "Ą": "A", + "Ć": "C", + "Ę": "E", + "Ł": "L", + "Ń": "N", + "Ó": "O", + "Ś": "S", + "Ź": "Z", + "Ż": "Z", + "ą": "a", + "ć": "c", + "ę": "e", + "ł": "l", + "ń": "n", + "ó": "o", + "ś": "s", + "ź": "z", + "ż": "z" +} diff --git a/public/kirby/i18n/rules/pt_BR.json b/public/kirby/i18n/rules/pt_BR.json new file mode 100644 index 0000000..39bca6c --- /dev/null +++ b/public/kirby/i18n/rules/pt_BR.json @@ -0,0 +1,187 @@ + +{ + "°": "0", + "¹": "1", + "²": "2", + "³": "3", + "⁴": "4", + "⁵": "5", + "⁶": "6", + "⁷": "7", + "⁸": "8", + "⁹": "9", + + "₀": "0", + "₁": "1", + "₂": "2", + "₃": "3", + "₄": "4", + "₅": "5", + "₆": "6", + "₇": "7", + "₈": "8", + "₉": "9", + + + "æ": "ae", + "ǽ": "ae", + "À": "A", + "Á": "A", + "Â": "A", + "Ã": "A", + "Å": "AA", + "Ǻ": "A", + "Ă": "A", + "Ǎ": "A", + "Æ": "AE", + "Ǽ": "AE", + "à": "a", + "á": "a", + "â": "a", + "ã": "a", + "å": "aa", + "ǻ": "a", + "ă": "a", + "ǎ": "a", + "ª": "a", + "@": "at", + "Ĉ": "C", + "Ċ": "C", + "Ç": "Ç", + "ç": "ç", + "ĉ": "c", + "ċ": "c", + "©": "c", + "Ð": "Dj", + "Đ": "D", + "ð": "dj", + "đ": "d", + "È": "E", + "É": "E", + "Ê": "E", + "Ë": "E", + "Ĕ": "E", + "Ė": "E", + "è": "e", + "é": "é", + "ê": "e", + "ë": "e", + "ĕ": "e", + "ė": "e", + "ƒ": "f", + "Ĝ": "G", + "Ġ": "G", + "ĝ": "g", + "ġ": "g", + "Ĥ": "H", + "Ħ": "H", + "ĥ": "h", + "ħ": "h", + "Ì": "I", + "Í": "I", + "Î": "I", + "Ï": "I", + "Ĩ": "I", + "Ĭ": "I", + "Ǐ": "I", + "Į": "I", + "IJ": "IJ", + "ì": "i", + "í": "i", + "î": "i", + "ï": "i", + "ĩ": "i", + "ĭ": "i", + "ǐ": "i", + "į": "i", + "ij": "ij", + "Ĵ": "J", + "ĵ": "j", + "Ĺ": "L", + "Ľ": "L", + "Ŀ": "L", + "ĺ": "l", + "ľ": "l", + "ŀ": "l", + "Ñ": "N", + "ñ": "n", + "ʼn": "n", + "Ò": "O", + "Ó": "O", + "Ô": "O", + "Õ": "O", + "Ō": "O", + "Ŏ": "O", + "Ǒ": "O", + "Ő": "O", + "Ơ": "O", + "Ø": "OE", + "Ǿ": "O", + "Œ": "OE", + "ò": "o", + "ó": "o", + "ô": "o", + "õ": "o", + "ō": "o", + "ŏ": "o", + "ǒ": "o", + "ő": "o", + "ơ": "o", + "ø": "oe", + "ǿ": "o", + "º": "o", + "œ": "oe", + "Ŕ": "R", + "Ŗ": "R", + "ŕ": "r", + "ŗ": "r", + "Ŝ": "S", + "Ș": "S", + "ŝ": "s", + "ș": "s", + "ſ": "s", + "Ţ": "T", + "Ț": "T", + "Ŧ": "T", + "Þ": "TH", + "ţ": "t", + "ț": "t", + "ŧ": "t", + "þ": "th", + "Ù": "U", + "Ú": "U", + "Û": "U", + "Ü": "U", + "Ũ": "U", + "Ŭ": "U", + "Ű": "U", + "Ų": "U", + "Ư": "U", + "Ǔ": "U", + "Ǖ": "U", + "Ǘ": "U", + "Ǚ": "U", + "Ǜ": "U", + "ù": "u", + "ú": "u", + "û": "u", + "ü": "u", + "ũ": "u", + "ŭ": "u", + "ű": "u", + "ų": "u", + "ư": "u", + "ǔ": "u", + "ǖ": "u", + "ǘ": "u", + "ǚ": "u", + "ǜ": "u", + "Ŵ": "W", + "ŵ": "w", + "Ý": "Y", + "Ÿ": "Y", + "Ŷ": "Y", + "ý": "y", + "ÿ": "y", + "ŷ": "y" +} diff --git a/public/kirby/i18n/rules/ro.json b/public/kirby/i18n/rules/ro.json new file mode 100644 index 0000000..47b9d9b --- /dev/null +++ b/public/kirby/i18n/rules/ro.json @@ -0,0 +1,16 @@ +{ + "ă": "a", + "î": "i", + "â": "a", + "ş": "s", + "ș": "s", + "ţ": "t", + "ț": "t", + "Ă": "A", + "Î": "I", + "Â": "A", + "Ş": "S", + "Ș": "S", + "Ţ": "T", + "Ț": "T" +} diff --git a/public/kirby/i18n/rules/ru.json b/public/kirby/i18n/rules/ru.json new file mode 100644 index 0000000..b8b354c --- /dev/null +++ b/public/kirby/i18n/rules/ru.json @@ -0,0 +1,68 @@ +{ + "Ъ": "", + "Ь": "", + "А": "A", + "Б": "B", + "Ц": "C", + "Ч": "Ch", + "Д": "D", + "Е": "E", + "Ё": "E", + "Э": "E", + "Ф": "F", + "Г": "G", + "Х": "H", + "И": "I", + "Й": "Y", + "Я": "Ya", + "Ю": "Yu", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Ш": "Sh", + "Щ": "Shch", + "Т": "T", + "У": "U", + "В": "V", + "Ы": "Y", + "З": "Z", + "Ж": "Zh", + "ъ": "", + "ь": "", + "а": "a", + "б": "b", + "ц": "c", + "ч": "ch", + "д": "d", + "е": "e", + "ё": "e", + "э": "e", + "ф": "f", + "г": "g", + "х": "h", + "и": "i", + "й": "y", + "я": "ya", + "ю": "yu", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "ш": "sh", + "щ": "shch", + "т": "t", + "у": "u", + "в": "v", + "ы": "y", + "з": "z", + "ж": "zh" +} diff --git a/public/kirby/i18n/rules/sr.json b/public/kirby/i18n/rules/sr.json new file mode 100644 index 0000000..f4c11db --- /dev/null +++ b/public/kirby/i18n/rules/sr.json @@ -0,0 +1,72 @@ +{ + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "ђ": "dj", + "е": "e", + "ж": "z", + "з": "z", + "и": "i", + "ј": "j", + "к": "k", + "л": "l", + "љ": "lj", + "м": "m", + "н": "n", + "њ": "nj", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "ћ": "c", + "у": "u", + "ф": "f", + "х": "h", + "ц": "c", + "ч": "c", + "џ": "dz", + "ш": "s", + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Ђ": "Dj", + "Е": "E", + "Ж": "Z", + "З": "Z", + "И": "I", + "Ј": "J", + "К": "K", + "Л": "L", + "Љ": "Lj", + "М": "M", + "Н": "N", + "Њ": "Nj", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "Ћ": "C", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "C", + "Ч": "C", + "Џ": "Dz", + "Ш": "S", + "š": "s", + "đ": "dj", + "ž": "z", + "ć": "c", + "č": "c", + "Š": "S", + "Đ": "DJ", + "Ž": "Z", + "Ć": "C", + "Č": "C" +} \ No newline at end of file diff --git a/public/kirby/i18n/rules/sv_SE.json b/public/kirby/i18n/rules/sv_SE.json new file mode 100644 index 0000000..a22f3eb --- /dev/null +++ b/public/kirby/i18n/rules/sv_SE.json @@ -0,0 +1,8 @@ +{ + "Ä": "A", + "Å": "a", + "Ö": "O", + "ä": "a", + "å": "a", + "ö": "o" +} diff --git a/public/kirby/i18n/rules/tr.json b/public/kirby/i18n/rules/tr.json new file mode 100644 index 0000000..07fbae5 --- /dev/null +++ b/public/kirby/i18n/rules/tr.json @@ -0,0 +1,14 @@ +{ + "Ç": "C", + "Ğ": "G", + "İ": "I", + "Ş": "S", + "Ö": "O", + "Ü": "U", + "ç": "c", + "ğ": "g", + "ı": "i", + "ş": "s", + "ö": "o", + "ü": "u" +} diff --git a/public/kirby/i18n/rules/uk.json b/public/kirby/i18n/rules/uk.json new file mode 100644 index 0000000..673b7ed --- /dev/null +++ b/public/kirby/i18n/rules/uk.json @@ -0,0 +1,10 @@ +{ + "Ґ": "G", + "І": "I", + "Ї": "Ji", + "Є": "Ye", + "ґ": "g", + "і": "i", + "ї": "ji", + "є": "ye" +} diff --git a/public/kirby/i18n/rules/vi.json b/public/kirby/i18n/rules/vi.json new file mode 100644 index 0000000..fdeff69 --- /dev/null +++ b/public/kirby/i18n/rules/vi.json @@ -0,0 +1,135 @@ +{ + "à": "a", + "ạ": "a", + "á": "a", + "ả": "a", + "ã": "a", + "â": "a", + "ầ": "a", + "ấ": "a", + "ậ": "a", + "ẩ": "a", + "ẫ": "a", + "ă": "a", + "ằ": "a", + "ắ": "a", + "ặ": "a", + "ẳ": "a", + "ẵ": "a", + "è": "e", + "é": "e", + "ẹ": "e", + "ẻ": "e", + "ẽ": "e", + "ê": "e", + "ề": "e", + "ế": "e", + "ệ": "e", + "ể": "e", + "ễ": "e", + "ì": "i", + "í": "i", + "ị": "i", + "ỉ": "i", + "ĩ": "i", + "ò": "o", + "ó": "o", + "ọ": "o", + "ỏ": "o", + "õ": "o", + "ô": "o", + "ồ": "o", + "ố": "o", + "ộ": "o", + "ổ": "o", + "ỗ": "o", + "ơ": "o", + "ờ": "o", + "ớ": "o", + "ợ": "o", + "ở": "o", + "ỡ": "o", + "ù": "u", + "ú": "u", + "ụ": "u", + "ủ": "u", + "ũ": "u", + "ư": "u", + "ừ": "u", + "ứ": "u", + "ự": "u", + "ử": "u", + "ữ": "u", + "y": "y", + "ỳ": "y", + "ý": "y", + "ỵ": "y", + "ỷ": "y", + "ỹ": "y", + "À": "A", + "Á": "A", + "Ạ": "A", + "Ả": "A", + "Ã": "A", + "Â": "A", + "Ầ": "A", + "Ấ": "A", + "Ậ": "A", + "Ẩ": "A", + "Ẫ": "A", + "Ă": "A", + "Ằ": "A", + "Ắ": "A", + "Ặ": "A", + "Ẳ": "A", + "Ẵ": "A", + "È": "E", + "É": "E", + "Ẹ": "E", + "Ẻ": "E", + "Ẽ": "E", + "Ê": "E", + "Ề": "E", + "Ế": "E", + "Ệ": "E", + "Ể": "E", + "Ễ": "E", + "Ì": "I", + "Í": "I", + "Ị": "I", + "Ỉ": "I", + "Ĩ": "I", + "Ò": "O", + "Ó": "O", + "Ọ": "O", + "Ỏ": "O", + "Õ": "O", + "Ô": "O", + "Ồ": "O", + "Ố": "O", + "Ộ": "O", + "Ổ": "O", + "Ỗ": "O", + "Ơ": "O", + "Ờ": "O", + "Ớ": "O", + "Ợ": "O", + "Ở": "O", + "Ỡ": "O", + "Ù": "U", + "Ụ": "U", + "Ủ": "U", + "Ũ": "U", + "Ư": "U", + "Ừ": "U", + "Ứ": "U", + "Ự": "U", + "Ử": "U", + "Ữ": "U", + "Y": "Y", + "Ỳ": "Y", + "Ý": "Y", + "Ỵ": "Y", + "Ỷ": "Y", + "Ỹ": "Y" +} diff --git a/public/kirby/i18n/rules/zh.json b/public/kirby/i18n/rules/zh.json new file mode 100644 index 0000000..21ec594 --- /dev/null +++ b/public/kirby/i18n/rules/zh.json @@ -0,0 +1,6937 @@ +{ + "腌" : "yan", + "嗄" : "a", + "迫" : "po", + "捱" : "ai", + "艾" : "ai", + "瑷" : "ai", + "嗌" : "ai", + "犴" : "an", + "鳌" : "ao", + "廒" : "ao", + "拗" : "niu", + "岙" : "ao", + "鏊" : "ao", + "扒" : "ba", + "岜" : "ba", + "耙" : "pa", + "鲅" : "ba", + "癍" : "ban", + "膀" : "pang", + "磅" : "bang", + "炮" : "pao", + "曝" : "pu", + "刨" : "pao", + "瀑" : "pu", + "陂" : "bei", + "埤" : "pi", + "鹎" : "bei", + "邶" : "bei", + "孛" : "bei", + "鐾" : "bei", + "鞴" : "bei", + "畚" : "ben", + "甏" : "beng", + "舭" : "bi", + "秘" : "mi", + "辟" : "pi", + "泌" : "mi", + "裨" : "bi", + "濞" : "bi", + "庳" : "bi", + "嬖" : "bi", + "畀" : "bi", + "筚" : "bi", + "箅" : "bi", + "襞" : "bi", + "跸" : "bi", + "笾" : "bian", + "扁" : "bian", + "碥" : "bian", + "窆" : "bian", + "便" : "bian", + "弁" : "bian", + "缏" : "bian", + "骠" : "biao", + "杓" : "shao", + "飚" : "biao", + "飑" : "biao", + "瘭" : "biao", + "髟" : "biao", + "玢" : "bin", + "豳" : "bin", + "镔" : "bin", + "膑" : "bin", + "屏" : "ping", + "泊" : "bo", + "逋" : "bu", + "晡" : "bu", + "钸" : "bu", + "醭" : "bu", + "埔" : "pu", + "瓿" : "bu", + "礤" : "ca", + "骖" : "can", + "藏" : "cang", + "艚" : "cao", + "侧" : "ce", + "喳" : "zha", + "刹" : "sha", + "瘥" : "chai", + "禅" : "chan", + "廛" : "chan", + "镡" : "tan", + "澶" : "chan", + "躔" : "chan", + "阊" : "chang", + "鲳" : "chang", + "长" : "chang", + "苌" : "chang", + "氅" : "chang", + "鬯" : "chang", + "焯" : "chao", + "朝" : "chao", + "车" : "che", + "琛" : "chen", + "谶" : "chen", + "榇" : "chen", + "蛏" : "cheng", + "埕" : "cheng", + "枨" : "cheng", + "塍" : "cheng", + "裎" : "cheng", + "螭" : "chi", + "眵" : "chi", + "墀" : "chi", + "篪" : "chi", + "坻" : "di", + "瘛" : "chi", + "种" : "zhong", + "重" : "zhong", + "仇" : "chou", + "帱" : "chou", + "俦" : "chou", + "雠" : "chou", + "臭" : "chou", + "楮" : "chu", + "畜" : "chu", + "嘬" : "zuo", + "膪" : "chuai", + "巛" : "chuan", + "椎" : "zhui", + "呲" : "ci", + "兹" : "zi", + "伺" : "si", + "璁" : "cong", + "楱" : "cou", + "攒" : "zan", + "爨" : "cuan", + "隹" : "zhui", + "榱" : "cui", + "撮" : "cuo", + "鹾" : "cuo", + "嗒" : "da", + "哒" : "da", + "沓" : "ta", + "骀" : "tai", + "绐" : "dai", + "埭" : "dai", + "甙" : "dai", + "弹" : "dan", + "澹" : "dan", + "叨" : "dao", + "纛" : "dao", + "簦" : "deng", + "提" : "ti", + "翟" : "zhai", + "绨" : "ti", + "丶" : "dian", + "佃" : "dian", + "簟" : "dian", + "癜" : "dian", + "调" : "tiao", + "铞" : "diao", + "佚" : "yi", + "堞" : "die", + "瓞" : "die", + "揲" : "die", + "垤" : "die", + "疔" : "ding", + "岽" : "dong", + "硐" : "dong", + "恫" : "dong", + "垌" : "dong", + "峒" : "dong", + "芏" : "du", + "煅" : "duan", + "碓" : "dui", + "镦" : "dui", + "囤" : "tun", + "铎" : "duo", + "缍" : "duo", + "驮" : "tuo", + "沲" : "tuo", + "柁" : "tuo", + "哦" : "o", + "恶" : "e", + "轭" : "e", + "锷" : "e", + "鹗" : "e", + "阏" : "e", + "诶" : "ea", + "鲕" : "er", + "珥" : "er", + "佴" : "er", + "番" : "fan", + "彷" : "pang", + "霏" : "fei", + "蜚" : "fei", + "鲱" : "fei", + "芾" : "fei", + "瀵" : "fen", + "鲼" : "fen", + "否" : "fou", + "趺" : "fu", + "桴" : "fu", + "莩" : "fu", + "菔" : "fu", + "幞" : "fu", + "郛" : "fu", + "绂" : "fu", + "绋" : "fu", + "祓" : "fu", + "砩" : "fu", + "黻" : "fu", + "罘" : "fu", + "蚨" : "fu", + "脯" : "pu", + "滏" : "fu", + "黼" : "fu", + "鲋" : "fu", + "鳆" : "fu", + "咖" : "ka", + "噶" : "ga", + "轧" : "zha", + "陔" : "gai", + "戤" : "gai", + "扛" : "kang", + "戆" : "gang", + "筻" : "gang", + "槔" : "gao", + "藁" : "gao", + "缟" : "gao", + "咯" : "ge", + "仡" : "yi", + "搿" : "ge", + "塥" : "ge", + "鬲" : "ge", + "哿" : "ge", + "句" : "ju", + "缑" : "gou", + "鞲" : "gou", + "笱" : "gou", + "遘" : "gou", + "瞽" : "gu", + "罟" : "gu", + "嘏" : "gu", + "牿" : "gu", + "鲴" : "gu", + "栝" : "kuo", + "莞" : "guan", + "纶" : "lun", + "涫" : "guan", + "涡" : "wo", + "呙" : "guo", + "馘" : "guo", + "猓" : "guo", + "咳" : "ke", + "氦" : "hai", + "颔" : "han", + "吭" : "keng", + "颃" : "hang", + "巷" : "xiang", + "蚵" : "ke", + "翮" : "he", + "吓" : "xia", + "桁" : "heng", + "泓" : "hong", + "蕻" : "hong", + "黉" : "hong", + "後" : "hou", + "唿" : "hu", + "煳" : "hu", + "浒" : "hu", + "祜" : "hu", + "岵" : "hu", + "鬟" : "huan", + "圜" : "huan", + "郇" : "xun", + "锾" : "huan", + "逭" : "huan", + "咴" : "hui", + "虺" : "hui", + "会" : "hui", + "溃" : "kui", + "哕" : "hui", + "缋" : "hui", + "锪" : "huo", + "蠖" : "huo", + "缉" : "ji", + "稽" : "ji", + "赍" : "ji", + "丌" : "ji", + "咭" : "ji", + "亟" : "ji", + "殛" : "ji", + "戢" : "ji", + "嵴" : "ji", + "蕺" : "ji", + "系" : "xi", + "蓟" : "ji", + "霁" : "ji", + "荠" : "qi", + "跽" : "ji", + "哜" : "ji", + "鲚" : "ji", + "洎" : "ji", + "芰" : "ji", + "茄" : "qie", + "珈" : "jia", + "迦" : "jia", + "笳" : "jia", + "葭" : "jia", + "跏" : "jia", + "郏" : "jia", + "恝" : "jia", + "铗" : "jia", + "袷" : "qia", + "蛱" : "jia", + "角" : "jiao", + "挢" : "jiao", + "岬" : "jia", + "徼" : "jiao", + "湫" : "qiu", + "敫" : "jiao", + "瘕" : "jia", + "浅" : "qian", + "蒹" : "jian", + "搛" : "jian", + "湔" : "jian", + "缣" : "jian", + "犍" : "jian", + "鹣" : "jian", + "鲣" : "jian", + "鞯" : "jian", + "蹇" : "jian", + "謇" : "jian", + "硷" : "jian", + "枧" : "jian", + "戬" : "jian", + "谫" : "jian", + "囝" : "jian", + "裥" : "jian", + "笕" : "jian", + "翦" : "jian", + "趼" : "jian", + "楗" : "jian", + "牮" : "jian", + "踺" : "jian", + "茳" : "jiang", + "礓" : "jiang", + "耩" : "jiang", + "降" : "jiang", + "绛" : "jiang", + "洚" : "jiang", + "鲛" : "jiao", + "僬" : "jiao", + "鹪" : "jiao", + "艽" : "jiao", + "茭" : "jiao", + "嚼" : "jiao", + "峤" : "qiao", + "觉" : "jiao", + "校" : "xiao", + "噍" : "jiao", + "醮" : "jiao", + "疖" : "jie", + "喈" : "jie", + "桔" : "ju", + "拮" : "jie", + "桀" : "jie", + "颉" : "jie", + "婕" : "jie", + "羯" : "jie", + "鲒" : "jie", + "蚧" : "jie", + "骱" : "jie", + "衿" : "jin", + "馑" : "jin", + "卺" : "jin", + "廑" : "jin", + "堇" : "jin", + "槿" : "jin", + "靳" : "jin", + "缙" : "jin", + "荩" : "jin", + "赆" : "jin", + "妗" : "jin", + "旌" : "jing", + "腈" : "jing", + "憬" : "jing", + "肼" : "jing", + "迳" : "jing", + "胫" : "jing", + "弪" : "jing", + "獍" : "jing", + "扃" : "jiong", + "鬏" : "jiu", + "疚" : "jiu", + "僦" : "jiu", + "桕" : "jiu", + "疽" : "ju", + "裾" : "ju", + "苴" : "ju", + "椐" : "ju", + "锔" : "ju", + "琚" : "ju", + "鞫" : "ju", + "踽" : "ju", + "榉" : "ju", + "莒" : "ju", + "遽" : "ju", + "倨" : "ju", + "钜" : "ju", + "犋" : "ju", + "屦" : "ju", + "榘" : "ju", + "窭" : "ju", + "讵" : "ju", + "醵" : "ju", + "苣" : "ju", + "圈" : "quan", + "镌" : "juan", + "蠲" : "juan", + "锩" : "juan", + "狷" : "juan", + "桊" : "juan", + "鄄" : "juan", + "獗" : "jue", + "攫" : "jue", + "孓" : "jue", + "橛" : "jue", + "珏" : "jue", + "桷" : "jue", + "劂" : "jue", + "爝" : "jue", + "镢" : "jue", + "觖" : "jue", + "筠" : "jun", + "麇" : "jun", + "捃" : "jun", + "浚" : "jun", + "喀" : "ka", + "卡" : "ka", + "佧" : "ka", + "胩" : "ka", + "锎" : "kai", + "蒈" : "kai", + "剀" : "kai", + "垲" : "kai", + "锴" : "kai", + "戡" : "kan", + "莰" : "kan", + "闶" : "kang", + "钪" : "kang", + "尻" : "kao", + "栲" : "kao", + "柯" : "ke", + "疴" : "ke", + "钶" : "ke", + "颏" : "ke", + "珂" : "ke", + "髁" : "ke", + "壳" : "ke", + "岢" : "ke", + "溘" : "ke", + "骒" : "ke", + "缂" : "ke", + "氪" : "ke", + "锞" : "ke", + "裉" : "ken", + "倥" : "kong", + "崆" : "kong", + "箜" : "kong", + "芤" : "kou", + "眍" : "kou", + "筘" : "kou", + "刳" : "ku", + "堀" : "ku", + "喾" : "ku", + "侉" : "kua", + "蒯" : "kuai", + "哙" : "kuai", + "狯" : "kuai", + "郐" : "kuai", + "匡" : "kuang", + "夼" : "kuang", + "邝" : "kuang", + "圹" : "kuang", + "纩" : "kuang", + "贶" : "kuang", + "岿" : "kui", + "悝" : "kui", + "睽" : "kui", + "逵" : "kui", + "馗" : "kui", + "夔" : "kui", + "喹" : "kui", + "隗" : "wei", + "暌" : "kui", + "揆" : "kui", + "蝰" : "kui", + "跬" : "kui", + "喟" : "kui", + "聩" : "kui", + "篑" : "kui", + "蒉" : "kui", + "愦" : "kui", + "锟" : "kun", + "醌" : "kun", + "琨" : "kun", + "髡" : "kun", + "悃" : "kun", + "阃" : "kun", + "蛞" : "kuo", + "砬" : "la", + "落" : "luo", + "剌" : "la", + "瘌" : "la", + "涞" : "lai", + "崃" : "lai", + "铼" : "lai", + "赉" : "lai", + "濑" : "lai", + "斓" : "lan", + "镧" : "lan", + "谰" : "lan", + "漤" : "lan", + "罱" : "lan", + "稂" : "lang", + "阆" : "lang", + "莨" : "liang", + "蒗" : "lang", + "铹" : "lao", + "痨" : "lao", + "醪" : "lao", + "栳" : "lao", + "铑" : "lao", + "耢" : "lao", + "勒" : "le", + "仂" : "le", + "叻" : "le", + "泐" : "le", + "鳓" : "le", + "了" : "le", + "镭" : "lei", + "嫘" : "lei", + "缧" : "lei", + "檑" : "lei", + "诔" : "lei", + "耒" : "lei", + "酹" : "lei", + "塄" : "leng", + "愣" : "leng", + "藜" : "li", + "骊" : "li", + "黧" : "li", + "缡" : "li", + "嫠" : "li", + "鲡" : "li", + "蓠" : "li", + "澧" : "li", + "锂" : "li", + "醴" : "li", + "鳢" : "li", + "俪" : "li", + "砺" : "li", + "郦" : "li", + "詈" : "li", + "猁" : "li", + "溧" : "li", + "栎" : "li", + "轹" : "li", + "傈" : "li", + "坜" : "li", + "苈" : "li", + "疠" : "li", + "疬" : "li", + "篥" : "li", + "粝" : "li", + "跞" : "li", + "俩" : "liang", + "裢" : "lian", + "濂" : "lian", + "臁" : "lian", + "奁" : "lian", + "蠊" : "lian", + "琏" : "lian", + "蔹" : "lian", + "裣" : "lian", + "楝" : "lian", + "潋" : "lian", + "椋" : "liang", + "墚" : "liang", + "寮" : "liao", + "鹩" : "liao", + "蓼" : "liao", + "钌" : "liao", + "廖" : "liao", + "尥" : "liao", + "洌" : "lie", + "捩" : "lie", + "埒" : "lie", + "躐" : "lie", + "鬣" : "lie", + "辚" : "lin", + "遴" : "lin", + "啉" : "lin", + "瞵" : "lin", + "懔" : "lin", + "廪" : "lin", + "蔺" : "lin", + "膦" : "lin", + "酃" : "ling", + "柃" : "ling", + "鲮" : "ling", + "呤" : "ling", + "镏" : "liu", + "旒" : "liu", + "骝" : "liu", + "鎏" : "liu", + "锍" : "liu", + "碌" : "lu", + "鹨" : "liu", + "茏" : "long", + "栊" : "long", + "泷" : "long", + "砻" : "long", + "癃" : "long", + "垅" : "long", + "偻" : "lou", + "蝼" : "lou", + "蒌" : "lou", + "耧" : "lou", + "嵝" : "lou", + "露" : "lu", + "瘘" : "lou", + "噜" : "lu", + "轳" : "lu", + "垆" : "lu", + "胪" : "lu", + "舻" : "lu", + "栌" : "lu", + "镥" : "lu", + "绿" : "lv", + "辘" : "lu", + "簏" : "lu", + "潞" : "lu", + "辂" : "lu", + "渌" : "lu", + "氇" : "lu", + "捋" : "lv", + "稆" : "lv", + "率" : "lv", + "闾" : "lv", + "栾" : "luan", + "銮" : "luan", + "滦" : "luan", + "娈" : "luan", + "脔" : "luan", + "锊" : "lve", + "猡" : "luo", + "椤" : "luo", + "脶" : "luo", + "镙" : "luo", + "倮" : "luo", + "蠃" : "luo", + "瘰" : "luo", + "珞" : "luo", + "泺" : "luo", + "荦" : "luo", + "雒" : "luo", + "呒" : "mu", + "抹" : "mo", + "唛" : "mai", + "杩" : "ma", + "么" : "me", + "埋" : "mai", + "荬" : "mai", + "脉" : "mai", + "劢" : "mai", + "颟" : "man", + "蔓" : "man", + "鳗" : "man", + "鞔" : "man", + "螨" : "man", + "墁" : "man", + "缦" : "man", + "熳" : "man", + "镘" : "man", + "邙" : "mang", + "硭" : "mang", + "旄" : "mao", + "茆" : "mao", + "峁" : "mao", + "泖" : "mao", + "昴" : "mao", + "耄" : "mao", + "瑁" : "mao", + "懋" : "mao", + "瞀" : "mao", + "麽" : "me", + "没" : "mei", + "嵋" : "mei", + "湄" : "mei", + "猸" : "mei", + "镅" : "mei", + "鹛" : "mei", + "浼" : "mei", + "钔" : "men", + "瞢" : "meng", + "甍" : "meng", + "礞" : "meng", + "艨" : "meng", + "黾" : "mian", + "鳘" : "min", + "溟" : "ming", + "暝" : "ming", + "模" : "mo", + "谟" : "mo", + "嫫" : "mo", + "镆" : "mo", + "瘼" : "mo", + "耱" : "mo", + "貊" : "mo", + "貘" : "mo", + "牟" : "mou", + "鍪" : "mou", + "蛑" : "mou", + "侔" : "mou", + "毪" : "mu", + "坶" : "mu", + "仫" : "mu", + "唔" : "wu", + "那" : "na", + "镎" : "na", + "哪" : "na", + "呢" : "ne", + "肭" : "na", + "艿" : "nai", + "鼐" : "nai", + "萘" : "nai", + "柰" : "nai", + "蝻" : "nan", + "馕" : "nang", + "攮" : "nang", + "曩" : "nang", + "猱" : "nao", + "铙" : "nao", + "硇" : "nao", + "蛲" : "nao", + "垴" : "nao", + "坭" : "ni", + "猊" : "ni", + "铌" : "ni", + "鲵" : "ni", + "祢" : "mi", + "睨" : "ni", + "慝" : "te", + "伲" : "ni", + "鲇" : "nian", + "鲶" : "nian", + "埝" : "nian", + "嬲" : "niao", + "茑" : "niao", + "脲" : "niao", + "啮" : "nie", + "陧" : "nie", + "颞" : "nie", + "臬" : "nie", + "蘖" : "nie", + "甯" : "ning", + "聍" : "ning", + "狃" : "niu", + "侬" : "nong", + "耨" : "nou", + "孥" : "nu", + "胬" : "nu", + "钕" : "nv", + "恧" : "nv", + "褰" : "qian", + "掮" : "qian", + "荨" : "xun", + "钤" : "qian", + "箝" : "qian", + "鬈" : "quan", + "缱" : "qian", + "肷" : "qian", + "纤" : "xian", + "茜" : "qian", + "慊" : "qian", + "椠" : "qian", + "戗" : "qiang", + "镪" : "qiang", + "锖" : "qiang", + "樯" : "qiang", + "嫱" : "qiang", + "雀" : "que", + "缲" : "qiao", + "硗" : "qiao", + "劁" : "qiao", + "樵" : "qiao", + "谯" : "qiao", + "鞒" : "qiao", + "愀" : "qiao", + "鞘" : "qiao", + "郄" : "xi", + "箧" : "qie", + "亲" : "qin", + "覃" : "tan", + "溱" : "qin", + "檎" : "qin", + "锓" : "qin", + "嗪" : "qin", + "螓" : "qin", + "揿" : "qin", + "吣" : "qin", + "圊" : "qing", + "鲭" : "qing", + "檠" : "qing", + "黥" : "qing", + "謦" : "qing", + "苘" : "qing", + "磬" : "qing", + "箐" : "qing", + "綮" : "qi", + "茕" : "qiong", + "邛" : "dao", + "蛩" : "tun", + "筇" : "qiong", + "跫" : "qiong", + "銎" : "qiong", + "楸" : "qiu", + "俅" : "qiu", + "赇" : "qiu", + "逑" : "qiu", + "犰" : "qiu", + "蝤" : "qiu", + "巯" : "qiu", + "鼽" : "qiu", + "糗" : "qiu", + "区" : "qu", + "祛" : "qu", + "麴" : "qu", + "诎" : "qu", + "衢" : "qu", + "癯" : "qu", + "劬" : "qu", + "璩" : "qu", + "氍" : "qu", + "朐" : "qu", + "磲" : "qu", + "鸲" : "qu", + "蕖" : "qu", + "蠼" : "qu", + "蘧" : "qu", + "阒" : "qu", + "颧" : "quan", + "荃" : "quan", + "铨" : "quan", + "辁" : "quan", + "筌" : "quan", + "绻" : "quan", + "畎" : "quan", + "阕" : "que", + "悫" : "que", + "髯" : "ran", + "禳" : "rang", + "穰" : "rang", + "仞" : "ren", + "妊" : "ren", + "轫" : "ren", + "衽" : "ren", + "狨" : "rong", + "肜" : "rong", + "蝾" : "rong", + "嚅" : "ru", + "濡" : "ru", + "薷" : "ru", + "襦" : "ru", + "颥" : "ru", + "洳" : "ru", + "溽" : "ru", + "蓐" : "ru", + "朊" : "ruan", + "蕤" : "rui", + "枘" : "rui", + "箬" : "ruo", + "挲" : "suo", + "脎" : "sa", + "塞" : "sai", + "鳃" : "sai", + "噻" : "sai", + "毵" : "san", + "馓" : "san", + "糁" : "san", + "霰" : "xian", + "磉" : "sang", + "颡" : "sang", + "缫" : "sao", + "鳋" : "sao", + "埽" : "sao", + "瘙" : "sao", + "色" : "se", + "杉" : "shan", + "鲨" : "sha", + "痧" : "sha", + "裟" : "sha", + "铩" : "sha", + "唼" : "sha", + "酾" : "shai", + "栅" : "zha", + "跚" : "shan", + "芟" : "shan", + "埏" : "shan", + "钐" : "shan", + "舢" : "shan", + "剡" : "yan", + "鄯" : "shan", + "疝" : "shan", + "蟮" : "shan", + "墒" : "shang", + "垧" : "shang", + "绱" : "shang", + "蛸" : "shao", + "筲" : "shao", + "苕" : "tiao", + "召" : "zhao", + "劭" : "shao", + "猞" : "she", + "畲" : "she", + "折" : "zhe", + "滠" : "she", + "歙" : "xi", + "厍" : "she", + "莘" : "shen", + "娠" : "shen", + "诜" : "shen", + "什" : "shen", + "谂" : "shen", + "渖" : "shen", + "矧" : "shen", + "胂" : "shen", + "椹" : "shen", + "省" : "sheng", + "眚" : "sheng", + "嵊" : "sheng", + "嘘" : "xu", + "蓍" : "shi", + "鲺" : "shi", + "识" : "shi", + "拾" : "shi", + "埘" : "shi", + "莳" : "shi", + "炻" : "shi", + "鲥" : "shi", + "豕" : "shi", + "似" : "si", + "噬" : "shi", + "贳" : "shi", + "铈" : "shi", + "螫" : "shi", + "筮" : "shi", + "殖" : "zhi", + "熟" : "shu", + "艏" : "shou", + "菽" : "shu", + "摅" : "shu", + "纾" : "shu", + "毹" : "shu", + "疋" : "shu", + "数" : "shu", + "属" : "shu", + "术" : "shu", + "澍" : "shu", + "沭" : "shu", + "丨" : "shu", + "腧" : "shu", + "说" : "shuo", + "妁" : "shuo", + "蒴" : "shuo", + "槊" : "shuo", + "搠" : "shuo", + "鸶" : "si", + "澌" : "si", + "缌" : "si", + "锶" : "si", + "厶" : "si", + "蛳" : "si", + "驷" : "si", + "泗" : "si", + "汜" : "si", + "兕" : "si", + "姒" : "si", + "耜" : "si", + "笥" : "si", + "忪" : "song", + "淞" : "song", + "崧" : "song", + "凇" : "song", + "菘" : "song", + "竦" : "song", + "溲" : "sou", + "飕" : "sou", + "蜩" : "tiao", + "萜" : "tie", + "汀" : "ting", + "葶" : "ting", + "莛" : "ting", + "梃" : "ting", + "佟" : "tong", + "酮" : "tong", + "仝" : "tong", + "茼" : "tong", + "砼" : "tong", + "钭" : "dou", + "酴" : "tu", + "钍" : "tu", + "堍" : "tu", + "抟" : "tuan", + "忒" : "te", + "煺" : "tui", + "暾" : "tun", + "氽" : "tun", + "乇" : "tuo", + "砣" : "tuo", + "沱" : "tuo", + "跎" : "tuo", + "坨" : "tuo", + "橐" : "tuo", + "酡" : "tuo", + "鼍" : "tuo", + "庹" : "tuo", + "拓" : "tuo", + "柝" : "tuo", + "箨" : "tuo", + "腽" : "wa", + "崴" : "wai", + "芄" : "wan", + "畹" : "wan", + "琬" : "wan", + "脘" : "wan", + "菀" : "wan", + "尢" : "you", + "辋" : "wang", + "魍" : "wang", + "逶" : "wei", + "葳" : "wei", + "隈" : "wei", + "惟" : "wei", + "帏" : "wei", + "圩" : "wei", + "囗" : "wei", + "潍" : "wei", + "嵬" : "wei", + "沩" : "wei", + "涠" : "wei", + "尾" : "wei", + "玮" : "wei", + "炜" : "wei", + "韪" : "wei", + "洧" : "wei", + "艉" : "wei", + "鲔" : "wei", + "遗" : "yi", + "尉" : "wei", + "軎" : "wei", + "璺" : "wen", + "阌" : "wen", + "蓊" : "weng", + "蕹" : "weng", + "渥" : "wo", + "硪" : "wo", + "龌" : "wo", + "圬" : "wu", + "吾" : "wu", + "浯" : "wu", + "鼯" : "wu", + "牾" : "wu", + "迕" : "wu", + "庑" : "wu", + "痦" : "wu", + "芴" : "wu", + "杌" : "wu", + "焐" : "wu", + "阢" : "wu", + "婺" : "wu", + "鋈" : "wu", + "樨" : "xi", + "栖" : "qi", + "郗" : "xi", + "蹊" : "qi", + "淅" : "xi", + "熹" : "xi", + "浠" : "xi", + "僖" : "xi", + "穸" : "xi", + "螅" : "xi", + "菥" : "xi", + "舾" : "xi", + "矽" : "xi", + "粞" : "xi", + "硒" : "xi", + "醯" : "xi", + "欷" : "xi", + "鼷" : "xi", + "檄" : "xi", + "隰" : "xi", + "觋" : "xi", + "屣" : "xi", + "葸" : "xi", + "蓰" : "xi", + "铣" : "xi", + "饩" : "xi", + "阋" : "xi", + "禊" : "xi", + "舄" : "xi", + "狎" : "xia", + "硖" : "xia", + "柙" : "xia", + "暹" : "xian", + "莶" : "xian", + "祆" : "xian", + "籼" : "xian", + "跹" : "xian", + "鹇" : "xian", + "痫" : "xian", + "猃" : "xian", + "燹" : "xian", + "蚬" : "xian", + "筅" : "xian", + "冼" : "xian", + "岘" : "xian", + "骧" : "xiang", + "葙" : "xiang", + "芗" : "xiang", + "缃" : "xiang", + "庠" : "xiang", + "鲞" : "xiang", + "蟓" : "xiang", + "削" : "xue", + "枵" : "xiao", + "绡" : "xiao", + "筱" : "xiao", + "邪" : "xie", + "勰" : "xie", + "缬" : "xie", + "血" : "xue", + "榭" : "xie", + "瀣" : "xie", + "薤" : "xie", + "燮" : "xie", + "躞" : "xie", + "廨" : "xie", + "绁" : "xie", + "渫" : "xie", + "榍" : "xie", + "獬" : "xie", + "昕" : "xin", + "忻" : "xin", + "囟" : "xin", + "陉" : "jing", + "荥" : "ying", + "饧" : "tang", + "硎" : "xing", + "荇" : "xing", + "芎" : "xiong", + "馐" : "xiu", + "庥" : "xiu", + "鸺" : "xiu", + "貅" : "xiu", + "髹" : "xiu", + "宿" : "xiu", + "岫" : "xiu", + "溴" : "xiu", + "吁" : "xu", + "盱" : "xu", + "顼" : "xu", + "糈" : "xu", + "醑" : "xu", + "洫" : "xu", + "溆" : "xu", + "蓿" : "xu", + "萱" : "xuan", + "谖" : "xuan", + "儇" : "xuan", + "煊" : "xuan", + "痃" : "xuan", + "铉" : "xuan", + "泫" : "xuan", + "碹" : "xuan", + "楦" : "xuan", + "镟" : "xuan", + "踅" : "xue", + "泶" : "xue", + "鳕" : "xue", + "埙" : "xun", + "曛" : "xun", + "窨" : "xun", + "獯" : "xun", + "峋" : "xun", + "洵" : "xun", + "恂" : "xun", + "浔" : "xun", + "鲟" : "xun", + "蕈" : "xun", + "垭" : "ya", + "岈" : "ya", + "琊" : "ya", + "痖" : "ya", + "迓" : "ya", + "砑" : "ya", + "咽" : "yan", + "鄢" : "yan", + "菸" : "yan", + "崦" : "yan", + "铅" : "qian", + "芫" : "yuan", + "兖" : "yan", + "琰" : "yan", + "罨" : "yan", + "厣" : "yan", + "焱" : "yan", + "酽" : "yan", + "谳" : "yan", + "鞅" : "yang", + "炀" : "yang", + "蛘" : "yang", + "约" : "yue", + "珧" : "yao", + "轺" : "yao", + "繇" : "yao", + "鳐" : "yao", + "崾" : "yao", + "钥" : "yao", + "曜" : "yao", + "铘" : "ye", + "烨" : "ye", + "邺" : "ye", + "靥" : "ye", + "晔" : "ye", + "猗" : "yi", + "铱" : "yi", + "欹" : "qi", + "黟" : "yi", + "怡" : "yi", + "沂" : "yi", + "圯" : "yi", + "荑" : "yi", + "诒" : "yi", + "眙" : "yi", + "嶷" : "yi", + "钇" : "yi", + "舣" : "yi", + "酏" : "yi", + "熠" : "yi", + "弋" : "yi", + "懿" : "yi", + "镒" : "yi", + "峄" : "yi", + "怿" : "yi", + "悒" : "yi", + "佾" : "yi", + "殪" : "yi", + "挹" : "yi", + "埸" : "yi", + "劓" : "yi", + "镱" : "yi", + "瘗" : "yi", + "癔" : "yi", + "翊" : "yi", + "蜴" : "yi", + "氤" : "yin", + "堙" : "yin", + "洇" : "yin", + "鄞" : "yin", + "狺" : "yin", + "夤" : "yin", + "圻" : "qi", + "饮" : "yin", + "吲" : "yin", + "胤" : "yin", + "茚" : "yin", + "璎" : "ying", + "撄" : "ying", + "嬴" : "ying", + "滢" : "ying", + "潆" : "ying", + "蓥" : "ying", + "瘿" : "ying", + "郢" : "ying", + "媵" : "ying", + "邕" : "yong", + "镛" : "yong", + "墉" : "yong", + "慵" : "yong", + "痈" : "yong", + "鳙" : "yong", + "饔" : "yong", + "喁" : "yong", + "俑" : "yong", + "莸" : "you", + "猷" : "you", + "疣" : "you", + "蚰" : "you", + "蝣" : "you", + "莜" : "you", + "牖" : "you", + "铕" : "you", + "卣" : "you", + "宥" : "you", + "侑" : "you", + "蚴" : "you", + "釉" : "you", + "馀" : "yu", + "萸" : "yu", + "禺" : "yu", + "妤" : "yu", + "欤" : "yu", + "觎" : "yu", + "窬" : "yu", + "蝓" : "yu", + "嵛" : "yu", + "舁" : "yu", + "雩" : "yu", + "龉" : "yu", + "伛" : "yu", + "圉" : "yu", + "庾" : "yu", + "瘐" : "yu", + "窳" : "yu", + "俣" : "yu", + "毓" : "yu", + "峪" : "yu", + "煜" : "yu", + "燠" : "yu", + "蓣" : "yu", + "饫" : "yu", + "阈" : "yu", + "鬻" : "yu", + "聿" : "yu", + "钰" : "yu", + "鹆" : "yu", + "蜮" : "yu", + "眢" : "yuan", + "箢" : "yuan", + "员" : "yuan", + "沅" : "yuan", + "橼" : "yuan", + "塬" : "yuan", + "爰" : "yuan", + "螈" : "yuan", + "鼋" : "yuan", + "掾" : "yuan", + "垸" : "yuan", + "瑗" : "yuan", + "刖" : "yue", + "瀹" : "yue", + "樾" : "yue", + "龠" : "yue", + "氲" : "yun", + "昀" : "yun", + "郧" : "yun", + "狁" : "yun", + "郓" : "yun", + "韫" : "yun", + "恽" : "yun", + "扎" : "zha", + "拶" : "za", + "咋" : "za", + "仔" : "zai", + "昝" : "zan", + "瓒" : "zan", + "藏" : "zang", + "奘" : "zang", + "唣" : "zao", + "择" : "ze", + "迮" : "ze", + "赜" : "ze", + "笮" : "ze", + "箦" : "ze", + "舴" : "ze", + "昃" : "ze", + "缯" : "zeng", + "罾" : "zeng", + "齄" : "zha", + "柞" : "zha", + "痄" : "zha", + "瘵" : "zhai", + "旃" : "zhan", + "璋" : "zhang", + "漳" : "zhang", + "嫜" : "zhang", + "鄣" : "zhang", + "仉" : "zhang", + "幛" : "zhang", + "着" : "zhe", + "啁" : "zhou", + "爪" : "zhao", + "棹" : "zhao", + "笊" : "zhao", + "摺" : "zhe", + "磔" : "zhe", + "这" : "zhe", + "柘" : "zhe", + "桢" : "zhen", + "蓁" : "zhen", + "祯" : "zhen", + "浈" : "zhen", + "畛" : "zhen", + "轸" : "zhen", + "稹" : "zhen", + "圳" : "zhen", + "徵" : "zhi", + "钲" : "zheng", + "卮" : "zhi", + "胝" : "zhi", + "祗" : "zhi", + "摭" : "zhi", + "絷" : "zhi", + "埴" : "zhi", + "轵" : "zhi", + "黹" : "zhi", + "帙" : "zhi", + "轾" : "zhi", + "贽" : "zhi", + "陟" : "zhi", + "忮" : "zhi", + "彘" : "zhi", + "膣" : "zhi", + "鸷" : "zhi", + "骘" : "zhi", + "踬" : "zhi", + "郅" : "zhi", + "觯" : "zhi", + "锺" : "zhong", + "螽" : "zhong", + "舯" : "zhong", + "碡" : "zhou", + "绉" : "zhou", + "荮" : "zhou", + "籀" : "zhou", + "酎" : "zhou", + "洙" : "zhu", + "邾" : "zhu", + "潴" : "zhu", + "槠" : "zhu", + "橥" : "zhu", + "舳" : "zhu", + "瘃" : "zhu", + "渚" : "zhu", + "麈" : "zhu", + "箸" : "zhu", + "炷" : "zhu", + "杼" : "zhu", + "翥" : "zhu", + "疰" : "zhu", + "颛" : "zhuan", + "赚" : "zhuan", + "馔" : "zhuan", + "僮" : "tong", + "缒" : "zhui", + "肫" : "zhun", + "窀" : "zhun", + "涿" : "zhuo", + "倬" : "zhuo", + "濯" : "zhuo", + "诼" : "zhuo", + "禚" : "zhuo", + "浞" : "zhuo", + "谘" : "zi", + "淄" : "zi", + "髭" : "zi", + "孳" : "zi", + "粢" : "zi", + "趑" : "zi", + "觜" : "zui", + "缁" : "zi", + "鲻" : "zi", + "嵫" : "zi", + "笫" : "zi", + "耔" : "zi", + "腙" : "zong", + "偬" : "zong", + "诹" : "zou", + "陬" : "zou", + "鄹" : "zou", + "驺" : "zou", + "鲰" : "zou", + "菹" : "ju", + "镞" : "zu", + "躜" : "zuan", + "缵" : "zuan", + "蕞" : "zui", + "撙" : "zun", + "胙" : "zuo", + "阿" : "a", + "阿" : "e", + "柏" : "bai", + "蚌" : "beng", + "薄" : "bo", + "堡" : "bao", + "呗" : "bei", + "贲" : "ben", + "臂" : "bi", + "瘪" : "bie", + "槟" : "bin", + "剥" : "bo", + "伯" : "bo", + "卜" : "bu", + "参" : "can", + "嚓" : "ca", + "差" : "cha", + "孱" : "chan", + "绰" : "chuo", + "称" : "cheng", + "澄" : "cheng", + "大" : "da", + "单" : "dan", + "得" : "de", + "的" : "de", + "地" : "di", + "都" : "dou", + "读" : "du", + "度" : "du", + "蹲" : "dun", + "佛" : "fo", + "伽" : "jia", + "盖" : "gai", + "镐" : "hao", + "给" : "gei", + "呱" : "gua", + "氿" : "jiu", + "桧" : "hui", + "掴" : "guo", + "蛤" : "ha", + "还" : "hai", + "和" : "he", + "核" : "he", + "哼" : "heng", + "鹄" : "hu", + "划" : "hua", + "夹" : "jia", + "贾" : "jia", + "芥" : "jie", + "劲" : "jin", + "荆" : "jing", + "颈" : "jing", + "貉" : "he", + "吖" : "a", + "啊" : "a", + "锕" : "a", + "哎" : "ai", + "哀" : "ai", + "埃" : "ai", + "唉" : "ai", + "欸" : "ai", + "锿" : "ai", + "挨" : "ai", + "皑" : "ai", + "癌" : "ai", + "毐" : "ai", + "矮" : "ai", + "蔼" : "ai", + "霭" : "ai", + "砹" : "ai", + "爱" : "ai", + "隘" : "ai", + "碍" : "ai", + "嗳" : "ai", + "嫒" : "ai", + "叆" : "ai", + "暧" : "ai", + "安" : "an", + "桉" : "an", + "氨" : "an", + "庵" : "an", + "谙" : "an", + "鹌" : "an", + "鞍" : "an", + "俺" : "an", + "埯" : "an", + "唵" : "an", + "铵" : "an", + "揞" : "an", + "岸" : "an", + "按" : "an", + "胺" : "an", + "案" : "an", + "暗" : "an", + "黯" : "an", + "玵" : "an", + "肮" : "ang", + "昂" : "ang", + "盎" : "ang", + "凹" : "ao", + "敖" : "ao", + "遨" : "ao", + "嗷" : "ao", + "獒" : "ao", + "熬" : "ao", + "聱" : "ao", + "螯" : "ao", + "翱" : "ao", + "謷" : "ao", + "鏖" : "ao", + "袄" : "ao", + "媪" : "ao", + "坳" : "ao", + "傲" : "ao", + "奥" : "ao", + "骜" : "ao", + "澳" : "ao", + "懊" : "ao", + "八" : "ba", + "巴" : "ba", + "叭" : "ba", + "芭" : "ba", + "疤" : "ba", + "捌" : "ba", + "笆" : "ba", + "粑" : "ba", + "拔" : "ba", + "茇" : "ba", + "妭" : "ba", + "菝" : "ba", + "跋" : "ba", + "魃" : "ba", + "把" : "ba", + "靶" : "ba", + "坝" : "ba", + "爸" : "ba", + "罢" : "ba", + "霸" : "ba", + "灞" : "ba", + "吧" : "ba", + "钯" : "ba", + "掰" : "bai", + "白" : "bai", + "百" : "bai", + "佰" : "bai", + "捭" : "bai", + "摆" : "bai", + "败" : "bai", + "拜" : "bai", + "稗" : "bai", + "扳" : "ban", + "攽" : "ban", + "班" : "ban", + "般" : "ban", + "颁" : "ban", + "斑" : "ban", + "搬" : "ban", + "瘢" : "ban", + "阪" : "ban", + "坂" : "ban", + "板" : "ban", + "版" : "ban", + "钣" : "ban", + "舨" : "ban", + "办" : "ban", + "半" : "ban", + "伴" : "ban", + "拌" : "ban", + "绊" : "ban", + "瓣" : "ban", + "扮" : "ban", + "邦" : "bang", + "帮" : "bang", + "梆" : "bang", + "浜" : "bang", + "绑" : "bang", + "榜" : "bang", + "棒" : "bang", + "傍" : "bang", + "谤" : "bang", + "蒡" : "bang", + "镑" : "bang", + "包" : "bao", + "苞" : "bao", + "孢" : "bao", + "胞" : "bao", + "龅" : "bao", + "煲" : "bao", + "褒" : "bao", + "雹" : "bao", + "饱" : "bao", + "宝" : "bao", + "保" : "bao", + "鸨" : "bao", + "葆" : "bao", + "褓" : "bao", + "报" : "bao", + "抱" : "bao", + "趵" : "bao", + "豹" : "bao", + "鲍" : "bao", + "暴" : "bao", + "爆" : "bao", + "枹" : "bao", + "杯" : "bei", + "卑" : "bei", + "悲" : "bei", + "碑" : "bei", + "北" : "bei", + "贝" : "bei", + "狈" : "bei", + "备" : "bei", + "背" : "bei", + "钡" : "bei", + "倍" : "bei", + "悖" : "bei", + "被" : "bei", + "辈" : "bei", + "惫" : "bei", + "焙" : "bei", + "蓓" : "bei", + "碚" : "bei", + "褙" : "bei", + "别" : "bei", + "蹩" : "bei", + "椑" : "bei", + "奔" : "ben", + "倴" : "ben", + "犇" : "ben", + "锛" : "ben", + "本" : "ben", + "苯" : "ben", + "坌" : "ben", + "笨" : "ben", + "崩" : "beng", + "绷" : "beng", + "嘣" : "beng", + "甭" : "beng", + "泵" : "beng", + "迸" : "beng", + "镚" : "beng", + "蹦" : "beng", + "屄" : "bi", + "逼" : "bi", + "荸" : "bi", + "鼻" : "bi", + "匕" : "bi", + "比" : "bi", + "吡" : "bi", + "沘" : "bi", + "妣" : "bi", + "彼" : "bi", + "秕" : "bi", + "笔" : "bi", + "俾" : "bi", + "鄙" : "bi", + "币" : "bi", + "必" : "bi", + "毕" : "bi", + "闭" : "bi", + "庇" : "bi", + "诐" : "bi", + "苾" : "bi", + "荜" : "bi", + "毖" : "bi", + "哔" : "bi", + "陛" : "bi", + "毙" : "bi", + "铋" : "bi", + "狴" : "bi", + "萆" : "bi", + "梐" : "bi", + "敝" : "bi", + "婢" : "bi", + "赑" : "bi", + "愎" : "bi", + "弼" : "bi", + "蓖" : "bi", + "痹" : "bi", + "滗" : "bi", + "碧" : "bi", + "蔽" : "bi", + "馝" : "bi", + "弊" : "bi", + "薜" : "bi", + "篦" : "bi", + "壁" : "bi", + "避" : "bi", + "髀" : "bi", + "璧" : "bi", + "芘" : "bi", + "边" : "bian", + "砭" : "bian", + "萹" : "bian", + "编" : "bian", + "煸" : "bian", + "蝙" : "bian", + "鳊" : "bian", + "鞭" : "bian", + "贬" : "bian", + "匾" : "bian", + "褊" : "bian", + "藊" : "bian", + "卞" : "bian", + "抃" : "bian", + "苄" : "bian", + "汴" : "bian", + "忭" : "bian", + "变" : "bian", + "遍" : "bian", + "辨" : "bian", + "辩" : "bian", + "辫" : "bian", + "标" : "biao", + "骉" : "biao", + "彪" : "biao", + "摽" : "biao", + "膘" : "biao", + "飙" : "biao", + "镖" : "biao", + "瀌" : "biao", + "镳" : "biao", + "表" : "biao", + "婊" : "biao", + "裱" : "biao", + "鳔" : "biao", + "憋" : "bie", + "鳖" : "bie", + "宾" : "bin", + "彬" : "bin", + "傧" : "bin", + "滨" : "bin", + "缤" : "bin", + "濒" : "bin", + "摈" : "bin", + "殡" : "bin", + "髌" : "bin", + "鬓" : "bin", + "冰" : "bing", + "兵" : "bing", + "丙" : "bing", + "邴" : "bing", + "秉" : "bing", + "柄" : "bing", + "饼" : "bing", + "炳" : "bing", + "禀" : "bing", + "并" : "bing", + "病" : "bing", + "摒" : "bing", + "拨" : "bo", + "波" : "bo", + "玻" : "bo", + "钵" : "bo", + "饽" : "bo", + "袯" : "bo", + "菠" : "bo", + "播" : "bo", + "驳" : "bo", + "帛" : "bo", + "勃" : "bo", + "钹" : "bo", + "铂" : "bo", + "亳" : "bo", + "舶" : "bo", + "脖" : "bo", + "博" : "bo", + "鹁" : "bo", + "渤" : "bo", + "搏" : "bo", + "馎" : "bo", + "箔" : "bo", + "膊" : "bo", + "踣" : "bo", + "馞" : "bo", + "礴" : "bo", + "跛" : "bo", + "檗" : "bo", + "擘" : "bo", + "簸" : "bo", + "啵" : "bo", + "蕃" : "bo", + "哱" : "bo", + "卟" : "bu", + "补" : "bu", + "捕" : "bu", + "哺" : "bu", + "不" : "bu", + "布" : "bu", + "步" : "bu", + "怖" : "bu", + "钚" : "bu", + "部" : "bu", + "埠" : "bu", + "簿" : "bu", + "擦" : "ca", + "猜" : "cai", + "才" : "cai", + "材" : "cai", + "财" : "cai", + "裁" : "cai", + "采" : "cai", + "彩" : "cai", + "睬" : "cai", + "踩" : "cai", + "菜" : "cai", + "蔡" : "cai", + "餐" : "can", + "残" : "can", + "蚕" : "can", + "惭" : "can", + "惨" : "can", + "黪" : "can", + "灿" : "can", + "粲" : "can", + "璨" : "can", + "穇" : "can", + "仓" : "cang", + "伧" : "cang", + "苍" : "cang", + "沧" : "cang", + "舱" : "cang", + "操" : "cao", + "糙" : "cao", + "曹" : "cao", + "嘈" : "cao", + "漕" : "cao", + "槽" : "cao", + "螬" : "cao", + "草" : "cao", + "册" : "ce", + "厕" : "ce", + "测" : "ce", + "恻" : "ce", + "策" : "ce", + "岑" : "cen", + "涔" : "cen", + "噌" : "ceng", + "层" : "ceng", + "嶒" : "ceng", + "蹭" : "ceng", + "叉" : "cha", + "杈" : "cha", + "插" : "cha", + "馇" : "cha", + "锸" : "cha", + "茬" : "cha", + "茶" : "cha", + "搽" : "cha", + "嵖" : "cha", + "猹" : "cha", + "槎" : "cha", + "碴" : "cha", + "察" : "cha", + "檫" : "cha", + "衩" : "cha", + "镲" : "cha", + "汊" : "cha", + "岔" : "cha", + "侘" : "cha", + "诧" : "cha", + "姹" : "cha", + "蹅" : "cha", + "拆" : "chai", + "钗" : "chai", + "侪" : "chai", + "柴" : "chai", + "豺" : "chai", + "虿" : "chai", + "茝" : "chai", + "觇" : "chan", + "掺" : "chan", + "搀" : "chan", + "襜" : "chan", + "谗" : "chan", + "婵" : "chan", + "馋" : "chan", + "缠" : "chan", + "蝉" : "chan", + "潺" : "chan", + "蟾" : "chan", + "巉" : "chan", + "产" : "chan", + "浐" : "chan", + "谄" : "chan", + "铲" : "chan", + "阐" : "chan", + "蒇" : "chan", + "骣" : "chan", + "冁" : "chan", + "忏" : "chan", + "颤" : "chan", + "羼" : "chan", + "韂" : "chan", + "伥" : "chang", + "昌" : "chang", + "菖" : "chang", + "猖" : "chang", + "娼" : "chang", + "肠" : "chang", + "尝" : "chang", + "常" : "chang", + "偿" : "chang", + "徜" : "chang", + "嫦" : "chang", + "厂" : "chang", + "场" : "chang", + "昶" : "chang", + "惝" : "chang", + "敞" : "chang", + "怅" : "chang", + "畅" : "chang", + "倡" : "chang", + "唱" : "chang", + "裳" : "chang", + "抄" : "chao", + "怊" : "chao", + "钞" : "chao", + "超" : "chao", + "晁" : "chao", + "巢" : "chao", + "嘲" : "chao", + "潮" : "chao", + "吵" : "chao", + "炒" : "chao", + "耖" : "chao", + "砗" : "che", + "扯" : "che", + "彻" : "che", + "坼" : "che", + "掣" : "che", + "撤" : "che", + "澈" : "che", + "瞮" : "che", + "抻" : "chen", + "郴" : "chen", + "嗔" : "chen", + "瞋" : "chen", + "臣" : "chen", + "尘" : "chen", + "辰" : "chen", + "沉" : "chen", + "忱" : "chen", + "陈" : "chen", + "宸" : "chen", + "晨" : "chen", + "谌" : "chen", + "碜" : "chen", + "衬" : "chen", + "龀" : "chen", + "趁" : "chen", + "柽" : "cheng", + "琤" : "cheng", + "撑" : "cheng", + "瞠" : "cheng", + "成" : "cheng", + "丞" : "cheng", + "呈" : "cheng", + "诚" : "cheng", + "承" : "cheng", + "城" : "cheng", + "铖" : "cheng", + "程" : "cheng", + "惩" : "cheng", + "酲" : "cheng", + "橙" : "cheng", + "逞" : "cheng", + "骋" : "cheng", + "秤" : "cheng", + "铛" : "cheng", + "樘" : "cheng", + "吃" : "chi", + "哧" : "chi", + "鸱" : "chi", + "蚩" : "chi", + "笞" : "chi", + "嗤" : "chi", + "痴" : "chi", + "媸" : "chi", + "魑" : "chi", + "池" : "chi", + "弛" : "chi", + "驰" : "chi", + "迟" : "chi", + "茌" : "chi", + "持" : "chi", + "踟" : "chi", + "尺" : "chi", + "齿" : "chi", + "侈" : "chi", + "耻" : "chi", + "豉" : "chi", + "褫" : "chi", + "彳" : "chi", + "叱" : "chi", + "斥" : "chi", + "赤" : "chi", + "饬" : "chi", + "炽" : "chi", + "翅" : "chi", + "敕" : "chi", + "啻" : "chi", + "傺" : "chi", + "匙" : "chi", + "冲" : "chong", + "充" : "chong", + "忡" : "chong", + "茺" : "chong", + "舂" : "chong", + "憧" : "chong", + "艟" : "chong", + "虫" : "chong", + "崇" : "chong", + "宠" : "chong", + "铳" : "chong", + "抽" : "chou", + "瘳" : "chou", + "惆" : "chou", + "绸" : "chou", + "畴" : "chou", + "酬" : "chou", + "稠" : "chou", + "愁" : "chou", + "筹" : "chou", + "踌" : "chou", + "丑" : "chou", + "瞅" : "chou", + "出" : "chu", + "初" : "chu", + "樗" : "chu", + "刍" : "chu", + "除" : "chu", + "厨" : "chu", + "锄" : "chu", + "滁" : "chu", + "蜍" : "chu", + "雏" : "chu", + "橱" : "chu", + "躇" : "chu", + "蹰" : "chu", + "杵" : "chu", + "础" : "chu", + "储" : "chu", + "楚" : "chu", + "褚" : "chu", + "亍" : "chu", + "处" : "chu", + "怵" : "chu", + "绌" : "chu", + "搐" : "chu", + "触" : "chu", + "憷" : "chu", + "黜" : "chu", + "矗" : "chu", + "揣" : "chuai", + "搋" : "chuai", + "膗" : "chuai", + "踹" : "chuai", + "川" : "chuan", + "氚" : "chuan", + "穿" : "chuan", + "舡" : "chuan", + "船" : "chuan", + "遄" : "chuan", + "椽" : "chuan", + "舛" : "chuan", + "喘" : "chuan", + "串" : "chuan", + "钏" : "chuan", + "疮" : "chuang", + "窗" : "chuang", + "床" : "chuang", + "闯" : "chuang", + "创" : "chuang", + "怆" : "chuang", + "吹" : "chui", + "炊" : "chui", + "垂" : "chui", + "陲" : "chui", + "捶" : "chui", + "棰" : "chui", + "槌" : "chui", + "锤" : "chui", + "春" : "chun", + "瑃" : "chun", + "椿" : "chun", + "蝽" : "chun", + "纯" : "chun", + "莼" : "chun", + "唇" : "chun", + "淳" : "chun", + "鹑" : "chun", + "醇" : "chun", + "蠢" : "chun", + "踔" : "chuo", + "戳" : "chuo", + "啜" : "chuo", + "惙" : "chuo", + "辍" : "chuo", + "龊" : "chuo", + "歠" : "chuo", + "疵" : "ci", + "词" : "ci", + "茈" : "ci", + "茨" : "ci", + "祠" : "ci", + "瓷" : "ci", + "辞" : "ci", + "慈" : "ci", + "磁" : "ci", + "雌" : "ci", + "鹚" : "ci", + "糍" : "ci", + "此" : "ci", + "泚" : "ci", + "跐" : "ci", + "次" : "ci", + "刺" : "ci", + "佽" : "ci", + "赐" : "ci", + "匆" : "cong", + "苁" : "cong", + "囱" : "cong", + "枞" : "cong", + "葱" : "cong", + "骢" : "cong", + "聪" : "cong", + "从" : "cong", + "丛" : "cong", + "淙" : "cong", + "悰" : "cong", + "琮" : "cong", + "凑" : "cou", + "辏" : "cou", + "腠" : "cou", + "粗" : "cu", + "徂" : "cu", + "殂" : "cu", + "促" : "cu", + "猝" : "cu", + "蔟" : "cu", + "醋" : "cu", + "踧" : "cu", + "簇" : "cu", + "蹙" : "cu", + "蹴" : "cu", + "汆" : "cuan", + "撺" : "cuan", + "镩" : "cuan", + "蹿" : "cuan", + "窜" : "cuan", + "篡" : "cuan", + "崔" : "cui", + "催" : "cui", + "摧" : "cui", + "璀" : "cui", + "脆" : "cui", + "萃" : "cui", + "啐" : "cui", + "淬" : "cui", + "悴" : "cui", + "毳" : "cui", + "瘁" : "cui", + "粹" : "cui", + "翠" : "cui", + "村" : "cun", + "皴" : "cun", + "存" : "cun", + "忖" : "cun", + "寸" : "cun", + "吋" : "cun", + "搓" : "cuo", + "磋" : "cuo", + "蹉" : "cuo", + "嵯" : "cuo", + "矬" : "cuo", + "痤" : "cuo", + "脞" : "cuo", + "挫" : "cuo", + "莝" : "cuo", + "厝" : "cuo", + "措" : "cuo", + "锉" : "cuo", + "错" : "cuo", + "酇" : "cuo", + "咑" : "da", + "垯" : "da", + "耷" : "da", + "搭" : "da", + "褡" : "da", + "达" : "da", + "怛" : "da", + "妲" : "da", + "荙" : "da", + "笪" : "da", + "答" : "da", + "跶" : "da", + "靼" : "da", + "瘩" : "da", + "鞑" : "da", + "打" : "da", + "呆" : "dai", + "歹" : "dai", + "逮" : "dai", + "傣" : "dai", + "代" : "dai", + "岱" : "dai", + "迨" : "dai", + "玳" : "dai", + "带" : "dai", + "殆" : "dai", + "贷" : "dai", + "待" : "dai", + "怠" : "dai", + "袋" : "dai", + "叇" : "dai", + "戴" : "dai", + "黛" : "dai", + "襶" : "dai", + "呔" : "dai", + "丹" : "dan", + "担" : "dan", + "眈" : "dan", + "耽" : "dan", + "郸" : "dan", + "聃" : "dan", + "殚" : "dan", + "瘅" : "dan", + "箪" : "dan", + "儋" : "dan", + "胆" : "dan", + "疸" : "dan", + "掸" : "dan", + "亶" : "dan", + "旦" : "dan", + "但" : "dan", + "诞" : "dan", + "萏" : "dan", + "啖" : "dan", + "淡" : "dan", + "惮" : "dan", + "蛋" : "dan", + "氮" : "dan", + "赕" : "dan", + "当" : "dang", + "裆" : "dang", + "挡" : "dang", + "档" : "dang", + "党" : "dang", + "谠" : "dang", + "凼" : "dang", + "砀" : "dang", + "宕" : "dang", + "荡" : "dang", + "菪" : "dang", + "刀" : "dao", + "忉" : "dao", + "氘" : "dao", + "舠" : "dao", + "导" : "dao", + "岛" : "dao", + "捣" : "dao", + "倒" : "dao", + "捯" : "dao", + "祷" : "dao", + "蹈" : "dao", + "到" : "dao", + "盗" : "dao", + "悼" : "dao", + "道" : "dao", + "稻" : "dao", + "焘" : "dao", + "锝" : "de", + "嘚" : "de", + "德" : "de", + "扽" : "den", + "灯" : "deng", + "登" : "deng", + "噔" : "deng", + "蹬" : "deng", + "等" : "deng", + "戥" : "deng", + "邓" : "deng", + "僜" : "deng", + "凳" : "deng", + "嶝" : "deng", + "磴" : "deng", + "瞪" : "deng", + "镫" : "deng", + "低" : "di", + "羝" : "di", + "堤" : "di", + "嘀" : "di", + "滴" : "di", + "狄" : "di", + "迪" : "di", + "籴" : "di", + "荻" : "di", + "敌" : "di", + "涤" : "di", + "笛" : "di", + "觌" : "di", + "嫡" : "di", + "镝" : "di", + "氐" : "di", + "邸" : "di", + "诋" : "di", + "抵" : "di", + "底" : "di", + "柢" : "di", + "砥" : "di", + "骶" : "di", + "玓" : "di", + "弟" : "di", + "帝" : "di", + "递" : "di", + "娣" : "di", + "第" : "di", + "谛" : "di", + "蒂" : "di", + "棣" : "di", + "睇" : "di", + "缔" : "di", + "碲" : "di", + "嗲" : "dia", + "掂" : "dian", + "滇" : "dian", + "颠" : "dian", + "巅" : "dian", + "癫" : "dian", + "典" : "dian", + "点" : "dian", + "碘" : "dian", + "踮" : "dian", + "电" : "dian", + "甸" : "dian", + "阽" : "dian", + "坫" : "dian", + "店" : "dian", + "玷" : "dian", + "垫" : "dian", + "钿" : "dian", + "淀" : "dian", + "惦" : "dian", + "奠" : "dian", + "殿" : "dian", + "靛" : "dian", + "刁" : "diao", + "叼" : "diao", + "汈" : "diao", + "凋" : "diao", + "貂" : "diao", + "碉" : "diao", + "雕" : "diao", + "鲷" : "diao", + "屌" : "diao", + "吊" : "diao", + "钓" : "diao", + "窎" : "diao", + "掉" : "diao", + "铫" : "diao", + "爹" : "die", + "跌" : "die", + "迭" : "die", + "谍" : "die", + "耋" : "die", + "喋" : "die", + "牒" : "die", + "叠" : "die", + "碟" : "die", + "嵽" : "die", + "蝶" : "die", + "蹀" : "die", + "鲽" : "die", + "仃" : "ding", + "叮" : "ding", + "玎" : "ding", + "盯" : "ding", + "町" : "ding", + "耵" : "ding", + "顶" : "ding", + "酊" : "ding", + "鼎" : "ding", + "订" : "ding", + "钉" : "ding", + "定" : "ding", + "啶" : "ding", + "腚" : "ding", + "碇" : "ding", + "锭" : "ding", + "丢" : "diu", + "铥" : "diu", + "东" : "dong", + "冬" : "dong", + "咚" : "dong", + "氡" : "dong", + "鸫" : "dong", + "董" : "dong", + "懂" : "dong", + "动" : "dong", + "冻" : "dong", + "侗" : "dong", + "栋" : "dong", + "胨" : "dong", + "洞" : "dong", + "胴" : "dong", + "兜" : "dou", + "蔸" : "dou", + "篼" : "dou", + "抖" : "dou", + "陡" : "dou", + "蚪" : "dou", + "斗" : "dou", + "豆" : "dou", + "逗" : "dou", + "痘" : "dou", + "窦" : "dou", + "督" : "du", + "嘟" : "du", + "毒" : "du", + "独" : "du", + "渎" : "du", + "椟" : "du", + "犊" : "du", + "牍" : "du", + "黩" : "du", + "髑" : "du", + "厾" : "du", + "笃" : "du", + "堵" : "du", + "赌" : "du", + "睹" : "du", + "杜" : "du", + "肚" : "du", + "妒" : "du", + "渡" : "du", + "镀" : "du", + "蠹" : "du", + "端" : "duan", + "短" : "duan", + "段" : "duan", + "断" : "duan", + "缎" : "duan", + "椴" : "duan", + "锻" : "duan", + "簖" : "duan", + "堆" : "dui", + "队" : "dui", + "对" : "dui", + "兑" : "dui", + "怼" : "dui", + "憝" : "dui", + "吨" : "dun", + "惇" : "dun", + "敦" : "dun", + "墩" : "dun", + "礅" : "dun", + "盹" : "dun", + "趸" : "dun", + "沌" : "dun", + "炖" : "dun", + "砘" : "dun", + "钝" : "dun", + "盾" : "dun", + "顿" : "dun", + "遁" : "dun", + "多" : "duo", + "咄" : "duo", + "哆" : "duo", + "掇" : "duo", + "裰" : "duo", + "夺" : "duo", + "踱" : "duo", + "朵" : "duo", + "垛" : "duo", + "哚" : "duo", + "躲" : "duo", + "亸" : "duo", + "剁" : "duo", + "舵" : "duo", + "堕" : "duo", + "惰" : "duo", + "跺" : "duo", + "屙" : "e", + "婀" : "e", + "讹" : "e", + "囮" : "e", + "俄" : "e", + "莪" : "e", + "峨" : "e", + "娥" : "e", + "锇" : "e", + "鹅" : "e", + "蛾" : "e", + "额" : "e", + "厄" : "e", + "扼" : "e", + "苊" : "e", + "呃" : "e", + "垩" : "e", + "饿" : "e", + "鄂" : "e", + "谔" : "e", + "萼" : "e", + "遏" : "e", + "愕" : "e", + "腭" : "e", + "颚" : "e", + "噩" : "e", + "鳄" : "e", + "恩" : "en", + "蒽" : "en", + "摁" : "en", + "鞥" : "eng", + "儿" : "er", + "而" : "er", + "鸸" : "er", + "尔" : "er", + "耳" : "er", + "迩" : "er", + "饵" : "er", + "洱" : "er", + "铒" : "er", + "二" : "er", + "贰" : "er", + "发" : "fa", + "乏" : "fa", + "伐" : "fa", + "罚" : "fa", + "垡" : "fa", + "阀" : "fa", + "筏" : "fa", + "法" : "fa", + "砝" : "fa", + "珐" : "fa", + "帆" : "fan", + "幡" : "fan", + "藩" : "fan", + "翻" : "fan", + "凡" : "fan", + "矾" : "fan", + "钒" : "fan", + "烦" : "fan", + "樊" : "fan", + "燔" : "fan", + "繁" : "fan", + "蹯" : "fan", + "蘩" : "fan", + "反" : "fan", + "返" : "fan", + "犯" : "fan", + "饭" : "fan", + "泛" : "fan", + "范" : "fan", + "贩" : "fan", + "畈" : "fan", + "梵" : "fan", + "方" : "fang", + "邡" : "fang", + "坊" : "fang", + "芳" : "fang", + "枋" : "fang", + "钫" : "fang", + "防" : "fang", + "妨" : "fang", + "肪" : "fang", + "房" : "fang", + "鲂" : "fang", + "仿" : "fang", + "访" : "fang", + "纺" : "fang", + "舫" : "fang", + "放" : "fang", + "飞" : "fei", + "妃" : "fei", + "非" : "fei", + "菲" : "fei", + "啡" : "fei", + "绯" : "fei", + "扉" : "fei", + "肥" : "fei", + "淝" : "fei", + "腓" : "fei", + "匪" : "fei", + "诽" : "fei", + "悱" : "fei", + "棐" : "fei", + "斐" : "fei", + "榧" : "fei", + "翡" : "fei", + "篚" : "fei", + "吠" : "fei", + "肺" : "fei", + "狒" : "fei", + "废" : "fei", + "沸" : "fei", + "费" : "fei", + "痱" : "fei", + "镄" : "fei", + "分" : "fen", + "芬" : "fen", + "吩" : "fen", + "纷" : "fen", + "氛" : "fen", + "酚" : "fen", + "坟" : "fen", + "汾" : "fen", + "棼" : "fen", + "焚" : "fen", + "鼢" : "fen", + "粉" : "fen", + "份" : "fen", + "奋" : "fen", + "忿" : "fen", + "偾" : "fen", + "粪" : "fen", + "愤" : "fen", + "丰" : "feng", + "风" : "feng", + "沣" : "feng", + "枫" : "feng", + "封" : "feng", + "砜" : "feng", + "疯" : "feng", + "峰" : "feng", + "烽" : "feng", + "葑" : "feng", + "锋" : "feng", + "蜂" : "feng", + "酆" : "feng", + "冯" : "feng", + "逢" : "feng", + "缝" : "feng", + "讽" : "feng", + "唪" : "feng", + "凤" : "feng", + "奉" : "feng", + "俸" : "feng", + "缶" : "fou", + "夫" : "fu", + "呋" : "fu", + "肤" : "fu", + "麸" : "fu", + "跗" : "fu", + "稃" : "fu", + "孵" : "fu", + "敷" : "fu", + "弗" : "fu", + "伏" : "fu", + "凫" : "fu", + "扶" : "fu", + "芙" : "fu", + "孚" : "fu", + "拂" : "fu", + "苻" : "fu", + "服" : "fu", + "怫" : "fu", + "茯" : "fu", + "氟" : "fu", + "俘" : "fu", + "浮" : "fu", + "符" : "fu", + "匐" : "fu", + "涪" : "fu", + "艴" : "fu", + "幅" : "fu", + "辐" : "fu", + "蜉" : "fu", + "福" : "fu", + "蝠" : "fu", + "抚" : "fu", + "甫" : "fu", + "拊" : "fu", + "斧" : "fu", + "府" : "fu", + "俯" : "fu", + "釜" : "fu", + "辅" : "fu", + "腑" : "fu", + "腐" : "fu", + "父" : "fu", + "讣" : "fu", + "付" : "fu", + "负" : "fu", + "妇" : "fu", + "附" : "fu", + "咐" : "fu", + "阜" : "fu", + "驸" : "fu", + "赴" : "fu", + "复" : "fu", + "副" : "fu", + "赋" : "fu", + "傅" : "fu", + "富" : "fu", + "腹" : "fu", + "缚" : "fu", + "赙" : "fu", + "蝮" : "fu", + "覆" : "fu", + "馥" : "fu", + "袱" : "fu", + "旮" : "ga", + "嘎" : "ga", + "钆" : "ga", + "尜" : "ga", + "尕" : "ga", + "尬" : "ga", + "该" : "gai", + "垓" : "gai", + "荄" : "gai", + "赅" : "gai", + "改" : "gai", + "丐" : "gai", + "钙" : "gai", + "溉" : "gai", + "概" : "gai", + "甘" : "gan", + "玕" : "gan", + "肝" : "gan", + "坩" : "gan", + "苷" : "gan", + "矸" : "gan", + "泔" : "gan", + "柑" : "gan", + "竿" : "gan", + "酐" : "gan", + "疳" : "gan", + "尴" : "gan", + "杆" : "gan", + "秆" : "gan", + "赶" : "gan", + "敢" : "gan", + "感" : "gan", + "澉" : "gan", + "橄" : "gan", + "擀" : "gan", + "干" : "gan", + "旰" : "gan", + "绀" : "gan", + "淦" : "gan", + "骭" : "gan", + "赣" : "gan", + "冈" : "gang", + "冮" : "gang", + "刚" : "gang", + "肛" : "gang", + "纲" : "gang", + "钢" : "gang", + "缸" : "gang", + "罡" : "gang", + "岗" : "gang", + "港" : "gang", + "杠" : "gang", + "皋" : "gao", + "高" : "gao", + "羔" : "gao", + "睾" : "gao", + "膏" : "gao", + "篙" : "gao", + "糕" : "gao", + "杲" : "gao", + "搞" : "gao", + "槁" : "gao", + "稿" : "gao", + "告" : "gao", + "郜" : "gao", + "诰" : "gao", + "锆" : "gao", + "戈" : "ge", + "圪" : "ge", + "纥" : "ge", + "疙" : "ge", + "哥" : "ge", + "胳" : "ge", + "鸽" : "ge", + "袼" : "ge", + "搁" : "ge", + "割" : "ge", + "歌" : "ge", + "革" : "ge", + "阁" : "ge", + "格" : "ge", + "隔" : "ge", + "嗝" : "ge", + "膈" : "ge", + "骼" : "ge", + "镉" : "ge", + "舸" : "ge", + "葛" : "ge", + "个" : "ge", + "各" : "ge", + "虼" : "ge", + "硌" : "ge", + "铬" : "ge", + "根" : "gen", + "跟" : "gen", + "哏" : "gen", + "亘" : "gen", + "艮" : "gen", + "茛" : "gen", + "庚" : "geng", + "耕" : "geng", + "浭" : "geng", + "赓" : "geng", + "羹" : "geng", + "埂" : "geng", + "耿" : "geng", + "哽" : "geng", + "绠" : "geng", + "梗" : "geng", + "鲠" : "geng", + "更" : "geng", + "工" : "gong", + "弓" : "gong", + "公" : "gong", + "功" : "gong", + "攻" : "gong", + "肱" : "gong", + "宫" : "gong", + "恭" : "gong", + "蚣" : "gong", + "躬" : "gong", + "龚" : "gong", + "塨" : "gong", + "觥" : "gong", + "巩" : "gong", + "汞" : "gong", + "拱" : "gong", + "珙" : "gong", + "共" : "gong", + "贡" : "gong", + "供" : "gong", + "勾" : "gou", + "佝" : "gou", + "沟" : "gou", + "钩" : "gou", + "篝" : "gou", + "苟" : "gou", + "岣" : "gou", + "狗" : "gou", + "枸" : "gou", + "构" : "gou", + "购" : "gou", + "诟" : "gou", + "垢" : "gou", + "够" : "gou", + "彀" : "gou", + "媾" : "gou", + "觏" : "gou", + "估" : "gu", + "咕" : "gu", + "沽" : "gu", + "孤" : "gu", + "姑" : "gu", + "轱" : "gu", + "鸪" : "gu", + "菰" : "gu", + "菇" : "gu", + "蛄" : "gu", + "蓇" : "gu", + "辜" : "gu", + "酤" : "gu", + "觚" : "gu", + "毂" : "gu", + "箍" : "gu", + "古" : "gu", + "谷" : "gu", + "汩" : "gu", + "诂" : "gu", + "股" : "gu", + "骨" : "gu", + "牯" : "gu", + "钴" : "gu", + "羖" : "gu", + "蛊" : "gu", + "鼓" : "gu", + "榾" : "gu", + "鹘" : "gu", + "臌" : "gu", + "瀔" : "gu", + "固" : "gu", + "故" : "gu", + "顾" : "gu", + "梏" : "gu", + "崮" : "gu", + "雇" : "gu", + "锢" : "gu", + "痼" : "gu", + "瓜" : "gua", + "刮" : "gua", + "胍" : "gua", + "鸹" : "gua", + "剐" : "gua", + "寡" : "gua", + "卦" : "gua", + "诖" : "gua", + "挂" : "gua", + "褂" : "gua", + "乖" : "guai", + "拐" : "guai", + "怪" : "guai", + "关" : "guan", + "观" : "guan", + "官" : "guan", + "倌" : "guan", + "蒄" : "guan", + "棺" : "guan", + "瘝" : "guan", + "鳏" : "guan", + "馆" : "guan", + "管" : "guan", + "贯" : "guan", + "冠" : "guan", + "掼" : "guan", + "惯" : "guan", + "祼" : "guan", + "盥" : "guan", + "灌" : "guan", + "瓘" : "guan", + "鹳" : "guan", + "罐" : "guan", + "琯" : "guan", + "光" : "guang", + "咣" : "guang", + "胱" : "guang", + "广" : "guang", + "犷" : "guang", + "桄" : "guang", + "逛" : "guang", + "归" : "gui", + "圭" : "gui", + "龟" : "gui", + "妫" : "gui", + "规" : "gui", + "皈" : "gui", + "闺" : "gui", + "硅" : "gui", + "瑰" : "gui", + "鲑" : "gui", + "宄" : "gui", + "轨" : "gui", + "庋" : "gui", + "匦" : "gui", + "诡" : "gui", + "鬼" : "gui", + "姽" : "gui", + "癸" : "gui", + "晷" : "gui", + "簋" : "gui", + "柜" : "gui", + "炅" : "gui", + "刿" : "gui", + "刽" : "gui", + "贵" : "gui", + "桂" : "gui", + "跪" : "gui", + "鳜" : "gui", + "衮" : "gun", + "绲" : "gun", + "辊" : "gun", + "滚" : "gun", + "磙" : "gun", + "鲧" : "gun", + "棍" : "gun", + "埚" : "guo", + "郭" : "guo", + "啯" : "guo", + "崞" : "guo", + "聒" : "guo", + "锅" : "guo", + "蝈" : "guo", + "国" : "guo", + "帼" : "guo", + "虢" : "guo", + "果" : "guo", + "椁" : "guo", + "蜾" : "guo", + "裹" : "guo", + "过" : "guo", + "哈" : "ha", + "铪" : "ha", + "孩" : "hai", + "骸" : "hai", + "胲" : "hai", + "海" : "hai", + "醢" : "hai", + "亥" : "hai", + "骇" : "hai", + "害" : "hai", + "嗐" : "hai", + "嗨" : "hai", + "顸" : "han", + "蚶" : "han", + "酣" : "han", + "憨" : "han", + "鼾" : "han", + "邗" : "han", + "邯" : "han", + "含" : "han", + "函" : "han", + "晗" : "han", + "焓" : "han", + "涵" : "han", + "韩" : "han", + "寒" : "han", + "罕" : "han", + "喊" : "han", + "蔊" : "han", + "汉" : "han", + "汗" : "han", + "旱" : "han", + "捍" : "han", + "悍" : "han", + "菡" : "han", + "焊" : "han", + "撖" : "han", + "撼" : "han", + "翰" : "han", + "憾" : "han", + "瀚" : "han", + "夯" : "hang", + "杭" : "hang", + "绗" : "hang", + "航" : "hang", + "沆" : "hang", + "蒿" : "hao", + "薅" : "hao", + "嚆" : "hao", + "蚝" : "hao", + "毫" : "hao", + "嗥" : "hao", + "豪" : "hao", + "壕" : "hao", + "嚎" : "hao", + "濠" : "hao", + "好" : "hao", + "郝" : "hao", + "号" : "hao", + "昊" : "hao", + "耗" : "hao", + "浩" : "hao", + "皓" : "hao", + "滈" : "hao", + "颢" : "hao", + "灏" : "hao", + "诃" : "he", + "呵" : "he", + "喝" : "he", + "嗬" : "he", + "禾" : "he", + "合" : "he", + "何" : "he", + "劾" : "he", + "河" : "he", + "曷" : "he", + "阂" : "he", + "盍" : "he", + "荷" : "he", + "菏" : "he", + "盒" : "he", + "涸" : "he", + "颌" : "he", + "阖" : "he", + "贺" : "he", + "赫" : "he", + "褐" : "he", + "鹤" : "he", + "壑" : "he", + "黑" : "hei", + "嘿" : "hei", + "痕" : "hen", + "很" : "hen", + "狠" : "hen", + "恨" : "hen", + "亨" : "heng", + "恒" : "heng", + "珩" : "heng", + "横" : "heng", + "衡" : "heng", + "蘅" : "heng", + "啈" : "heng", + "轰" : "hong", + "訇" : "hong", + "烘" : "hong", + "薨" : "hong", + "弘" : "hong", + "红" : "hong", + "闳" : "hong", + "宏" : "hong", + "荭" : "hong", + "虹" : "hong", + "竑" : "hong", + "洪" : "hong", + "鸿" : "hong", + "哄" : "hong", + "讧" : "hong", + "吽" : "hong", + "齁" : "hou", + "侯" : "hou", + "喉" : "hou", + "猴" : "hou", + "瘊" : "hou", + "骺" : "hou", + "篌" : "hou", + "糇" : "hou", + "吼" : "hou", + "后" : "hou", + "郈" : "hou", + "厚" : "hou", + "垕" : "hou", + "逅" : "hou", + "候" : "hou", + "堠" : "hou", + "鲎" : "hou", + "乎" : "hu", + "呼" : "hu", + "忽" : "hu", + "轷" : "hu", + "烀" : "hu", + "惚" : "hu", + "滹" : "hu", + "囫" : "hu", + "狐" : "hu", + "弧" : "hu", + "胡" : "hu", + "壶" : "hu", + "斛" : "hu", + "葫" : "hu", + "猢" : "hu", + "湖" : "hu", + "瑚" : "hu", + "鹕" : "hu", + "槲" : "hu", + "蝴" : "hu", + "糊" : "hu", + "醐" : "hu", + "觳" : "hu", + "虎" : "hu", + "唬" : "hu", + "琥" : "hu", + "互" : "hu", + "户" : "hu", + "冱" : "hu", + "护" : "hu", + "沪" : "hu", + "枑" : "hu", + "怙" : "hu", + "戽" : "hu", + "笏" : "hu", + "瓠" : "hu", + "扈" : "hu", + "鹱" : "hu", + "花" : "hua", + "砉" : "hua", + "华" : "hua", + "哗" : "hua", + "骅" : "hua", + "铧" : "hua", + "猾" : "hua", + "滑" : "hua", + "化" : "hua", + "画" : "hua", + "话" : "hua", + "桦" : "hua", + "婳" : "hua", + "觟" : "hua", + "怀" : "huai", + "徊" : "huai", + "淮" : "huai", + "槐" : "huai", + "踝" : "huai", + "耲" : "huai", + "坏" : "huai", + "欢" : "huan", + "獾" : "huan", + "环" : "huan", + "洹" : "huan", + "桓" : "huan", + "萑" : "huan", + "寰" : "huan", + "缳" : "huan", + "缓" : "huan", + "幻" : "huan", + "奂" : "huan", + "宦" : "huan", + "换" : "huan", + "唤" : "huan", + "涣" : "huan", + "浣" : "huan", + "患" : "huan", + "焕" : "huan", + "痪" : "huan", + "豢" : "huan", + "漶" : "huan", + "鲩" : "huan", + "擐" : "huan", + "肓" : "huang", + "荒" : "huang", + "塃" : "huang", + "慌" : "huang", + "皇" : "huang", + "黄" : "huang", + "凰" : "huang", + "隍" : "huang", + "喤" : "huang", + "遑" : "huang", + "徨" : "huang", + "湟" : "huang", + "惶" : "huang", + "媓" : "huang", + "煌" : "huang", + "锽" : "huang", + "潢" : "huang", + "璜" : "huang", + "蝗" : "huang", + "篁" : "huang", + "艎" : "huang", + "磺" : "huang", + "癀" : "huang", + "蟥" : "huang", + "簧" : "huang", + "鳇" : "huang", + "恍" : "huang", + "晃" : "huang", + "谎" : "huang", + "幌" : "huang", + "滉" : "huang", + "皝" : "huang", + "灰" : "hui", + "诙" : "hui", + "挥" : "hui", + "恢" : "hui", + "晖" : "hui", + "辉" : "hui", + "麾" : "hui", + "徽" : "hui", + "隳" : "hui", + "回" : "hui", + "茴" : "hui", + "洄" : "hui", + "蛔" : "hui", + "悔" : "hui", + "毁" : "hui", + "卉" : "hui", + "汇" : "hui", + "讳" : "hui", + "荟" : "hui", + "浍" : "hui", + "诲" : "hui", + "绘" : "hui", + "恚" : "hui", + "贿" : "hui", + "烩" : "hui", + "彗" : "hui", + "晦" : "hui", + "秽" : "hui", + "惠" : "hui", + "喙" : "hui", + "慧" : "hui", + "蕙" : "hui", + "蟪" : "hui", + "珲" : "hun", + "昏" : "hun", + "荤" : "hun", + "阍" : "hun", + "惛" : "hun", + "婚" : "hun", + "浑" : "hun", + "馄" : "hun", + "混" : "hun", + "魂" : "hun", + "诨" : "hun", + "溷" : "hun", + "耠" : "huo", + "劐" : "huo", + "豁" : "huo", + "活" : "huo", + "火" : "huo", + "伙" : "huo", + "钬" : "huo", + "夥" : "huo", + "或" : "huo", + "货" : "huo", + "获" : "huo", + "祸" : "huo", + "惑" : "huo", + "霍" : "huo", + "镬" : "huo", + "攉" : "huo", + "藿" : "huo", + "嚯" : "huo", + "讥" : "ji", + "击" : "ji", + "叽" : "ji", + "饥" : "ji", + "玑" : "ji", + "圾" : "ji", + "芨" : "ji", + "机" : "ji", + "乩" : "ji", + "肌" : "ji", + "矶" : "ji", + "鸡" : "ji", + "剞" : "ji", + "唧" : "ji", + "积" : "ji", + "笄" : "ji", + "屐" : "ji", + "姬" : "ji", + "基" : "ji", + "犄" : "ji", + "嵇" : "ji", + "畸" : "ji", + "跻" : "ji", + "箕" : "ji", + "齑" : "ji", + "畿" : "ji", + "墼" : "ji", + "激" : "ji", + "羁" : "ji", + "及" : "ji", + "吉" : "ji", + "岌" : "ji", + "汲" : "ji", + "级" : "ji", + "极" : "ji", + "即" : "ji", + "佶" : "ji", + "笈" : "ji", + "急" : "ji", + "疾" : "ji", + "棘" : "ji", + "集" : "ji", + "蒺" : "ji", + "楫" : "ji", + "辑" : "ji", + "嫉" : "ji", + "瘠" : "ji", + "藉" : "ji", + "籍" : "ji", + "几" : "ji", + "己" : "ji", + "虮" : "ji", + "挤" : "ji", + "脊" : "ji", + "掎" : "ji", + "戟" : "ji", + "麂" : "ji", + "计" : "ji", + "记" : "ji", + "伎" : "ji", + "纪" : "ji", + "技" : "ji", + "忌" : "ji", + "际" : "ji", + "妓" : "ji", + "季" : "ji", + "剂" : "ji", + "迹" : "ji", + "济" : "ji", + "既" : "ji", + "觊" : "ji", + "继" : "ji", + "偈" : "ji", + "祭" : "ji", + "悸" : "ji", + "寄" : "ji", + "寂" : "ji", + "绩" : "ji", + "暨" : "ji", + "稷" : "ji", + "鲫" : "ji", + "髻" : "ji", + "冀" : "ji", + "骥" : "ji", + "加" : "jia", + "佳" : "jia", + "枷" : "jia", + "浃" : "jia", + "痂" : "jia", + "家" : "jia", + "袈" : "jia", + "嘉" : "jia", + "镓" : "jia", + "荚" : "jia", + "戛" : "jia", + "颊" : "jia", + "甲" : "jia", + "胛" : "jia", + "钾" : "jia", + "假" : "jia", + "价" : "jia", + "驾" : "jia", + "架" : "jia", + "嫁" : "jia", + "稼" : "jia", + "戋" : "jian", + "尖" : "jian", + "奸" : "jian", + "歼" : "jian", + "坚" : "jian", + "间" : "jian", + "肩" : "jian", + "艰" : "jian", + "监" : "jian", + "兼" : "jian", + "菅" : "jian", + "笺" : "jian", + "缄" : "jian", + "煎" : "jian", + "拣" : "jian", + "茧" : "jian", + "柬" : "jian", + "俭" : "jian", + "捡" : "jian", + "检" : "jian", + "减" : "jian", + "剪" : "jian", + "睑" : "jian", + "简" : "jian", + "碱" : "jian", + "见" : "jian", + "件" : "jian", + "饯" : "jian", + "建" : "jian", + "荐" : "jian", + "贱" : "jian", + "剑" : "jian", + "健" : "jian", + "舰" : "jian", + "涧" : "jian", + "渐" : "jian", + "谏" : "jian", + "践" : "jian", + "锏" : "jian", + "毽" : "jian", + "腱" : "jian", + "溅" : "jian", + "鉴" : "jian", + "键" : "jian", + "僭" : "jian", + "箭" : "jian", + "江" : "jiang", + "将" : "jiang", + "姜" : "jiang", + "豇" : "jiang", + "浆" : "jiang", + "僵" : "jiang", + "缰" : "jiang", + "疆" : "jiang", + "讲" : "jiang", + "奖" : "jiang", + "桨" : "jiang", + "蒋" : "jiang", + "匠" : "jiang", + "酱" : "jiang", + "犟" : "jiang", + "糨" : "jiang", + "交" : "jiao", + "郊" : "jiao", + "浇" : "jiao", + "娇" : "jiao", + "姣" : "jiao", + "骄" : "jiao", + "胶" : "jiao", + "椒" : "jiao", + "蛟" : "jiao", + "焦" : "jiao", + "跤" : "jiao", + "蕉" : "jiao", + "礁" : "jiao", + "佼" : "jiao", + "狡" : "jiao", + "饺" : "jiao", + "绞" : "jiao", + "铰" : "jiao", + "矫" : "jiao", + "皎" : "jiao", + "脚" : "jiao", + "搅" : "jiao", + "剿" : "jiao", + "缴" : "jiao", + "叫" : "jiao", + "轿" : "jiao", + "较" : "jiao", + "教" : "jiao", + "窖" : "jiao", + "酵" : "jiao", + "侥" : "jiao", + "阶" : "jie", + "皆" : "jie", + "接" : "jie", + "秸" : "jie", + "揭" : "jie", + "嗟" : "jie", + "街" : "jie", + "孑" : "jie", + "节" : "jie", + "讦" : "jie", + "劫" : "jie", + "杰" : "jie", + "诘" : "jie", + "洁" : "jie", + "结" : "jie", + "捷" : "jie", + "睫" : "jie", + "截" : "jie", + "碣" : "jie", + "竭" : "jie", + "姐" : "jie", + "解" : "jie", + "介" : "jie", + "戒" : "jie", + "届" : "jie", + "界" : "jie", + "疥" : "jie", + "诫" : "jie", + "借" : "jie", + "巾" : "jin", + "斤" : "jin", + "今" : "jin", + "金" : "jin", + "津" : "jin", + "矜" : "jin", + "筋" : "jin", + "襟" : "jin", + "仅" : "jin", + "紧" : "jin", + "锦" : "jin", + "谨" : "jin", + "尽" : "jin", + "进" : "jin", + "近" : "jin", + "晋" : "jin", + "烬" : "jin", + "浸" : "jin", + "禁" : "jin", + "觐" : "jin", + "噤" : "jin", + "茎" : "jing", + "京" : "jing", + "泾" : "jing", + "经" : "jing", + "菁" : "jing", + "惊" : "jing", + "晶" : "jing", + "睛" : "jing", + "粳" : "jing", + "兢" : "jing", + "精" : "jing", + "鲸" : "jing", + "井" : "jing", + "阱" : "jing", + "刭" : "jing", + "景" : "jing", + "儆" : "jing", + "警" : "jing", + "径" : "jing", + "净" : "jing", + "痉" : "jing", + "竞" : "jing", + "竟" : "jing", + "敬" : "jing", + "靖" : "jing", + "静" : "jing", + "境" : "jing", + "镜" : "jing", + "迥" : "jiong", + "炯" : "jiong", + "窘" : "jiong", + "纠" : "jiu", + "鸠" : "jiu", + "究" : "jiu", + "赳" : "jiu", + "阄" : "jiu", + "揪" : "jiu", + "啾" : "jiu", + "九" : "jiu", + "久" : "jiu", + "玖" : "jiu", + "灸" : "jiu", + "韭" : "jiu", + "酒" : "jiu", + "旧" : "jiu", + "臼" : "jiu", + "咎" : "jiu", + "柩" : "jiu", + "救" : "jiu", + "厩" : "jiu", + "就" : "jiu", + "舅" : "jiu", + "鹫" : "jiu", + "军" : "jun", + "均" : "jun", + "君" : "jun", + "钧" : "jun", + "菌" : "jun", + "皲" : "jun", + "俊" : "jun", + "郡" : "jun", + "峻" : "jun", + "骏" : "jun", + "竣" : "jun", + "拘" : "ju", + "狙" : "ju", + "居" : "ju", + "驹" : "ju", + "掬" : "ju", + "雎" : "ju", + "鞠" : "ju", + "局" : "ju", + "菊" : "ju", + "焗" : "ju", + "橘" : "ju", + "咀" : "ju", + "沮" : "ju", + "矩" : "ju", + "举" : "ju", + "龃" : "ju", + "巨" : "ju", + "拒" : "ju", + "具" : "ju", + "炬" : "ju", + "俱" : "ju", + "剧" : "ju", + "据" : "ju", + "距" : "ju", + "惧" : "ju", + "飓" : "ju", + "锯" : "ju", + "聚" : "ju", + "踞" : "ju", + "捐" : "juan", + "涓" : "juan", + "娟" : "juan", + "鹃" : "juan", + "卷" : "juan", + "倦" : "juan", + "绢" : "juan", + "眷" : "juan", + "隽" : "juan", + "撅" : "jue", + "噘" : "jue", + "决" : "jue", + "诀" : "jue", + "抉" : "jue", + "绝" : "jue", + "掘" : "jue", + "崛" : "jue", + "厥" : "jue", + "谲" : "jue", + "蕨" : "jue", + "爵" : "jue", + "蹶" : "jue", + "矍" : "jue", + "倔" : "jue", + "咔" : "ka", + "开" : "kai", + "揩" : "kai", + "凯" : "kai", + "铠" : "kai", + "慨" : "kai", + "楷" : "kai", + "忾" : "kai", + "刊" : "kan", + "勘" : "kan", + "龛" : "kan", + "堪" : "kan", + "坎" : "kan", + "侃" : "kan", + "砍" : "kan", + "槛" : "kan", + "看" : "kan", + "瞰" : "kan", + "康" : "kang", + "慷" : "kang", + "糠" : "kang", + "亢" : "kang", + "伉" : "kang", + "抗" : "kang", + "炕" : "kang", + "考" : "kao", + "拷" : "kao", + "烤" : "kao", + "铐" : "kao", + "犒" : "kao", + "靠" : "kao", + "苛" : "ke", + "轲" : "ke", + "科" : "ke", + "棵" : "ke", + "搕" : "ke", + "嗑" : "ke", + "稞" : "ke", + "窠" : "ke", + "颗" : "ke", + "磕" : "ke", + "瞌" : "ke", + "蝌" : "ke", + "可" : "ke", + "坷" : "ke", + "渴" : "ke", + "克" : "ke", + "刻" : "ke", + "恪" : "ke", + "客" : "ke", + "课" : "ke", + "肯" : "ken", + "垦" : "ken", + "恳" : "ken", + "啃" : "ken", + "坑" : "keng", + "铿" : "keng", + "空" : "kong", + "孔" : "kong", + "恐" : "kong", + "控" : "kong", + "抠" : "kou", + "口" : "kou", + "叩" : "kou", + "扣" : "kou", + "寇" : "kou", + "蔻" : "kou", + "枯" : "ku", + "哭" : "ku", + "窟" : "ku", + "骷" : "ku", + "苦" : "ku", + "库" : "ku", + "绔" : "ku", + "裤" : "ku", + "酷" : "ku", + "夸" : "kua", + "垮" : "kua", + "挎" : "kua", + "胯" : "kua", + "跨" : "kua", + "块" : "kuai", + "快" : "kuai", + "侩" : "kuai", + "脍" : "kuai", + "筷" : "kuai", + "宽" : "kuan", + "髋" : "kuan", + "款" : "kuan", + "诓" : "kuang", + "哐" : "kuang", + "筐" : "kuang", + "狂" : "kuang", + "诳" : "kuang", + "旷" : "kuang", + "况" : "kuang", + "矿" : "kuang", + "框" : "kuang", + "眶" : "kuang", + "亏" : "kui", + "盔" : "kui", + "窥" : "kui", + "葵" : "kui", + "魁" : "kui", + "傀" : "kui", + "匮" : "kui", + "馈" : "kui", + "愧" : "kui", + "坤" : "kun", + "昆" : "kun", + "鲲" : "kun", + "捆" : "kun", + "困" : "kun", + "扩" : "kuo", + "括" : "kuo", + "阔" : "kuo", + "廓" : "kuo", + "垃" : "la", + "拉" : "la", + "啦" : "la", + "邋" : "la", + "旯" : "la", + "喇" : "la", + "腊" : "la", + "蜡" : "la", + "辣" : "la", + "来" : "lai", + "莱" : "lai", + "徕" : "lai", + "睐" : "lai", + "赖" : "lai", + "癞" : "lai", + "籁" : "lai", + "兰" : "lan", + "岚" : "lan", + "拦" : "lan", + "栏" : "lan", + "婪" : "lan", + "阑" : "lan", + "蓝" : "lan", + "澜" : "lan", + "褴" : "lan", + "篮" : "lan", + "览" : "lan", + "揽" : "lan", + "缆" : "lan", + "榄" : "lan", + "懒" : "lan", + "烂" : "lan", + "滥" : "lan", + "啷" : "lang", + "郎" : "lang", + "狼" : "lang", + "琅" : "lang", + "廊" : "lang", + "榔" : "lang", + "锒" : "lang", + "螂" : "lang", + "朗" : "lang", + "浪" : "lang", + "捞" : "lao", + "劳" : "lao", + "牢" : "lao", + "崂" : "lao", + "老" : "lao", + "佬" : "lao", + "姥" : "lao", + "唠" : "lao", + "烙" : "lao", + "涝" : "lao", + "酪" : "lao", + "雷" : "lei", + "羸" : "lei", + "垒" : "lei", + "磊" : "lei", + "蕾" : "lei", + "儡" : "lei", + "肋" : "lei", + "泪" : "lei", + "类" : "lei", + "累" : "lei", + "擂" : "lei", + "嘞" : "lei", + "棱" : "leng", + "楞" : "leng", + "冷" : "leng", + "睖" : "leng", + "厘" : "li", + "狸" : "li", + "离" : "li", + "梨" : "li", + "犁" : "li", + "鹂" : "li", + "喱" : "li", + "蜊" : "li", + "漓" : "li", + "璃" : "li", + "黎" : "li", + "罹" : "li", + "篱" : "li", + "蠡" : "li", + "礼" : "li", + "李" : "li", + "里" : "li", + "俚" : "li", + "逦" : "li", + "哩" : "li", + "娌" : "li", + "理" : "li", + "鲤" : "li", + "力" : "li", + "历" : "li", + "厉" : "li", + "立" : "li", + "吏" : "li", + "丽" : "li", + "励" : "li", + "呖" : "li", + "利" : "li", + "沥" : "li", + "枥" : "li", + "例" : "li", + "戾" : "li", + "隶" : "li", + "荔" : "li", + "俐" : "li", + "莉" : "li", + "莅" : "li", + "栗" : "li", + "砾" : "li", + "蛎" : "li", + "唳" : "li", + "笠" : "li", + "粒" : "li", + "雳" : "li", + "痢" : "li", + "连" : "lian", + "怜" : "lian", + "帘" : "lian", + "莲" : "lian", + "涟" : "lian", + "联" : "lian", + "廉" : "lian", + "鲢" : "lian", + "镰" : "lian", + "敛" : "lian", + "脸" : "lian", + "练" : "lian", + "炼" : "lian", + "恋" : "lian", + "殓" : "lian", + "链" : "lian", + "良" : "liang", + "凉" : "liang", + "梁" : "liang", + "粮" : "liang", + "粱" : "liang", + "两" : "liang", + "魉" : "liang", + "亮" : "liang", + "谅" : "liang", + "辆" : "liang", + "靓" : "liang", + "量" : "liang", + "晾" : "liang", + "踉" : "liang", + "辽" : "liao", + "疗" : "liao", + "聊" : "liao", + "僚" : "liao", + "寥" : "liao", + "撩" : "liao", + "嘹" : "liao", + "獠" : "liao", + "潦" : "liao", + "缭" : "liao", + "燎" : "liao", + "料" : "liao", + "撂" : "liao", + "瞭" : "liao", + "镣" : "liao", + "咧" : "lie", + "列" : "lie", + "劣" : "lie", + "冽" : "lie", + "烈" : "lie", + "猎" : "lie", + "裂" : "lie", + "趔" : "lie", + "拎" : "lin", + "邻" : "lin", + "林" : "lin", + "临" : "lin", + "淋" : "lin", + "琳" : "lin", + "粼" : "lin", + "嶙" : "lin", + "潾" : "lin", + "霖" : "lin", + "磷" : "lin", + "鳞" : "lin", + "麟" : "lin", + "凛" : "lin", + "檩" : "lin", + "吝" : "lin", + "赁" : "lin", + "躏" : "lin", + "伶" : "ling", + "灵" : "ling", + "苓" : "ling", + "囹" : "ling", + "泠" : "ling", + "玲" : "ling", + "瓴" : "ling", + "铃" : "ling", + "凌" : "ling", + "陵" : "ling", + "聆" : "ling", + "菱" : "ling", + "棂" : "ling", + "蛉" : "ling", + "翎" : "ling", + "羚" : "ling", + "绫" : "ling", + "零" : "ling", + "龄" : "ling", + "岭" : "ling", + "领" : "ling", + "另" : "ling", + "令" : "ling", + "溜" : "liu", + "熘" : "liu", + "刘" : "liu", + "浏" : "liu", + "留" : "liu", + "流" : "liu", + "琉" : "liu", + "硫" : "liu", + "馏" : "liu", + "榴" : "liu", + "瘤" : "liu", + "柳" : "liu", + "绺" : "liu", + "六" : "liu", + "遛" : "liu", + "龙" : "long", + "咙" : "long", + "珑" : "long", + "胧" : "long", + "聋" : "long", + "笼" : "long", + "隆" : "long", + "窿" : "long", + "陇" : "long", + "拢" : "long", + "垄" : "long", + "娄" : "lou", + "楼" : "lou", + "髅" : "lou", + "搂" : "lou", + "篓" : "lou", + "陋" : "lou", + "镂" : "lou", + "漏" : "lou", + "喽" : "lou", + "撸" : "lu", + "卢" : "lu", + "芦" : "lu", + "庐" : "lu", + "炉" : "lu", + "泸" : "lu", + "鸬" : "lu", + "颅" : "lu", + "鲈" : "lu", + "卤" : "lu", + "虏" : "lu", + "掳" : "lu", + "鲁" : "lu", + "橹" : "lu", + "录" : "lu", + "赂" : "lu", + "鹿" : "lu", + "禄" : "lu", + "路" : "lu", + "箓" : "lu", + "漉" : "lu", + "戮" : "lu", + "鹭" : "lu", + "麓" : "lu", + "峦" : "luan", + "孪" : "luan", + "挛" : "luan", + "鸾" : "luan", + "卵" : "luan", + "乱" : "luan", + "抡" : "lun", + "仑" : "lun", + "伦" : "lun", + "囵" : "lun", + "沦" : "lun", + "轮" : "lun", + "论" : "lun", + "啰" : "luo", + "罗" : "luo", + "萝" : "luo", + "逻" : "luo", + "锣" : "luo", + "箩" : "luo", + "骡" : "luo", + "螺" : "luo", + "裸" : "luo", + "洛" : "luo", + "络" : "luo", + "骆" : "luo", + "摞" : "luo", + "漯" : "luo", + "驴" : "lv", + "榈" : "lv", + "吕" : "lv", + "侣" : "lv", + "旅" : "lv", + "铝" : "lv", + "屡" : "lv", + "缕" : "lv", + "膂" : "lv", + "褛" : "lv", + "履" : "lv", + "律" : "lv", + "虑" : "lv", + "氯" : "lv", + "滤" : "lv", + "掠" : "lve", + "略" : "lve", + "妈" : "ma", + "麻" : "ma", + "蟆" : "ma", + "马" : "ma", + "犸" : "ma", + "玛" : "ma", + "码" : "ma", + "蚂" : "ma", + "骂" : "ma", + "吗" : "ma", + "嘛" : "ma", + "霾" : "mai", + "买" : "mai", + "迈" : "mai", + "麦" : "mai", + "卖" : "mai", + "霡" : "mai", + "蛮" : "man", + "馒" : "man", + "瞒" : "man", + "满" : "man", + "曼" : "man", + "谩" : "man", + "幔" : "man", + "漫" : "man", + "慢" : "man", + "牤" : "mang", + "芒" : "mang", + "忙" : "mang", + "盲" : "mang", + "氓" : "mang", + "茫" : "mang", + "莽" : "mang", + "漭" : "mang", + "蟒" : "mang", + "猫" : "mao", + "毛" : "mao", + "矛" : "mao", + "茅" : "mao", + "牦" : "mao", + "锚" : "mao", + "髦" : "mao", + "蝥" : "mao", + "蟊" : "mao", + "冇" : "mao", + "卯" : "mao", + "铆" : "mao", + "茂" : "mao", + "冒" : "mao", + "贸" : "mao", + "袤" : "mao", + "帽" : "mao", + "貌" : "mao", + "玫" : "mei", + "枚" : "mei", + "眉" : "mei", + "莓" : "mei", + "梅" : "mei", + "媒" : "mei", + "楣" : "mei", + "煤" : "mei", + "酶" : "mei", + "霉" : "mei", + "每" : "mei", + "美" : "mei", + "镁" : "mei", + "妹" : "mei", + "昧" : "mei", + "袂" : "mei", + "寐" : "mei", + "媚" : "mei", + "魅" : "mei", + "门" : "men", + "扪" : "men", + "闷" : "men", + "焖" : "men", + "懑" : "men", + "们" : "men", + "虻" : "meng", + "萌" : "meng", + "蒙" : "meng", + "盟" : "meng", + "檬" : "meng", + "曚" : "meng", + "朦" : "meng", + "猛" : "meng", + "锰" : "meng", + "蜢" : "meng", + "懵" : "meng", + "孟" : "meng", + "梦" : "meng", + "咪" : "mi", + "眯" : "mi", + "弥" : "mi", + "迷" : "mi", + "猕" : "mi", + "谜" : "mi", + "醚" : "mi", + "糜" : "mi", + "麋" : "mi", + "靡" : "mi", + "米" : "mi", + "弭" : "mi", + "觅" : "mi", + "密" : "mi", + "幂" : "mi", + "谧" : "mi", + "蜜" : "mi", + "眠" : "mian", + "绵" : "mian", + "棉" : "mian", + "免" : "mian", + "勉" : "mian", + "娩" : "mian", + "冕" : "mian", + "渑" : "mian", + "湎" : "mian", + "缅" : "mian", + "腼" : "mian", + "面" : "mian", + "喵" : "miao", + "苗" : "miao", + "描" : "miao", + "瞄" : "miao", + "秒" : "miao", + "渺" : "miao", + "藐" : "miao", + "妙" : "miao", + "庙" : "miao", + "缥" : "miao", + "咩" : "mie", + "灭" : "mie", + "蔑" : "mie", + "篾" : "mie", + "乜" : "mie", + "民" : "min", + "皿" : "min", + "抿" : "min", + "泯" : "min", + "闽" : "min", + "悯" : "min", + "敏" : "min", + "名" : "ming", + "明" : "ming", + "鸣" : "ming", + "茗" : "ming", + "冥" : "ming", + "铭" : "ming", + "瞑" : "ming", + "螟" : "ming", + "酩" : "ming", + "命" : "ming", + "谬" : "miu", + "摸" : "mo", + "馍" : "mo", + "摹" : "mo", + "膜" : "mo", + "摩" : "mo", + "磨" : "mo", + "蘑" : "mo", + "魔" : "mo", + "末" : "mo", + "茉" : "mo", + "殁" : "mo", + "沫" : "mo", + "陌" : "mo", + "莫" : "mo", + "秣" : "mo", + "蓦" : "mo", + "漠" : "mo", + "寞" : "mo", + "墨" : "mo", + "默" : "mo", + "嬷" : "mo", + "缪" : "mou", + "哞" : "mou", + "眸" : "mou", + "谋" : "mou", + "某" : "mou", + "母" : "mu", + "牡" : "mu", + "亩" : "mu", + "拇" : "mu", + "姆" : "mu", + "木" : "mu", + "目" : "mu", + "沐" : "mu", + "苜" : "mu", + "牧" : "mu", + "钼" : "mu", + "募" : "mu", + "墓" : "mu", + "幕" : "mu", + "睦" : "mu", + "慕" : "mu", + "暮" : "mu", + "穆" : "mu", + "拿" : "na", + "呐" : "na", + "纳" : "na", + "钠" : "na", + "衲" : "na", + "捺" : "na", + "乃" : "nai", + "奶" : "nai", + "氖" : "nai", + "奈" : "nai", + "耐" : "nai", + "囡" : "nan", + "男" : "nan", + "南" : "nan", + "难" : "nan", + "喃" : "nan", + "楠" : "nan", + "赧" : "nan", + "腩" : "nan", + "囔" : "nang", + "囊" : "nang", + "孬" : "nao", + "呶" : "nao", + "挠" : "nao", + "恼" : "nao", + "脑" : "nao", + "瑙" : "nao", + "闹" : "nao", + "淖" : "nao", + "讷" : "ne", + "馁" : "nei", + "内" : "nei", + "嫩" : "nen", + "恁" : "nen", + "能" : "neng", + "嗯" : "ng", + "妮" : "ni", + "尼" : "ni", + "泥" : "ni", + "怩" : "ni", + "倪" : "ni", + "霓" : "ni", + "拟" : "ni", + "你" : "ni", + "旎" : "ni", + "昵" : "ni", + "逆" : "ni", + "匿" : "ni", + "腻" : "ni", + "溺" : "ni", + "拈" : "nian", + "蔫" : "nian", + "年" : "nian", + "黏" : "nian", + "捻" : "nian", + "辇" : "nian", + "撵" : "nian", + "碾" : "nian", + "廿" : "nian", + "念" : "nian", + "娘" : "niang", + "酿" : "niang", + "鸟" : "niao", + "袅" : "niao", + "尿" : "niao", + "捏" : "nie", + "聂" : "nie", + "涅" : "nie", + "嗫" : "nie", + "镊" : "nie", + "镍" : "nie", + "蹑" : "nie", + "孽" : "nie", + "您" : "nin", + "宁" : "ning", + "咛" : "ning", + "狞" : "ning", + "柠" : "ning", + "凝" : "ning", + "拧" : "ning", + "佞" : "ning", + "泞" : "ning", + "妞" : "niu", + "牛" : "niu", + "扭" : "niu", + "忸" : "niu", + "纽" : "niu", + "钮" : "niu", + "农" : "nong", + "哝" : "nong", + "浓" : "nong", + "脓" : "nong", + "弄" : "nong", + "奴" : "nu", + "驽" : "nu", + "努" : "nu", + "弩" : "nu", + "怒" : "nu", + "暖" : "nuan", + "疟" : "nue", + "虐" : "nue", + "挪" : "nuo", + "诺" : "nuo", + "喏" : "nuo", + "懦" : "nuo", + "糯" : "nuo", + "女" : "nv", + "噢" : "o", + "讴" : "ou", + "瓯" : "ou", + "欧" : "ou", + "殴" : "ou", + "鸥" : "ou", + "呕" : "ou", + "偶" : "ou", + "藕" : "ou", + "怄" : "ou", + "趴" : "pa", + "啪" : "pa", + "葩" : "pa", + "杷" : "pa", + "爬" : "pa", + "琶" : "pa", + "帕" : "pa", + "怕" : "pa", + "拍" : "pai", + "排" : "pai", + "徘" : "pai", + "牌" : "pai", + "哌" : "pai", + "派" : "pai", + "湃" : "pai", + "潘" : "pan", + "攀" : "pan", + "爿" : "pan", + "盘" : "pan", + "磐" : "pan", + "蹒" : "pan", + "蟠" : "pan", + "判" : "pan", + "盼" : "pan", + "叛" : "pan", + "畔" : "pan", + "乓" : "pang", + "滂" : "pang", + "庞" : "pang", + "旁" : "pang", + "螃" : "pang", + "耪" : "pang", + "抛" : "pao", + "咆" : "pao", + "庖" : "pao", + "袍" : "pao", + "跑" : "pao", + "泡" : "pao", + "呸" : "pei", + "胚" : "pei", + "陪" : "pei", + "培" : "pei", + "赔" : "pei", + "裴" : "pei", + "沛" : "pei", + "佩" : "pei", + "配" : "pei", + "喷" : "pen", + "盆" : "pen", + "抨" : "peng", + "怦" : "peng", + "砰" : "peng", + "烹" : "peng", + "嘭" : "peng", + "朋" : "peng", + "彭" : "peng", + "棚" : "peng", + "蓬" : "peng", + "硼" : "peng", + "鹏" : "peng", + "澎" : "peng", + "篷" : "peng", + "膨" : "peng", + "捧" : "peng", + "碰" : "peng", + "丕" : "pi", + "批" : "pi", + "纰" : "pi", + "坯" : "pi", + "披" : "pi", + "砒" : "pi", + "劈" : "pi", + "噼" : "pi", + "霹" : "pi", + "皮" : "pi", + "枇" : "pi", + "毗" : "pi", + "蚍" : "pi", + "疲" : "pi", + "啤" : "pi", + "琵" : "pi", + "脾" : "pi", + "貔" : "pi", + "匹" : "pi", + "痞" : "pi", + "癖" : "pi", + "屁" : "pi", + "睥" : "pi", + "媲" : "pi", + "僻" : "pi", + "譬" : "pi", + "偏" : "pian", + "篇" : "pian", + "翩" : "pian", + "骈" : "pian", + "蹁" : "pian", + "片" : "pian", + "骗" : "pian", + "剽" : "piao", + "漂" : "piao", + "飘" : "piao", + "瓢" : "piao", + "殍" : "piao", + "瞟" : "piao", + "票" : "piao", + "氕" : "pie", + "瞥" : "pie", + "撇" : "pie", + "拼" : "pin", + "姘" : "pin", + "贫" : "pin", + "频" : "pin", + "嫔" : "pin", + "颦" : "pin", + "品" : "pin", + "聘" : "pin", + "乒" : "ping", + "娉" : "ping", + "平" : "ping", + "评" : "ping", + "坪" : "ping", + "苹" : "ping", + "凭" : "ping", + "瓶" : "ping", + "萍" : "ping", + "钋" : "po", + "坡" : "po", + "泼" : "po", + "颇" : "po", + "婆" : "po", + "鄱" : "po", + "叵" : "po", + "珀" : "po", + "破" : "po", + "粕" : "po", + "魄" : "po", + "剖" : "pou", + "抔" : "pou", + "扑" : "pu", + "铺" : "pu", + "噗" : "pu", + "仆" : "pu", + "匍" : "pu", + "菩" : "pu", + "葡" : "pu", + "蒲" : "pu", + "璞" : "pu", + "圃" : "pu", + "浦" : "pu", + "普" : "pu", + "谱" : "pu", + "蹼" : "pu", + "七" : "qi", + "沏" : "qi", + "妻" : "qi", + "柒" : "qi", + "凄" : "qi", + "萋" : "qi", + "戚" : "qi", + "期" : "qi", + "欺" : "qi", + "嘁" : "qi", + "漆" : "qi", + "齐" : "qi", + "芪" : "qi", + "其" : "qi", + "歧" : "qi", + "祈" : "qi", + "祇" : "qi", + "脐" : "qi", + "畦" : "qi", + "跂" : "qi", + "崎" : "qi", + "骑" : "qi", + "琪" : "qi", + "棋" : "qi", + "旗" : "qi", + "鳍" : "qi", + "麒" : "qi", + "乞" : "qi", + "岂" : "qi", + "企" : "qi", + "杞" : "qi", + "启" : "qi", + "起" : "qi", + "绮" : "qi", + "气" : "qi", + "讫" : "qi", + "迄" : "qi", + "弃" : "qi", + "汽" : "qi", + "泣" : "qi", + "契" : "qi", + "砌" : "qi", + "葺" : "qi", + "器" : "qi", + "憩" : "qi", + "俟" : "qi", + "掐" : "qia", + "洽" : "qia", + "恰" : "qia", + "千" : "qian", + "仟" : "qian", + "阡" : "qian", + "芊" : "qian", + "迁" : "qian", + "钎" : "qian", + "牵" : "qian", + "悭" : "qian", + "谦" : "qian", + "签" : "qian", + "愆" : "qian", + "前" : "qian", + "虔" : "qian", + "钱" : "qian", + "钳" : "qian", + "乾" : "qian", + "潜" : "qian", + "黔" : "qian", + "遣" : "qian", + "谴" : "qian", + "欠" : "qian", + "芡" : "qian", + "倩" : "qian", + "堑" : "qian", + "嵌" : "qian", + "歉" : "qian", + "羌" : "qiang", + "枪" : "qiang", + "戕" : "qiang", + "腔" : "qiang", + "蜣" : "qiang", + "锵" : "qiang", + "墙" : "qiang", + "蔷" : "qiang", + "抢" : "qiang", + "羟" : "qiang", + "襁" : "qiang", + "呛" : "qiang", + "炝" : "qiang", + "跄" : "qiang", + "悄" : "qiao", + "跷" : "qiao", + "锹" : "qiao", + "敲" : "qiao", + "橇" : "qiao", + "乔" : "qiao", + "侨" : "qiao", + "荞" : "qiao", + "桥" : "qiao", + "憔" : "qiao", + "瞧" : "qiao", + "巧" : "qiao", + "俏" : "qiao", + "诮" : "qiao", + "峭" : "qiao", + "窍" : "qiao", + "翘" : "qiao", + "撬" : "qiao", + "切" : "qie", + "且" : "qie", + "妾" : "qie", + "怯" : "qie", + "窃" : "qie", + "挈" : "qie", + "惬" : "qie", + "趄" : "qie", + "锲" : "qie", + "钦" : "qin", + "侵" : "qin", + "衾" : "qin", + "芹" : "qin", + "芩" : "qin", + "秦" : "qin", + "琴" : "qin", + "禽" : "qin", + "勤" : "qin", + "擒" : "qin", + "噙" : "qin", + "寝" : "qin", + "沁" : "qin", + "青" : "qing", + "轻" : "qing", + "氢" : "qing", + "倾" : "qing", + "卿" : "qing", + "清" : "qing", + "蜻" : "qing", + "情" : "qing", + "晴" : "qing", + "氰" : "qing", + "擎" : "qing", + "顷" : "qing", + "请" : "qing", + "庆" : "qing", + "罄" : "qing", + "穷" : "qiong", + "穹" : "qiong", + "琼" : "qiong", + "丘" : "qiu", + "秋" : "qiu", + "蚯" : "qiu", + "鳅" : "qiu", + "囚" : "qiu", + "求" : "qiu", + "虬" : "qiu", + "泅" : "qiu", + "酋" : "qiu", + "球" : "qiu", + "遒" : "qiu", + "裘" : "qiu", + "岖" : "qu", + "驱" : "qu", + "屈" : "qu", + "蛆" : "qu", + "躯" : "qu", + "趋" : "qu", + "蛐" : "qu", + "黢" : "qu", + "渠" : "qu", + "瞿" : "qu", + "曲" : "qu", + "取" : "qu", + "娶" : "qu", + "龋" : "qu", + "去" : "qu", + "趣" : "qu", + "觑" : "qu", + "悛" : "quan", + "权" : "quan", + "全" : "quan", + "诠" : "quan", + "泉" : "quan", + "拳" : "quan", + "痊" : "quan", + "蜷" : "quan", + "醛" : "quan", + "犬" : "quan", + "劝" : "quan", + "券" : "quan", + "炔" : "que", + "缺" : "que", + "瘸" : "que", + "却" : "que", + "确" : "que", + "鹊" : "que", + "阙" : "que", + "榷" : "que", + "逡" : "qun", + "裙" : "qun", + "群" : "qun", + "蚺" : "ran", + "然" : "ran", + "燃" : "ran", + "冉" : "ran", + "苒" : "ran", + "染" : "ran", + "瓤" : "rang", + "壤" : "rang", + "攘" : "rang", + "嚷" : "rang", + "让" : "rang", + "荛" : "rao", + "饶" : "rao", + "娆" : "rao", + "桡" : "rao", + "扰" : "rao", + "绕" : "rao", + "惹" : "re", + "热" : "re", + "人" : "ren", + "壬" : "ren", + "仁" : "ren", + "忍" : "ren", + "荏" : "ren", + "稔" : "ren", + "刃" : "ren", + "认" : "ren", + "任" : "ren", + "纫" : "ren", + "韧" : "ren", + "饪" : "ren", + "扔" : "reng", + "仍" : "reng", + "日" : "ri", + "戎" : "rong", + "茸" : "rong", + "荣" : "rong", + "绒" : "rong", + "容" : "rong", + "嵘" : "rong", + "蓉" : "rong", + "溶" : "rong", + "榕" : "rong", + "熔" : "rong", + "融" : "rong", + "冗" : "rong", + "氄" : "rong", + "柔" : "rou", + "揉" : "rou", + "糅" : "rou", + "蹂" : "rou", + "鞣" : "rou", + "肉" : "rou", + "如" : "ru", + "茹" : "ru", + "铷" : "ru", + "儒" : "ru", + "孺" : "ru", + "蠕" : "ru", + "汝" : "ru", + "乳" : "ru", + "辱" : "ru", + "入" : "ru", + "缛" : "ru", + "褥" : "ru", + "阮" : "ruan", + "软" : "ruan", + "蕊" : "rui", + "蚋" : "rui", + "锐" : "rui", + "瑞" : "rui", + "睿" : "rui", + "闰" : "run", + "润" : "run", + "若" : "ruo", + "偌" : "ruo", + "弱" : "ruo", + "仨" : "sa", + "洒" : "sa", + "撒" : "sa", + "卅" : "sa", + "飒" : "sa", + "萨" : "sa", + "腮" : "sai", + "赛" : "sai", + "三" : "san", + "叁" : "san", + "伞" : "san", + "散" : "san", + "桑" : "sang", + "搡" : "sang", + "嗓" : "sang", + "丧" : "sang", + "搔" : "sao", + "骚" : "sao", + "扫" : "sao", + "嫂" : "sao", + "臊" : "sao", + "涩" : "se", + "啬" : "se", + "铯" : "se", + "瑟" : "se", + "穑" : "se", + "森" : "sen", + "僧" : "seng", + "杀" : "sha", + "沙" : "sha", + "纱" : "sha", + "砂" : "sha", + "啥" : "sha", + "傻" : "sha", + "厦" : "sha", + "歃" : "sha", + "煞" : "sha", + "霎" : "sha", + "筛" : "shai", + "晒" : "shai", + "山" : "shan", + "删" : "shan", + "苫" : "shan", + "衫" : "shan", + "姗" : "shan", + "珊" : "shan", + "煽" : "shan", + "潸" : "shan", + "膻" : "shan", + "闪" : "shan", + "陕" : "shan", + "讪" : "shan", + "汕" : "shan", + "扇" : "shan", + "善" : "shan", + "骟" : "shan", + "缮" : "shan", + "擅" : "shan", + "膳" : "shan", + "嬗" : "shan", + "赡" : "shan", + "鳝" : "shan", + "伤" : "shang", + "殇" : "shang", + "商" : "shang", + "觞" : "shang", + "熵" : "shang", + "晌" : "shang", + "赏" : "shang", + "上" : "shang", + "尚" : "shang", + "捎" : "shao", + "烧" : "shao", + "梢" : "shao", + "稍" : "shao", + "艄" : "shao", + "勺" : "shao", + "芍" : "shao", + "韶" : "shao", + "少" : "shao", + "邵" : "shao", + "绍" : "shao", + "哨" : "shao", + "潲" : "shao", + "奢" : "she", + "赊" : "she", + "舌" : "she", + "佘" : "she", + "蛇" : "she", + "舍" : "she", + "设" : "she", + "社" : "she", + "射" : "she", + "涉" : "she", + "赦" : "she", + "摄" : "she", + "慑" : "she", + "麝" : "she", + "申" : "shen", + "伸" : "shen", + "身" : "shen", + "呻" : "shen", + "绅" : "shen", + "砷" : "shen", + "深" : "shen", + "神" : "shen", + "沈" : "shen", + "审" : "shen", + "哂" : "shen", + "婶" : "shen", + "肾" : "shen", + "甚" : "shen", + "渗" : "shen", + "葚" : "shen", + "蜃" : "shen", + "慎" : "shen", + "升" : "sheng", + "生" : "sheng", + "声" : "sheng", + "昇" : "sheng", + "牲" : "sheng", + "笙" : "sheng", + "甥" : "sheng", + "绳" : "sheng", + "圣" : "sheng", + "胜" : "sheng", + "晟" : "sheng", + "剩" : "sheng", + "尸" : "shi", + "失" : "shi", + "师" : "shi", + "诗" : "shi", + "虱" : "shi", + "狮" : "shi", + "施" : "shi", + "湿" : "shi", + "十" : "shi", + "时" : "shi", + "实" : "shi", + "食" : "shi", + "蚀" : "shi", + "史" : "shi", + "矢" : "shi", + "使" : "shi", + "始" : "shi", + "驶" : "shi", + "屎" : "shi", + "士" : "shi", + "氏" : "shi", + "示" : "shi", + "世" : "shi", + "仕" : "shi", + "市" : "shi", + "式" : "shi", + "势" : "shi", + "事" : "shi", + "侍" : "shi", + "饰" : "shi", + "试" : "shi", + "视" : "shi", + "拭" : "shi", + "柿" : "shi", + "是" : "shi", + "适" : "shi", + "恃" : "shi", + "室" : "shi", + "逝" : "shi", + "轼" : "shi", + "舐" : "shi", + "弑" : "shi", + "释" : "shi", + "谥" : "shi", + "嗜" : "shi", + "誓" : "shi", + "收" : "shou", + "手" : "shou", + "守" : "shou", + "首" : "shou", + "寿" : "shou", + "受" : "shou", + "狩" : "shou", + "授" : "shou", + "售" : "shou", + "兽" : "shou", + "绶" : "shou", + "瘦" : "shou", + "殳" : "shu", + "书" : "shu", + "抒" : "shu", + "枢" : "shu", + "叔" : "shu", + "姝" : "shu", + "殊" : "shu", + "倏" : "shu", + "梳" : "shu", + "淑" : "shu", + "舒" : "shu", + "疏" : "shu", + "输" : "shu", + "蔬" : "shu", + "秫" : "shu", + "孰" : "shu", + "赎" : "shu", + "塾" : "shu", + "暑" : "shu", + "黍" : "shu", + "署" : "shu", + "蜀" : "shu", + "鼠" : "shu", + "薯" : "shu", + "曙" : "shu", + "戍" : "shu", + "束" : "shu", + "述" : "shu", + "树" : "shu", + "竖" : "shu", + "恕" : "shu", + "庶" : "shu", + "墅" : "shu", + "漱" : "shu", + "刷" : "shua", + "唰" : "shua", + "耍" : "shua", + "衰" : "shuai", + "摔" : "shuai", + "甩" : "shuai", + "帅" : "shuai", + "蟀" : "shuai", + "闩" : "shuan", + "拴" : "shuan", + "栓" : "shuan", + "涮" : "shuan", + "双" : "shuang", + "霜" : "shuang", + "孀" : "shuang", + "爽" : "shuang", + "谁" : "shui", + "水" : "shui", + "税" : "shui", + "睡" : "shui", + "吮" : "shun", + "顺" : "shun", + "舜" : "shun", + "瞬" : "shun", + "烁" : "shuo", + "铄" : "shuo", + "朔" : "shuo", + "硕" : "shuo", + "司" : "si", + "丝" : "si", + "私" : "si", + "咝" : "si", + "思" : "si", + "斯" : "si", + "厮" : "si", + "撕" : "si", + "嘶" : "si", + "死" : "si", + "巳" : "si", + "四" : "si", + "寺" : "si", + "祀" : "si", + "饲" : "si", + "肆" : "si", + "嗣" : "si", + "松" : "song", + "嵩" : "song", + "怂" : "song", + "耸" : "song", + "悚" : "song", + "讼" : "song", + "宋" : "song", + "送" : "song", + "诵" : "song", + "颂" : "song", + "搜" : "sou", + "嗖" : "sou", + "馊" : "sou", + "艘" : "sou", + "叟" : "sou", + "擞" : "sou", + "嗽" : "sou", + "苏" : "su", + "酥" : "su", + "俗" : "su", + "夙" : "su", + "诉" : "su", + "肃" : "su", + "素" : "su", + "速" : "su", + "粟" : "su", + "嗉" : "su", + "塑" : "su", + "溯" : "su", + "簌" : "su", + "酸" : "suan", + "蒜" : "suan", + "算" : "suan", + "虽" : "sui", + "睢" : "sui", + "绥" : "sui", + "隋" : "sui", + "随" : "sui", + "髓" : "sui", + "岁" : "sui", + "祟" : "sui", + "遂" : "sui", + "碎" : "sui", + "隧" : "sui", + "穗" : "sui", + "孙" : "sun", + "损" : "sun", + "笋" : "sun", + "隼" : "sun", + "唆" : "suo", + "梭" : "suo", + "蓑" : "suo", + "羧" : "suo", + "缩" : "suo", + "所" : "suo", + "索" : "suo", + "唢" : "suo", + "琐" : "suo", + "锁" : "suo", + "他" : "ta", + "它" : "ta", + "她" : "ta", + "铊" : "ta", + "塌" : "ta", + "塔" : "ta", + "獭" : "ta", + "挞" : "ta", + "榻" : "ta", + "踏" : "ta", + "蹋" : "ta", + "胎" : "tai", + "台" : "tai", + "邰" : "tai", + "抬" : "tai", + "苔" : "tai", + "跆" : "tai", + "太" : "tai", + "汰" : "tai", + "态" : "tai", + "钛" : "tai", + "泰" : "tai", + "酞" : "tai", + "贪" : "tan", + "摊" : "tan", + "滩" : "tan", + "瘫" : "tan", + "坛" : "tan", + "昙" : "tan", + "谈" : "tan", + "痰" : "tan", + "谭" : "tan", + "潭" : "tan", + "檀" : "tan", + "坦" : "tan", + "袒" : "tan", + "毯" : "tan", + "叹" : "tan", + "炭" : "tan", + "探" : "tan", + "碳" : "tan", + "汤" : "tang", + "嘡" : "tang", + "羰" : "tang", + "唐" : "tang", + "堂" : "tang", + "棠" : "tang", + "塘" : "tang", + "搪" : "tang", + "膛" : "tang", + "镗" : "tang", + "糖" : "tang", + "螳" : "tang", + "倘" : "tang", + "淌" : "tang", + "躺" : "tang", + "烫" : "tang", + "趟" : "tang", + "涛" : "tao", + "绦" : "tao", + "掏" : "tao", + "滔" : "tao", + "韬" : "tao", + "饕" : "tao", + "逃" : "tao", + "桃" : "tao", + "陶" : "tao", + "萄" : "tao", + "淘" : "tao", + "讨" : "tao", + "套" : "tao", + "特" : "te", + "疼" : "teng", + "腾" : "teng", + "誊" : "teng", + "滕" : "teng", + "藤" : "teng", + "剔" : "ti", + "梯" : "ti", + "踢" : "ti", + "啼" : "ti", + "题" : "ti", + "醍" : "ti", + "蹄" : "ti", + "体" : "ti", + "屉" : "ti", + "剃" : "ti", + "涕" : "ti", + "悌" : "ti", + "惕" : "ti", + "替" : "ti", + "天" : "tian", + "添" : "tian", + "田" : "tian", + "恬" : "tian", + "甜" : "tian", + "填" : "tian", + "忝" : "tian", + "殄" : "tian", + "舔" : "tian", + "掭" : "tian", + "佻" : "tiao", + "挑" : "tiao", + "条" : "tiao", + "迢" : "tiao", + "笤" : "tiao", + "髫" : "tiao", + "窕" : "tiao", + "眺" : "tiao", + "粜" : "tiao", + "跳" : "tiao", + "帖" : "tie", + "贴" : "tie", + "铁" : "tie", + "餮" : "tie", + "铤" : "ting", + "厅" : "ting", + "听" : "ting", + "烃" : "ting", + "廷" : "ting", + "亭" : "ting", + "庭" : "ting", + "停" : "ting", + "蜓" : "ting", + "婷" : "ting", + "霆" : "ting", + "挺" : "ting", + "艇" : "ting", + "通" : "tong", + "嗵" : "tong", + "同" : "tong", + "彤" : "tong", + "桐" : "tong", + "铜" : "tong", + "童" : "tong", + "潼" : "tong", + "瞳" : "tong", + "统" : "tong", + "捅" : "tong", + "桶" : "tong", + "筒" : "tong", + "恸" : "tong", + "痛" : "tong", + "偷" : "tou", + "头" : "tou", + "投" : "tou", + "骰" : "tou", + "透" : "tou", + "凸" : "tu", + "秃" : "tu", + "突" : "tu", + "图" : "tu", + "荼" : "tu", + "徒" : "tu", + "途" : "tu", + "涂" : "tu", + "屠" : "tu", + "土" : "tu", + "吐" : "tu", + "兔" : "tu", + "菟" : "tu", + "湍" : "tuan", + "团" : "tuan", + "疃" : "tuan", + "彖" : "tuan", + "推" : "tui", + "颓" : "tui", + "腿" : "tui", + "退" : "tui", + "蜕" : "tui", + "褪" : "tui", + "吞" : "tun", + "屯" : "tun", + "饨" : "tun", + "豚" : "tun", + "臀" : "tun", + "托" : "tuo", + "拖" : "tuo", + "脱" : "tuo", + "佗" : "tuo", + "陀" : "tuo", + "驼" : "tuo", + "鸵" : "tuo", + "妥" : "tuo", + "椭" : "tuo", + "唾" : "tuo", + "挖" : "wa", + "哇" : "wa", + "洼" : "wa", + "娲" : "wa", + "蛙" : "wa", + "娃" : "wa", + "瓦" : "wa", + "佤" : "wa", + "袜" : "wa", + "歪" : "wai", + "外" : "wai", + "弯" : "wan", + "剜" : "wan", + "湾" : "wan", + "蜿" : "wan", + "豌" : "wan", + "丸" : "wan", + "纨" : "wan", + "完" : "wan", + "玩" : "wan", + "顽" : "wan", + "烷" : "wan", + "宛" : "wan", + "挽" : "wan", + "晚" : "wan", + "惋" : "wan", + "婉" : "wan", + "绾" : "wan", + "皖" : "wan", + "碗" : "wan", + "万" : "wan", + "腕" : "wan", + "汪" : "wang", + "亡" : "wang", + "王" : "wang", + "网" : "wang", + "枉" : "wang", + "罔" : "wang", + "往" : "wang", + "惘" : "wang", + "妄" : "wang", + "忘" : "wang", + "旺" : "wang", + "望" : "wang", + "危" : "wei", + "威" : "wei", + "偎" : "wei", + "微" : "wei", + "煨" : "wei", + "薇" : "wei", + "巍" : "wei", + "韦" : "wei", + "为" : "wei", + "违" : "wei", + "围" : "wei", + "闱" : "wei", + "桅" : "wei", + "唯" : "wei", + "帷" : "wei", + "维" : "wei", + "伟" : "wei", + "伪" : "wei", + "苇" : "wei", + "纬" : "wei", + "委" : "wei", + "诿" : "wei", + "娓" : "wei", + "萎" : "wei", + "猥" : "wei", + "痿" : "wei", + "卫" : "wei", + "未" : "wei", + "位" : "wei", + "味" : "wei", + "畏" : "wei", + "胃" : "wei", + "谓" : "wei", + "喂" : "wei", + "猬" : "wei", + "渭" : "wei", + "蔚" : "wei", + "慰" : "wei", + "魏" : "wei", + "温" : "wen", + "瘟" : "wen", + "文" : "wen", + "纹" : "wen", + "闻" : "wen", + "蚊" : "wen", + "雯" : "wen", + "刎" : "wen", + "吻" : "wen", + "紊" : "wen", + "稳" : "wen", + "问" : "wen", + "汶" : "wen", + "翁" : "weng", + "嗡" : "weng", + "瓮" : "weng", + "挝" : "wo", + "莴" : "wo", + "倭" : "wo", + "喔" : "wo", + "窝" : "wo", + "蜗" : "wo", + "我" : "wo", + "肟" : "wo", + "沃" : "wo", + "卧" : "wo", + "握" : "wo", + "幄" : "wo", + "斡" : "wo", + "乌" : "wu", + "邬" : "wu", + "污" : "wu", + "巫" : "wu", + "呜" : "wu", + "钨" : "wu", + "诬" : "wu", + "屋" : "wu", + "无" : "wu", + "毋" : "wu", + "芜" : "wu", + "吴" : "wu", + "梧" : "wu", + "蜈" : "wu", + "五" : "wu", + "午" : "wu", + "伍" : "wu", + "仵" : "wu", + "怃" : "wu", + "忤" : "wu", + "妩" : "wu", + "武" : "wu", + "侮" : "wu", + "捂" : "wu", + "鹉" : "wu", + "舞" : "wu", + "兀" : "wu", + "勿" : "wu", + "戊" : "wu", + "务" : "wu", + "坞" : "wu", + "物" : "wu", + "误" : "wu", + "悟" : "wu", + "晤" : "wu", + "骛" : "wu", + "雾" : "wu", + "寤" : "wu", + "鹜" : "wu", + "夕" : "xi", + "兮" : "xi", + "西" : "xi", + "吸" : "xi", + "汐" : "xi", + "希" : "xi", + "昔" : "xi", + "析" : "xi", + "唏" : "xi", + "牺" : "xi", + "息" : "xi", + "奚" : "xi", + "悉" : "xi", + "烯" : "xi", + "惜" : "xi", + "晰" : "xi", + "稀" : "xi", + "翕" : "xi", + "犀" : "xi", + "皙" : "xi", + "锡" : "xi", + "溪" : "xi", + "熙" : "xi", + "蜥" : "xi", + "熄" : "xi", + "嘻" : "xi", + "膝" : "xi", + "嬉" : "xi", + "羲" : "xi", + "蟋" : "xi", + "曦" : "xi", + "习" : "xi", + "席" : "xi", + "袭" : "xi", + "媳" : "xi", + "洗" : "xi", + "玺" : "xi", + "徙" : "xi", + "喜" : "xi", + "禧" : "xi", + "戏" : "xi", + "细" : "xi", + "隙" : "xi", + "呷" : "xia", + "虾" : "xia", + "瞎" : "xia", + "匣" : "xia", + "侠" : "xia", + "峡" : "xia", + "狭" : "xia", + "遐" : "xia", + "瑕" : "xia", + "暇" : "xia", + "辖" : "xia", + "霞" : "xia", + "黠" : "xia", + "下" : "xia", + "夏" : "xia", + "罅" : "xia", + "仙" : "xian", + "先" : "xian", + "氙" : "xian", + "掀" : "xian", + "酰" : "xian", + "锨" : "xian", + "鲜" : "xian", + "闲" : "xian", + "贤" : "xian", + "弦" : "xian", + "咸" : "xian", + "涎" : "xian", + "娴" : "xian", + "衔" : "xian", + "舷" : "xian", + "嫌" : "xian", + "显" : "xian", + "险" : "xian", + "跣" : "xian", + "藓" : "xian", + "苋" : "xian", + "县" : "xian", + "现" : "xian", + "限" : "xian", + "线" : "xian", + "宪" : "xian", + "陷" : "xian", + "馅" : "xian", + "羡" : "xian", + "献" : "xian", + "腺" : "xian", + "乡" : "xiang", + "相" : "xiang", + "香" : "xiang", + "厢" : "xiang", + "湘" : "xiang", + "箱" : "xiang", + "襄" : "xiang", + "镶" : "xiang", + "详" : "xiang", + "祥" : "xiang", + "翔" : "xiang", + "享" : "xiang", + "响" : "xiang", + "饷" : "xiang", + "飨" : "xiang", + "想" : "xiang", + "向" : "xiang", + "项" : "xiang", + "象" : "xiang", + "像" : "xiang", + "橡" : "xiang", + "肖" : "xiao", + "枭" : "xiao", + "哓" : "xiao", + "骁" : "xiao", + "逍" : "xiao", + "消" : "xiao", + "宵" : "xiao", + "萧" : "xiao", + "硝" : "xiao", + "销" : "xiao", + "箫" : "xiao", + "潇" : "xiao", + "霄" : "xiao", + "魈" : "xiao", + "嚣" : "xiao", + "崤" : "xiao", + "淆" : "xiao", + "小" : "xiao", + "晓" : "xiao", + "孝" : "xiao", + "哮" : "xiao", + "笑" : "xiao", + "效" : "xiao", + "啸" : "xiao", + "挟" : "xie", + "些" : "xie", + "楔" : "xie", + "歇" : "xie", + "蝎" : "xie", + "协" : "xie", + "胁" : "xie", + "偕" : "xie", + "斜" : "xie", + "谐" : "xie", + "揳" : "xie", + "携" : "xie", + "撷" : "xie", + "鞋" : "xie", + "写" : "xie", + "泄" : "xie", + "泻" : "xie", + "卸" : "xie", + "屑" : "xie", + "械" : "xie", + "亵" : "xie", + "谢" : "xie", + "邂" : "xie", + "懈" : "xie", + "蟹" : "xie", + "心" : "xin", + "芯" : "xin", + "辛" : "xin", + "欣" : "xin", + "锌" : "xin", + "新" : "xin", + "歆" : "xin", + "薪" : "xin", + "馨" : "xin", + "鑫" : "xin", + "信" : "xin", + "衅" : "xin", + "星" : "xing", + "猩" : "xing", + "惺" : "xing", + "腥" : "xing", + "刑" : "xing", + "邢" : "xing", + "形" : "xing", + "型" : "xing", + "醒" : "xing", + "擤" : "xing", + "兴" : "xing", + "杏" : "xing", + "幸" : "xing", + "性" : "xing", + "姓" : "xing", + "悻" : "xing", + "凶" : "xiong", + "兄" : "xiong", + "匈" : "xiong", + "讻" : "xiong", + "汹" : "xiong", + "胸" : "xiong", + "雄" : "xiong", + "熊" : "xiong", + "休" : "xiu", + "咻" : "xiu", + "修" : "xiu", + "羞" : "xiu", + "朽" : "xiu", + "秀" : "xiu", + "袖" : "xiu", + "绣" : "xiu", + "锈" : "xiu", + "嗅" : "xiu", + "欻" : "xu", + "戌" : "xu", + "须" : "xu", + "胥" : "xu", + "虚" : "xu", + "墟" : "xu", + "需" : "xu", + "魆" : "xu", + "徐" : "xu", + "许" : "xu", + "诩" : "xu", + "栩" : "xu", + "旭" : "xu", + "序" : "xu", + "叙" : "xu", + "恤" : "xu", + "酗" : "xu", + "勖" : "xu", + "绪" : "xu", + "续" : "xu", + "絮" : "xu", + "婿" : "xu", + "蓄" : "xu", + "煦" : "xu", + "轩" : "xuan", + "宣" : "xuan", + "揎" : "xuan", + "喧" : "xuan", + "暄" : "xuan", + "玄" : "xuan", + "悬" : "xuan", + "旋" : "xuan", + "漩" : "xuan", + "璇" : "xuan", + "选" : "xuan", + "癣" : "xuan", + "炫" : "xuan", + "绚" : "xuan", + "眩" : "xuan", + "渲" : "xuan", + "靴" : "xue", + "薛" : "xue", + "穴" : "xue", + "学" : "xue", + "噱" : "xue", + "雪" : "xue", + "谑" : "xue", + "勋" : "xun", + "熏" : "xun", + "薰" : "xun", + "醺" : "xun", + "旬" : "xun", + "寻" : "xun", + "巡" : "xun", + "询" : "xun", + "荀" : "xun", + "循" : "xun", + "训" : "xun", + "讯" : "xun", + "汛" : "xun", + "迅" : "xun", + "驯" : "xun", + "徇" : "xun", + "逊" : "xun", + "殉" : "xun", + "巽" : "xun", + "丫" : "ya", + "压" : "ya", + "押" : "ya", + "鸦" : "ya", + "桠" : "ya", + "鸭" : "ya", + "牙" : "ya", + "伢" : "ya", + "芽" : "ya", + "蚜" : "ya", + "崖" : "ya", + "涯" : "ya", + "睚" : "ya", + "衙" : "ya", + "哑" : "ya", + "雅" : "ya", + "亚" : "ya", + "讶" : "ya", + "娅" : "ya", + "氩" : "ya", + "揠" : "ya", + "呀" : "ya", + "恹" : "yan", + "胭" : "yan", + "烟" : "yan", + "焉" : "yan", + "阉" : "yan", + "淹" : "yan", + "湮" : "yan", + "嫣" : "yan", + "延" : "yan", + "闫" : "yan", + "严" : "yan", + "言" : "yan", + "妍" : "yan", + "岩" : "yan", + "炎" : "yan", + "沿" : "yan", + "研" : "yan", + "盐" : "yan", + "阎" : "yan", + "蜒" : "yan", + "筵" : "yan", + "颜" : "yan", + "檐" : "yan", + "奄" : "yan", + "俨" : "yan", + "衍" : "yan", + "掩" : "yan", + "郾" : "yan", + "眼" : "yan", + "偃" : "yan", + "演" : "yan", + "魇" : "yan", + "鼹" : "yan", + "厌" : "yan", + "砚" : "yan", + "彦" : "yan", + "艳" : "yan", + "晏" : "yan", + "唁" : "yan", + "宴" : "yan", + "验" : "yan", + "谚" : "yan", + "堰" : "yan", + "雁" : "yan", + "焰" : "yan", + "滟" : "yan", + "餍" : "yan", + "燕" : "yan", + "赝" : "yan", + "央" : "yang", + "泱" : "yang", + "殃" : "yang", + "鸯" : "yang", + "秧" : "yang", + "扬" : "yang", + "羊" : "yang", + "阳" : "yang", + "杨" : "yang", + "佯" : "yang", + "疡" : "yang", + "徉" : "yang", + "洋" : "yang", + "仰" : "yang", + "养" : "yang", + "氧" : "yang", + "痒" : "yang", + "怏" : "yang", + "样" : "yang", + "恙" : "yang", + "烊" : "yang", + "漾" : "yang", + "幺" : "yao", + "夭" : "yao", + "吆" : "yao", + "妖" : "yao", + "腰" : "yao", + "邀" : "yao", + "爻" : "yao", + "尧" : "yao", + "肴" : "yao", + "姚" : "yao", + "窑" : "yao", + "谣" : "yao", + "摇" : "yao", + "徭" : "yao", + "遥" : "yao", + "瑶" : "yao", + "杳" : "yao", + "咬" : "yao", + "舀" : "yao", + "窈" : "yao", + "药" : "yao", + "要" : "yao", + "鹞" : "yao", + "耀" : "yao", + "耶" : "ye", + "掖" : "ye", + "椰" : "ye", + "噎" : "ye", + "爷" : "ye", + "揶" : "ye", + "也" : "ye", + "冶" : "ye", + "野" : "ye", + "业" : "ye", + "叶" : "ye", + "页" : "ye", + "曳" : "ye", + "夜" : "ye", + "液" : "ye", + "谒" : "ye", + "腋" : "ye", + "一" : "yi", + "伊" : "yi", + "衣" : "yi", + "医" : "yi", + "依" : "yi", + "咿" : "yi", + "揖" : "yi", + "壹" : "yi", + "漪" : "yi", + "噫" : "yi", + "仪" : "yi", + "夷" : "yi", + "饴" : "yi", + "宜" : "yi", + "咦" : "yi", + "贻" : "yi", + "姨" : "yi", + "胰" : "yi", + "移" : "yi", + "痍" : "yi", + "颐" : "yi", + "疑" : "yi", + "彝" : "yi", + "乙" : "yi", + "已" : "yi", + "以" : "yi", + "苡" : "yi", + "矣" : "yi", + "迤" : "yi", + "蚁" : "yi", + "倚" : "yi", + "椅" : "yi", + "旖" : "yi", + "乂" : "yi", + "亿" : "yi", + "义" : "yi", + "艺" : "yi", + "刈" : "yi", + "忆" : "yi", + "议" : "yi", + "屹" : "yi", + "亦" : "yi", + "异" : "yi", + "抑" : "yi", + "呓" : "yi", + "邑" : "yi", + "役" : "yi", + "译" : "yi", + "易" : "yi", + "诣" : "yi", + "绎" : "yi", + "驿" : "yi", + "轶" : "yi", + "弈" : "yi", + "奕" : "yi", + "疫" : "yi", + "羿" : "yi", + "益" : "yi", + "谊" : "yi", + "逸" : "yi", + "翌" : "yi", + "肄" : "yi", + "裔" : "yi", + "意" : "yi", + "溢" : "yi", + "缢" : "yi", + "毅" : "yi", + "薏" : "yi", + "翳" : "yi", + "臆" : "yi", + "翼" : "yi", + "因" : "yin", + "阴" : "yin", + "茵" : "yin", + "荫" : "yin", + "音" : "yin", + "姻" : "yin", + "铟" : "yin", + "喑" : "yin", + "愔" : "yin", + "吟" : "yin", + "垠" : "yin", + "银" : "yin", + "淫" : "yin", + "寅" : "yin", + "龈" : "yin", + "霪" : "yin", + "尹" : "yin", + "引" : "yin", + "蚓" : "yin", + "隐" : "yin", + "瘾" : "yin", + "印" : "yin", + "英" : "ying", + "莺" : "ying", + "婴" : "ying", + "嘤" : "ying", + "罂" : "ying", + "缨" : "ying", + "樱" : "ying", + "鹦" : "ying", + "膺" : "ying", + "鹰" : "ying", + "迎" : "ying", + "茔" : "ying", + "荧" : "ying", + "盈" : "ying", + "莹" : "ying", + "萤" : "ying", + "营" : "ying", + "萦" : "ying", + "楹" : "ying", + "蝇" : "ying", + "赢" : "ying", + "瀛" : "ying", + "颍" : "ying", + "颖" : "ying", + "影" : "ying", + "应" : "ying", + "映" : "ying", + "硬" : "ying", + "哟" : "yo", + "唷" : "yo", + "佣" : "yong", + "拥" : "yong", + "庸" : "yong", + "雍" : "yong", + "壅" : "yong", + "臃" : "yong", + "永" : "yong", + "甬" : "yong", + "咏" : "yong", + "泳" : "yong", + "勇" : "yong", + "涌" : "yong", + "恿" : "yong", + "蛹" : "yong", + "踊" : "yong", + "用" : "yong", + "优" : "you", + "攸" : "you", + "忧" : "you", + "呦" : "you", + "幽" : "you", + "悠" : "you", + "尤" : "you", + "由" : "you", + "邮" : "you", + "犹" : "you", + "油" : "you", + "铀" : "you", + "鱿" : "you", + "游" : "you", + "友" : "you", + "有" : "you", + "酉" : "you", + "莠" : "you", + "黝" : "you", + "又" : "you", + "右" : "you", + "幼" : "you", + "佑" : "you", + "柚" : "you", + "囿" : "you", + "诱" : "you", + "鼬" : "you", + "迂" : "yu", + "纡" : "yu", + "於" : "yu", + "淤" : "yu", + "瘀" : "yu", + "于" : "yu", + "余" : "yu", + "盂" : "yu", + "臾" : "yu", + "鱼" : "yu", + "竽" : "yu", + "俞" : "yu", + "狳" : "yu", + "谀" : "yu", + "娱" : "yu", + "渔" : "yu", + "隅" : "yu", + "揄" : "yu", + "逾" : "yu", + "腴" : "yu", + "渝" : "yu", + "愉" : "yu", + "瑜" : "yu", + "榆" : "yu", + "虞" : "yu", + "愚" : "yu", + "舆" : "yu", + "与" : "yu", + "予" : "yu", + "屿" : "yu", + "宇" : "yu", + "羽" : "yu", + "雨" : "yu", + "禹" : "yu", + "语" : "yu", + "圄" : "yu", + "玉" : "yu", + "驭" : "yu", + "芋" : "yu", + "妪" : "yu", + "郁" : "yu", + "育" : "yu", + "狱" : "yu", + "浴" : "yu", + "预" : "yu", + "域" : "yu", + "欲" : "yu", + "谕" : "yu", + "遇" : "yu", + "喻" : "yu", + "御" : "yu", + "寓" : "yu", + "裕" : "yu", + "愈" : "yu", + "誉" : "yu", + "豫" : "yu", + "鹬" : "yu", + "鸢" : "yuan", + "鸳" : "yuan", + "冤" : "yuan", + "渊" : "yuan", + "元" : "yuan", + "园" : "yuan", + "垣" : "yuan", + "袁" : "yuan", + "原" : "yuan", + "圆" : "yuan", + "援" : "yuan", + "媛" : "yuan", + "缘" : "yuan", + "猿" : "yuan", + "源" : "yuan", + "辕" : "yuan", + "远" : "yuan", + "苑" : "yuan", + "怨" : "yuan", + "院" : "yuan", + "愿" : "yuan", + "曰" : "yue", + "月" : "yue", + "岳" : "yue", + "钺" : "yue", + "阅" : "yue", + "悦" : "yue", + "跃" : "yue", + "越" : "yue", + "粤" : "yue", + "晕" : "yun", + "云" : "yun", + "匀" : "yun", + "芸" : "yun", + "纭" : "yun", + "耘" : "yun", + "允" : "yun", + "陨" : "yun", + "殒" : "yun", + "孕" : "yun", + "运" : "yun", + "酝" : "yun", + "愠" : "yun", + "韵" : "yun", + "蕴" : "yun", + "熨" : "yun", + "匝" : "za", + "咂" : "za", + "杂" : "za", + "砸" : "za", + "灾" : "zai", + "甾" : "zai", + "哉" : "zai", + "栽" : "zai", + "载" : "zai", + "宰" : "zai", + "崽" : "zai", + "再" : "zai", + "在" : "zai", + "糌" : "zan", + "簪" : "zan", + "咱" : "zan", + "趱" : "zan", + "暂" : "zan", + "錾" : "zan", + "赞" : "zan", + "赃" : "zang", + "脏" : "zang", + "臧" : "zang", + "驵" : "zang", + "葬" : "zang", + "遭" : "zao", + "糟" : "zao", + "凿" : "zao", + "早" : "zao", + "枣" : "zao", + "蚤" : "zao", + "澡" : "zao", + "藻" : "zao", + "皂" : "zao", + "灶" : "zao", + "造" : "zao", + "噪" : "zao", + "燥" : "zao", + "躁" : "zao", + "则" : "ze", + "责" : "ze", + "泽" : "ze", + "啧" : "ze", + "帻" : "ze", + "仄" : "ze", + "贼" : "zei", + "怎" : "zen", + "谮" : "zen", + "增" : "zeng", + "憎" : "zeng", + "锃" : "zeng", + "赠" : "zeng", + "甑" : "zeng", + "吒" : "zha", + "挓" : "zha", + "哳" : "zha", + "揸" : "zha", + "渣" : "zha", + "楂" : "zha", + "札" : "zha", + "闸" : "zha", + "铡" : "zha", + "眨" : "zha", + "砟" : "zha", + "乍" : "zha", + "诈" : "zha", + "咤" : "zha", + "炸" : "zha", + "蚱" : "zha", + "榨" : "zha", + "拃" : "zha", + "斋" : "zhai", + "摘" : "zhai", + "宅" : "zhai", + "窄" : "zhai", + "债" : "zhai", + "砦" : "zhai", + "寨" : "zhai", + "沾" : "zhan", + "毡" : "zhan", + "粘" : "zhan", + "詹" : "zhan", + "谵" : "zhan", + "瞻" : "zhan", + "斩" : "zhan", + "盏" : "zhan", + "展" : "zhan", + "崭" : "zhan", + "搌" : "zhan", + "辗" : "zhan", + "占" : "zhan", + "栈" : "zhan", + "战" : "zhan", + "站" : "zhan", + "绽" : "zhan", + "湛" : "zhan", + "蘸" : "zhan", + "张" : "zhang", + "章" : "zhang", + "獐" : "zhang", + "彰" : "zhang", + "樟" : "zhang", + "蟑" : "zhang", + "涨" : "zhang", + "掌" : "zhang", + "丈" : "zhang", + "仗" : "zhang", + "杖" : "zhang", + "帐" : "zhang", + "账" : "zhang", + "胀" : "zhang", + "障" : "zhang", + "嶂" : "zhang", + "瘴" : "zhang", + "钊" : "zhao", + "招" : "zhao", + "昭" : "zhao", + "找" : "zhao", + "沼" : "zhao", + "兆" : "zhao", + "诏" : "zhao", + "赵" : "zhao", + "照" : "zhao", + "罩" : "zhao", + "肇" : "zhao", + "蜇" : "zhe", + "遮" : "zhe", + "哲" : "zhe", + "辄" : "zhe", + "蛰" : "zhe", + "谪" : "zhe", + "辙" : "zhe", + "者" : "zhe", + "锗" : "zhe", + "赭" : "zhe", + "褶" : "zhe", + "浙" : "zhe", + "蔗" : "zhe", + "鹧" : "zhe", + "贞" : "zhen", + "针" : "zhen", + "侦" : "zhen", + "珍" : "zhen", + "帧" : "zhen", + "胗" : "zhen", + "真" : "zhen", + "砧" : "zhen", + "斟" : "zhen", + "甄" : "zhen", + "榛" : "zhen", + "箴" : "zhen", + "臻" : "zhen", + "诊" : "zhen", + "枕" : "zhen", + "疹" : "zhen", + "缜" : "zhen", + "阵" : "zhen", + "鸩" : "zhen", + "振" : "zhen", + "朕" : "zhen", + "赈" : "zhen", + "震" : "zhen", + "镇" : "zhen", + "争" : "zheng", + "征" : "zheng", + "怔" : "zheng", + "峥" : "zheng", + "狰" : "zheng", + "睁" : "zheng", + "铮" : "zheng", + "筝" : "zheng", + "蒸" : "zheng", + "拯" : "zheng", + "整" : "zheng", + "正" : "zheng", + "证" : "zheng", + "郑" : "zheng", + "诤" : "zheng", + "政" : "zheng", + "挣" : "zheng", + "症" : "zheng", + "之" : "zhi", + "支" : "zhi", + "只" : "zhi", + "汁" : "zhi", + "芝" : "zhi", + "吱" : "zhi", + "枝" : "zhi", + "知" : "zhi", + "肢" : "zhi", + "织" : "zhi", + "栀" : "zhi", + "脂" : "zhi", + "蜘" : "zhi", + "执" : "zhi", + "直" : "zhi", + "侄" : "zhi", + "值" : "zhi", + "职" : "zhi", + "植" : "zhi", + "跖" : "zhi", + "踯" : "zhi", + "止" : "zhi", + "旨" : "zhi", + "址" : "zhi", + "芷" : "zhi", + "纸" : "zhi", + "祉" : "zhi", + "指" : "zhi", + "枳" : "zhi", + "咫" : "zhi", + "趾" : "zhi", + "酯" : "zhi", + "至" : "zhi", + "志" : "zhi", + "豸" : "zhi", + "帜" : "zhi", + "制" : "zhi", + "质" : "zhi", + "炙" : "zhi", + "治" : "zhi", + "栉" : "zhi", + "峙" : "zhi", + "挚" : "zhi", + "桎" : "zhi", + "致" : "zhi", + "秩" : "zhi", + "掷" : "zhi", + "痔" : "zhi", + "窒" : "zhi", + "蛭" : "zhi", + "智" : "zhi", + "痣" : "zhi", + "滞" : "zhi", + "置" : "zhi", + "雉" : "zhi", + "稚" : "zhi", + "中" : "zhong", + "忠" : "zhong", + "终" : "zhong", + "盅" : "zhong", + "钟" : "zhong", + "衷" : "zhong", + "肿" : "zhong", + "冢" : "zhong", + "踵" : "zhong", + "仲" : "zhong", + "众" : "zhong", + "舟" : "zhou", + "州" : "zhou", + "诌" : "zhou", + "周" : "zhou", + "洲" : "zhou", + "粥" : "zhou", + "妯" : "zhou", + "轴" : "zhou", + "肘" : "zhou", + "纣" : "zhou", + "咒" : "zhou", + "宙" : "zhou", + "胄" : "zhou", + "昼" : "zhou", + "皱" : "zhou", + "骤" : "zhou", + "帚" : "zhou", + "朱" : "zhu", + "侏" : "zhu", + "诛" : "zhu", + "茱" : "zhu", + "珠" : "zhu", + "株" : "zhu", + "诸" : "zhu", + "铢" : "zhu", + "猪" : "zhu", + "蛛" : "zhu", + "竹" : "zhu", + "竺" : "zhu", + "逐" : "zhu", + "烛" : "zhu", + "躅" : "zhu", + "主" : "zhu", + "拄" : "zhu", + "煮" : "zhu", + "嘱" : "zhu", + "瞩" : "zhu", + "伫" : "zhu", + "苎" : "zhu", + "助" : "zhu", + "住" : "zhu", + "贮" : "zhu", + "注" : "zhu", + "驻" : "zhu", + "柱" : "zhu", + "祝" : "zhu", + "著" : "zhu", + "蛀" : "zhu", + "铸" : "zhu", + "筑" : "zhu", + "抓" : "zhua", + "跩" : "zhuai", + "拽" : "zhuai", + "专" : "zhuan", + "砖" : "zhuan", + "转" : "zhuan", + "啭" : "zhuan", + "撰" : "zhuan", + "篆" : "zhuan", + "妆" : "zhuang", + "庄" : "zhuang", + "桩" : "zhuang", + "装" : "zhuang", + "壮" : "zhuang", + "状" : "zhuang", + "撞" : "zhuang", + "幢" : "zhuang", + "追" : "zhui", + "骓" : "zhui", + "锥" : "zhui", + "坠" : "zhui", + "缀" : "zhui", + "惴" : "zhui", + "赘" : "zhui", + "谆" : "zhun", + "准" : "zhun", + "拙" : "zhuo", + "捉" : "zhuo", + "桌" : "zhuo", + "灼" : "zhuo", + "茁" : "zhuo", + "卓" : "zhuo", + "斫" : "zhuo", + "浊" : "zhuo", + "酌" : "zhuo", + "啄" : "zhuo", + "擢" : "zhuo", + "镯" : "zhuo", + "孜" : "zi", + "咨" : "zi", + "姿" : "zi", + "赀" : "zi", + "资" : "zi", + "辎" : "zi", + "嗞" : "zi", + "滋" : "zi", + "锱" : "zi", + "龇" : "zi", + "子" : "zi", + "姊" : "zi", + "秭" : "zi", + "籽" : "zi", + "梓" : "zi", + "紫" : "zi", + "訾" : "zi", + "滓" : "zi", + "自" : "zi", + "字" : "zi", + "恣" : "zi", + "眦" : "zi", + "渍" : "zi", + "宗" : "zong", + "综" : "zong", + "棕" : "zong", + "踪" : "zong", + "鬃" : "zong", + "总" : "zong", + "纵" : "zong", + "粽" : "zong", + "邹" : "zou", + "走" : "zou", + "奏" : "zou", + "揍" : "zou", + "租" : "zu", + "足" : "zu", + "卒" : "zu", + "族" : "zu", + "诅" : "zu", + "阻" : "zu", + "组" : "zu", + "俎" : "zu", + "祖" : "zu", + "纂" : "zuan", + "钻" : "zuan", + "攥" : "zuan", + "嘴" : "zui", + "最" : "zui", + "罪" : "zui", + "醉" : "zui", + "尊" : "zun", + "遵" : "zun", + "樽" : "zun", + "鳟" : "zun", + "昨" : "zuo", + "左" : "zuo", + "佐" : "zuo", + "作" : "zuo", + "坐" : "zuo", + "阼" : "zuo", + "怍" : "zuo", + "祚" : "zuo", + "唑" : "zuo", + "座" : "zuo", + "做" : "zuo", + "酢" : "zuo", + "斌" : "bin", + "曾" : "zeng", + "查" : "zha", + "査" : "zha", + "乘" : "cheng", + "传" : "chuan", + "丁" : "ding", + "行" : "xing", + "瑾" : "jin", + "婧" : "jing", + "恺" : "kai", + "阚" : "kan", + "奎" : "kui", + "乐" : "le", + "陆" : "lu", + "逯" : "lv", + "璐" : "lu", + "淼" : "miao", + "闵" : "min", + "娜" : "na", + "奇" : "qi", + "琦" : "qi", + "强" : "qiang", + "邱" : "qiu", + "芮" : "rui", + "莎" : "sha", + "盛" : "sheng", + "石" : "shi", + "祎" : "yi", + "殷" : "yin", + "瑛" : "ying", + "昱" : "yu", + "眃" : "yun", + "琢" : "zhuo", + "枰" : "ping", + "玟" : "min", + "珉" : "min", + "珣" : "xun", + "淇" : "qi", + "缈" : "miao", + "彧" : "yu", + "祺" : "qi", + "骞" : "qian", + "垚" : "yao", + "妸" : "e", + "烜" : "hui", + "祁" : "qi", + "傢" : "jia", + "珮" : "pei", + "濮" : "pu", + "屺" : "qi", + "珅" : "shen", + "缇" : "ti", + "霈" : "pei", + "晞" : "xi", + "璠" : "fan", + "骐" : "qi", + "姞" : "ji", + "偲" : "cai", + "齼" : "chu", + "宓" : "mi", + "朴" : "pu", + "萁" : "qi", + "颀" : "qi", + "阗" : "tian", + "湉" : "tian", + "翀" : "chong", + "岷" : "min", + "桤" : "qi", + "囯" : "guo", + "浛" : "han", + "勐" : "meng", + "苠" : "min", + "岍" : "qian", + "皞" : "hao", + "岐" : "qi", + "溥" : "pu", + "锘" : "muo", + "渼" : "mei", + "燊" : "shen", + "玚" : "chang", + "亓" : "qi", + "湋" : "wei", + "涴" : "wan", + "沤" : "ou", + "胖" : "pang", + "莆" : "pu", + "扦" : "qian", + "僳" : "su", + "坍" : "tan", + "锑" : "ti", + "嚏" : "ti", + "腆" : "tian", + "丿" : "pie", + "鼗" : "tao", + "芈" : "mi", + "匚" : "fang", + "刂" : "li", + "冂" : "tong", + "亻" : "dan", + "仳" : "pi", + "俜" : "ping", + "俳" : "pai", + "倜" : "ti", + "傥" : "tang", + "傩" : "nuo", + "佥" : "qian", + "勹" : "bao", + "亠" : "tou", + "廾" : "gong", + "匏" : "pao", + "扌" : "ti", + "拚" : "pin", + "掊" : "pou", + "搦" : "nuo", + "擗" : "pi", + "啕" : "tao", + "嗦" : "suo", + "嗍" : "suo", + "辔" : "pei", + "嘌" : "piao", + "嗾" : "sou", + "嘧" : "mi", + "帔" : "pei", + "帑" : "tang", + "彡" : "san", + "犭" : "fan", + "狍" : "pao", + "狲" : "sun", + "狻" : "jun", + "飧" : "sun", + "夂" : "zhi", + "饣" : "shi", + "庀" : "pi", + "忄" : "shu", + "愫" : "su", + "闼" : "ta", + "丬" : "jiang", + "氵" : "san", + "汔" : "qi", + "沔" : "mian", + "汨" : "mi", + "泮" : "pan", + "洮" : "tao", + "涑" : "su", + "淠" : "pi", + "湓" : "pen", + "溻" : "ta", + "溏" : "tang", + "濉" : "sui", + "宀" : "bao", + "搴" : "qian", + "辶" : "zou", + "逄" : "pang", + "逖" : "ti", + "遢" : "ta", + "邈" : "miao", + "邃" : "sui", + "彐" : "ji", + "屮" : "cao", + "娑" : "suo", + "嫖" : "piao", + "纟" : "jiao", + "缗" : "min", + "瑭" : "tang", + "杪" : "miao", + "桫" : "suo", + "榀" : "pin", + "榫" : "sun", + "槭" : "qi", + "甓" : "pi", + "攴" : "po", + "耆" : "qi", + "牝" : "pin", + "犏" : "pian", + "氆" : "pu", + "攵" : "fan", + "肽" : "tai", + "胼" : "pian", + "脒" : "mi", + "脬" : "pao", + "旆" : "pei", + "炱" : "tai", + "燧" : "sui", + "灬" : "biao", + "礻" : "shi", + "祧" : "tiao", + "忑" : "te", + "忐" : "tan", + "愍" : "min", + "肀" : "yu", + "碛" : "qi", + "眄" : "mian", + "眇" : "miao", + "眭" : "sui", + "睃" : "suo", + "瞍" : "sou", + "畋" : "tian", + "罴" : "pi", + "蠓" : "meng", + "蠛" : "mie", + "笸" : "po", + "筢" : "pa", + "衄" : "nv", + "艋" : "meng", + "敉" : "mi", + "糸" : "mi", + "綦" : "qi", + "醅" : "pei", + "醣" : "tang", + "趿" : "ta", + "觫" : "su", + "龆" : "tiao", + "鲆" : "ping", + "稣" : "su", + "鲐" : "tai", + "鲦" : "tiao", + "鳎" : "ta", + "髂" : "qia", + "縻" : "mi", + "裒" : "pou", + "冫" : "liang", + "冖" : "tu", + "讠" : "yan", + "谇" : "sui", + "谝" : "pian", + "谡" : "su", + "卩" : "dan", + "阝" : "zuo", + "陴" : "pi", + "邳" : "pi", + "郫" : "pi", + "郯" : "tan", + "廴" : "yin", + "凵" : "qian", + "圮" : "pi", + "堋" : "peng", + "鼙" : "pi", + "艹" : "cao", + "芑" : "qi", + "苤" : "pie", + "荪" : "sun", + "荽" : "sui", + "葜" : "qia", + "蒎" : "pai", + "蔌" : "su", + "蕲" : "qi", + "薮" : "sou", + "薹" : "tai", + "蘼" : "mi", + "钅" : "jin", + "钷" : "po", + "钽" : "tan", + "铍" : "pi", + "铴" : "tang", + "铽" : "te", + "锫" : "pei", + "锬" : "tan", + "锼" : "sou", + "镤" : "pu", + "镨" : "pu", + "皤" : "po", + "鹈" : "ti", + "鹋" : "miao", + "疒" : "bing", + "疱" : "pao", + "衤" : "yi", + "袢" : "pan", + "裼" : "ti", + "襻" : "pan", + "耥" : "tang", + "耦" : "ou", + "虍" : "hu", + "蛴" : "qi", + "蜞" : "qi", + "蜱" : "pi", + "螋" : "sou", + "螗" : "tang", + "螵" : "piao", + "蟛" : "peng" +} diff --git a/public/kirby/i18n/translations/bg.json b/public/kirby/i18n/translations/bg.json new file mode 100644 index 0000000..94c0d78 --- /dev/null +++ b/public/kirby/i18n/translations/bg.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Change your name", + "account.delete": "Delete your account", + "account.delete.confirm": "Do you really want to delete your account? You will be logged out immediately. Your account cannot be recovered.", + + "activate": "Activate", + "add": "\u0414\u043e\u0431\u0430\u0432\u0438", + "alpha": "Alpha", + "author": "Author", + "avatar": "Профилна снимка", + "back": "Назад", + "cancel": "\u041e\u0442\u043a\u0430\u0436\u0438", + "change": "\u041f\u0440\u043e\u043c\u0435\u043d\u0438", + "close": "\u0417\u0430\u0442\u0432\u043e\u0440\u0438", + "changes": "Changes", + "confirm": "Ок", + "collapse": "Collapse", + "collapse.all": "Collapse All", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Копирай", + "copy.all": "Copy all", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Създай", + "custom": "Custom", + + "date": "Дата", + "date.select": "Select a date", + + "day": "Day", + "days.fri": "\u041f\u0442", + "days.mon": "\u041f\u043d", + "days.sat": "\u0421\u0431", + "days.sun": "\u041d\u0434", + "days.thu": "\u0427\u0442", + "days.tue": "\u0412\u0442", + "days.wed": "\u0421\u0440", + + "debugging": "Debugging", + + "delete": "\u0418\u0437\u0442\u0440\u0438\u0439", + "delete.all": "Delete all", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No files to select", + "dialog.pages.empty": "No pages to select", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No users to select", + + "dimensions": "Размери", + "disable": "Disable", + "disabled": "Disabled", + "discard": "\u041e\u0442\u043c\u0435\u043d\u0438", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Download", + "duplicate": "Duplicate", + + "edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u0430\u0439", + + "email": "Email", + "email.placeholder": "mail@example.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Environment", + + "error": "Error", + "error.access.code": "Invalid code", + "error.access.login": "Invalid login", + "error.access.panel": "Нямате права за достъп до панела", + "error.access.view": "You are not allowed to access this part of the panel", + + "error.avatar.create.fail": "Профилната снимка не може да се качи", + "error.avatar.delete.fail": "Профилната снимка не може да бъде изтрита", + "error.avatar.dimensions.invalid": "Моля запазете ширината и височината на профилната снимка под 3000 пиксела", + "error.avatar.mime.forbidden": "Профилната снимка трябва да бъде в JPEG или PNG формат", + + "error.blueprint.notFound": "Образецът \"{name}\" не може да бъде зареден", + + "error.blocks.max.plural": "You must not add more than {max} blocks", + "error.blocks.max.singular": "You must not add more than one block", + "error.blocks.min.plural": "You must add at least {min} blocks", + "error.blocks.min.singular": "You must add at least one block", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "Email шаблонът \"{name}\" не може да бъде открит", + + "error.field.converter.invalid": "Невалиден конвертор \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "The name must not be empty", + "error.file.changeName.permission": "Не можете да смените името на \"{filename}\"", + "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": "Файл с име \"{filename}\" вече съществува", + "error.file.extension.forbidden": "Файловото разширение \"{extension}\" не е позволено", + "error.file.extension.invalid": "Invalid extension: {extension}", + "error.file.extension.missing": "Липсва файлово разширение за файла \"{filename}\"", + "error.file.maxheight": "The height of the image must not exceed {height} pixels", + "error.file.maxsize": "The file is too large", + "error.file.maxwidth": "The width of the image must not exceed {width} pixels", + "error.file.mime.differs": "Каченият файл трябва да бъде от същия mime тип \"{mime}\"", + "error.file.mime.forbidden": "The media type \"{mime}\" is not allowed", + "error.file.mime.invalid": "Invalid mime type: {mime}", + "error.file.mime.missing": "The media type for \"{filename}\" cannot be detected", + "error.file.minheight": "The height of the image must be at least {height} pixels", + "error.file.minsize": "The file is too small", + "error.file.minwidth": "The width of the image must be at least {width} pixels", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Името на файла е задължително", + "error.file.notFound": "Файлът \"{filename}\" не може да бъде намерен", + "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": "Не е позволен ъплоуда на файлове от тип {type}", + "error.file.type.invalid": "Invalid file type: {type}", + "error.file.undefined": "\u0424\u0430\u0439\u043b\u044a\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d", + + "error.form.incomplete": "Моля коригирайте всички грешки във формата...", + "error.form.notSaved": "Формата не може да бъде запазена", + + "error.language.code": "Please enter a valid code for the language", + "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": "The language already exists", + "error.language.name": "Please enter a valid name for the language", + "error.language.notFound": "The language could not be found", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "There's an error in layout {index} settings", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Моля въведете валиден email адрес", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "The license could not be verified", + + "error.login.totp.confirm.invalid": "Invalid code", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "The Panel is currently offline", + + "error.page.changeSlug.permission": "Не можете да смените URL на \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{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": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.", + "error.page.delete.permission": "Не можете да изтриете \"{slug}\"", + "error.page.draft.duplicate": "Вече съществува чернова с URL-добавка \"{slug}\"", + "error.page.duplicate": "Страница с URL-добавка \"{slug}\" вече съществува", + "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"", + "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.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}\"", + "error.page.notFound": "Страницата \"{slug}\" не може да бъде намерена", + "error.page.num.invalid": "Моля въведете валидно число за сортиране. Числата не трябва да са негативни.", + "error.page.slug.invalid": "Please enter a valid URL appendix", + "error.page.slug.maxlength": "Slug length must be less than \"{length}\" characters", + "error.page.sort.permission": "Страницата \"{slug}\" не може да бъде сортирана", + "error.page.status.invalid": "Моля изберете валиден статус на страницата", + "error.page.undefined": "\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u0430\u0442\u0430 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0430", + "error.page.update.permission": "Не можете да обновите \"{slug}\"", + + "error.section.files.max.plural": "Не можете да добавяте повече от {max} файлa в секция \"{section}\"", + "error.section.files.max.singular": "Не можете да добавяте повече от един файл в секция \"{section}\"", + "error.section.files.min.plural": "The \"{section}\" section requires at least {min} files", + "error.section.files.min.singular": "The \"{section}\" section requires at least one file", + + "error.section.pages.max.plural": "Не можете да добавяте повече от {max} страници в секция \"{section}\"", + "error.section.pages.max.singular": "Не можете да добавяте повече от една страница в секция \"{section}\"", + "error.section.pages.min.plural": "The \"{section}\" section requires at least {min} pages", + "error.section.pages.min.singular": "The \"{section}\" section requires at least one page", + + "error.section.notLoaded": "Секция \"{name}\" не може да бъде заредена", + "error.section.type.invalid": "Типът \"{type}\" на секция не е валиден", + + "error.site.changeTitle.empty": "Заглавието е задължително", + "error.site.changeTitle.permission": "Не може да променяте заглавието на сайта", + "error.site.update.permission": "Нямате права за да обновите сайта", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Стандартният шаблон не съществува", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: 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": "You are not allowed to promote someone to the admin role", + "error.user.create.permission": "Нямате права да създадете този потребител", + "error.user.delete": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u044f\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0438\u0437\u0442\u0440\u0438\u0442", + "error.user.delete.lastAdmin": "\u041d\u0435 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0438\u0437\u0442\u0440\u0438\u0435\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u044f \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440", + "error.user.delete.lastUser": "Последният потребител не може да бъде изтрит", + "error.user.delete.permission": "\u041d\u0435 \u0435 \u043f\u043e\u0437\u0432\u043e\u043b\u0435\u043d\u043e \u0434\u0430 \u0438\u0437\u0442\u0440\u0438\u0432\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b", + "error.user.duplicate": "Потребител с имейл \"{email}\" вече съществува", + "error.user.email.invalid": "Моля въведете валиден email адрес", + "error.user.language.invalid": "Моля въведете валиден език", + "error.user.notFound": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u044f\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d.", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Моля въведете валидна парола. Тя трабва да съдържа поне 8 символа.", + "error.user.password.notSame": "\u041c\u043e\u043b\u044f, \u043f\u043e\u0442\u0432\u044a\u0440\u0434\u0435\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430", + "error.user.password.undefined": "Потребителят няма парола", + "error.user.password.wrong": "Wrong password", + "error.user.role.invalid": "Моля въведете валидна роля", + "error.user.undefined": "Потребителят не може да бъде намерен.", + "error.user.update.permission": "Нямате права да обновите този потребител \"{name}\"", + + "error.validation.accepted": "Моля потвърдете", + "error.validation.alpha": "Моля въвдете символи измежду a-z", + "error.validation.alphanum": "Моля въвдете символи измежду a-z или цифри 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Моля въведете стойност между \"{min}\" и \"{max}\"", + "error.validation.boolean": "Моля потвърдете или откажете", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Моля въведете стойност, която съдържа \"{needle}\"", + "error.validation.date": "Моля въведете валидна дата", + "error.validation.date.after": "Please enter a date after {date}", + "error.validation.date.before": "Please enter a date before {date}", + "error.validation.date.between": "Please enter a date between {min} and {max}", + "error.validation.denied": "Моля откажете", + "error.validation.different": "Стойността не трябва да е \"{other}\"", + "error.validation.email": "Моля въведете валиден email адрес", + "error.validation.endswith": "Стойността трябва да завършва с \"{end\"}", + "error.validation.filename": "Моля въведете валидно име на файла", + "error.validation.in": "Моля въведете едно от следните: ({in})", + "error.validation.integer": "Моля въведете валидно цяло число", + "error.validation.ip": "Моля въведете валиден IP адрес", + "error.validation.less": "Моля въведете стойност по-ниска от {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Стойността не съвпада с очаквания модел", + "error.validation.max": "Please enter a value equal to or lower than {max}", + "error.validation.maxlength": "Моля въведете по-къса стойност. (макс. {max} символа)", + "error.validation.maxwords": "Моля въведете не повече от {max} дума(и)", + "error.validation.min": "Please enter a value equal to or greater than {min}", + "error.validation.minlength": "Моля въведете по-дълга стойност. (мин. {min} символа)", + "error.validation.minwords": "Моля въведете поне {min} дума(и).", + "error.validation.more": "Моля въведете стойност по-висока от {min}", + "error.validation.notcontains": "Моля въведете стойност, която не съдържа \"{needle}\"", + "error.validation.notin": "Моля не въвеждайте нито едно от следните: ({notIn})", + "error.validation.option": "Моля изберете валидна опция", + "error.validation.num": "Моля въведете валидно число", + "error.validation.required": "Моля въведете нещо", + "error.validation.same": "Моля въведете \"{other}\"", + "error.validation.size": "Размерът на стойността трябва да бъде \"{size}\"", + "error.validation.startswith": "Стойността трябва да започва с \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Моля въведете валидно време", + "error.validation.time.after": "Please enter a time after {time}", + "error.validation.time.before": "Please enter a time before {time}", + "error.validation.time.between": "Please enter a time between {min} and {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Моля въведете валиден URL", + + "expand": "Expand", + "expand.all": "Expand All", + + "field.invalid": "The field is invalid", + "field.required": "The field is required", + "field.blocks.changeType": "Change type", + "field.blocks.code.name": "Код", + "field.blocks.code.language": "Език", + "field.blocks.code.placeholder": "Your code …", + "field.blocks.delete.confirm": "Do you really want to delete this block?", + "field.blocks.delete.confirm.all": "Do you really want to delete all blocks?", + "field.blocks.delete.confirm.selected": "Do you really want to delete the selected blocks?", + "field.blocks.empty": "No blocks yet", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Please select a block type …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Gallery", + "field.blocks.gallery.images.empty": "No images yet", + "field.blocks.gallery.images.label": "Images", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Heading", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Heading …", + "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": "Alternative text", + "field.blocks.image.caption": "Caption", + "field.blocks.image.crop": "Crop", + "field.blocks.image.link": "Връзка", + "field.blocks.image.location": "Location", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Изображение", + "field.blocks.image.placeholder": "Select an image", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Image URL", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "List", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Quote", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Quote …", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "by …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Caption", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Location", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Enter a video URL", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "Все още няма статии", + + "field.files.empty": "Все още не са избрани файлове", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Delete layout", + "field.layout.delete.confirm": "Do you really want to delete this layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "No rows yet", + "field.layout.select": "Select a layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Все още не са избрани страници", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Сигурни ли сте, че искате да изтриете това вписване?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Все още няма статии", + + "field.users.empty": "Все още не са избрани потребители", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Файл", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Промени шаблон", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Сигурни ли сте, че искате да изтриете
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Change position", + + "files": "Файлове", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Няма файлове", + + "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", + "import": "Import", + "info": "Info", + "insert": "\u0412\u043c\u044a\u043a\u043d\u0438", + "insert.after": "Insert after", + "insert.before": "Insert before", + "install": "Инсталирай", + + "installation": "Инсталация", + "installation.completed": "The panel has been installed", + "installation.disabled": "The panel installer is disabled on public servers by default. Please run the installer on a local machine or enable it with the panel.install option.", + "installation.issues.accounts": "Папката /site/accounts не съществува или не позволява запис", + "installation.issues.content": "Папката /content и всички файлове в нея трябва да позволяват запис", + "installation.issues.curl": "Изисква се CURL разширението", + "installation.issues.headline": "Панелът не може да бъде инсталиран", + "installation.issues.mbstring": "Изисква се разширението MB String", + "installation.issues.media": "Папката /media не съществува или няма права за запис", + "installation.issues.php": "Бъдете сигурни, че използвате PHP 8+", + "installation.issues.sessions": "The /site/sessions folder does not exist or is not writable", + + "language": "\u0415\u0437\u0438\u043a", + "language.code": "Код", + "language.convert": "Направи по подразбиране", + "language.convert.confirm": "

Сигурни ли сте, че искате да зададете {name} за език по подразбиране? Действието не може да бъде отменено.

В случай, че в {name} има непреведено съдържание, то части от сайта ви могат да останат празни.

", + "language.create": "Добавете нов език", + "language.default": "Език по подразбиране", + "language.delete.confirm": "Сигурни ли сте, че искате да изтриете език {name}, включително всички негови преводи? Действието не може да бъде отменено!", + "language.deleted": "Езикът беше изтрит", + "language.direction": "Посока на четене", + "language.direction.ltr": "Отляво надясно", + "language.direction.rtl": "Отдясно наляво", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Име", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Езикът беше обновен", + "language.variables": "Language variables", + "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", + + "languages": "Езици", + "languages.default": "Език по подразбиране", + "languages.empty": "Все още няма добавени езици", + "languages.secondary": "Второстепенни езици", + "languages.secondary.empty": "Все още няма второстепенни езици", + + "license": "\u041b\u0438\u0446\u0435\u043d\u0437 \u0437\u0430 Kirby", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Купи лиценз", + "license.code": "Код", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Please enter your license code", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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", + "license.unregistered.label": "Unregistered", + + "link": "\u0412\u0440\u044a\u0437\u043a\u0430", + "link.text": "Текстова връзка", + + "loading": "Зареждане", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more 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}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Подписване", + "login.code.label.login": "Login code", + "login.code.label.password-reset": "Password reset code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "If your email address is registered, the requested code was sent via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Your login code", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Your password reset code", + "login.remember": "Keep me logged in", + "login.reset": "Reset password", + "login.toggleText.code.email": "Login via email", + "login.toggleText.code.email-password": "Login with password", + "login.toggleText.password-reset.email": "Forgot your password?", + "login.toggleText.password-reset.email-password": "← Back to login", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Изход", + + "merge": "Merge", + "menu": "Меню", + "meridiem": "AM/PM", + "mime": "Media Type", + "minutes": "Minutes", + + "month": "Month", + "months.april": "\u0410\u043f\u0440\u0438\u043b", + "months.august": "\u0410\u0432\u0433\u0443\u0441\u0442", + "months.december": "\u0414\u0435\u043a\u0435\u043c\u0432\u0440\u0438", + "months.february": "Февруари", + "months.january": "\u042f\u043d\u0443\u0430\u0440\u0438", + "months.july": "\u042e\u043b\u0438", + "months.june": "\u042e\u043d\u0438", + "months.march": "\u041c\u0430\u0440\u0442", + "months.may": "\u041c\u0430\u0439", + "months.november": "\u041d\u043e\u0435\u043c\u0432\u0440\u0438", + "months.october": "\u041e\u043a\u0442\u043e\u043c\u0432\u0440\u0438", + "months.september": "\u0421\u0435\u043f\u0442\u0435\u043c\u0432\u0440\u0438", + + "more": "Още", + "move": "Move", + "name": "Име", + "next": "Next", + "night": "Night", + "no": "no", + "off": "off", + "on": "on", + "open": "Отвори", + "open.newWindow": "Open in new window", + "option": "Option", + "options": "Options", + "options.none": "No options", + "options.all": "Show all {count} options", + + "orientation": "Ориентация", + "orientation.landscape": "Пейзаж", + "orientation.portrait": "Портрет", + "orientation.square": "Квадрат", + + "page": "Страница", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "\u041f\u0440\u043e\u043c\u0435\u043d\u0438 URL", + "page.changeSlug.fromTitle": "\u0421\u044a\u0437\u0434\u0430\u0439\u0442\u0435 \u043e\u0442 \u0437\u0430\u0433\u043b\u0430\u0432\u0438\u0435\u0442\u043e", + "page.changeStatus": "Промени статус", + "page.changeStatus.position": "Моля изберете позиция", + "page.changeStatus.select": "Изберете нов статус", + "page.changeTemplate": "Промени шаблон", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Сигурни ли сте, че искате да изтриете {title}?", + "page.delete.confirm.subpages": "Тази страница има подстраници.
Всички подстраници също ще бъдат изтрити.", + "page.delete.confirm.title": "Въведи заглавие на страница за да потвърдиш", + "page.duplicate.appendix": "Копирай", + "page.duplicate.files": "Copy files", + "page.duplicate.pages": "Copy pages", + "page.move": "Move page", + "page.sort": "Change position", + "page.status": "Status", + "page.status.draft": "Чернова", + "page.status.draft.description": "The page is in draft mode and only visible for logged in editors or via secret link", + "page.status.listed": "Публично", + "page.status.listed.description": "Страницата е публична за всички", + "page.status.unlisted": "Скрит", + "page.status.unlisted.description": "Страницата е достъпна само чрез URL", + + "pages": "Страници", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Все още няма страници", + "pages.status.draft": "Drafts", + "pages.status.listed": "Published", + "pages.status.unlisted": "Скрит", + + "pagination.page": "Страница", + + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "Пиксел", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Previous", + "preview": "Preview", + + "publish": "Publish", + "published": "Published", + + "remove": "Премахни", + "rename": "Преименувай", + "renew": "Renew", + "replace": "\u0417\u0430\u043c\u0435\u0441\u0442\u0438", + "replace.with": "Replace with", + "retry": "\u041e\u043f\u0438\u0442\u0430\u0439 \u043f\u0430\u043a", + "revert": "\u041e\u0442\u043c\u0435\u043d\u0438", + "revert.confirm": "Do you really want to delete all unsaved changes?", + + "role": "\u0420\u043e\u043b\u044f", + "role.admin.description": "The admin has all rights", + "role.admin.title": "Admin", + "role.all": "Всички", + "role.empty": "Не съществуват потребители с тази роля", + "role.description.placeholder": "Липсва описание", + "role.nobody.description": "This is a fallback role without any permissions", + "role.nobody.title": "Nobody", + + "save": "\u0417\u0430\u043f\u0438\u0448\u0438", + "saved": "Saved", + "search": "Търси", + "searching": "Searching", + "search.min": "Enter {min} characters to search", + "search.all": "Show all {count} results", + "search.results.none": "No results", + + "section.invalid": "The section is invalid", + "section.required": "The section is required", + + "security": "Security", + "select": "Избери", + "server": "Server", + "settings": "Настройки", + "show": "Show", + "site.blueprint": "The site has no blueprint yet. You can define the setup in /site/blueprints/site.yml", + "size": "Размер", + "slug": "URL-\u0434\u043e\u0431\u0430\u0432\u043a\u0430", + "sort": "Сортирай", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Образец", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Заглавие", + "today": "Днес", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Код", + "toolbar.button.bold": "\u041f\u043e\u043b\u0443\u0447\u0435\u0440 \u0448\u0440\u0438\u0444\u0442", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Заглавия", + "toolbar.button.heading.1": "Заглавие 1", + "toolbar.button.heading.2": "Заглавие 2", + "toolbar.button.heading.3": "Заглавие 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "\u041d\u0430\u043a\u043b\u043e\u043d\u0435\u043d \u0448\u0440\u0438\u0444\u0442", + "toolbar.button.file": "Файл", + "toolbar.button.file.select": "Select a file", + "toolbar.button.file.upload": "Upload a file", + "toolbar.button.link": "\u0412\u0440\u044a\u0437\u043a\u0430", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Strike-through", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Подреден списък", + "toolbar.button.underline": "Underline", + "toolbar.button.ul": "Списък", + + "translation.author": "Kirby екип", + "translation.direction": "ltr", + "translation.name": "Български", + "translation.locale": "bg_BG", + + "type": "Type", + + "upload": "Прикачи", + "upload.error.cantMove": "The uploaded file could not be moved", + "upload.error.cantWrite": "Failed to write file to disk", + "upload.error.default": "The file could not be uploaded", + "upload.error.extension": "File upload stopped by extension", + "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form", + "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini", + "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini", + "upload.error.noFile": "No file was uploaded", + "upload.error.noFiles": "No files were uploaded", + "upload.error.partial": "The uploaded file was only partially uploaded", + "upload.error.tmpDir": "Missing a temporary folder", + "upload.errors": "Грешка", + "upload.progress": "Uploading…", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Потребител", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Промени email", + "user.changeLanguage": "Промени език", + "user.changeName": "Преименувай този потребител", + "user.changePassword": "Промени парола", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Нова парола", + "user.changePassword.new.confirm": "Потвърдете новата парола...", + "user.changeRole": "Променете роля", + "user.changeRole.select": "Изберете нова роля", + "user.create": "Добавете нов потребител", + "user.delete": "Изтрийте потребителя", + "user.delete.confirm": "Сигурни ли сте, че искате да изтриете
{email}?", + + "users": "Потребители", + + "version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 Kirby", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "\u0412\u0430\u0448\u0438\u044f \u0430\u043a\u0430\u0443\u043d\u0442", + "view.installation": "\u0418\u043d\u0441\u0442\u0430\u043b\u0430\u0446\u0438\u044f", + "view.languages": "Езици", + "view.resetPassword": "Reset password", + "view.site": "Сайт", + "view.system": "System", + "view.users": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0438", + + "welcome": "Добре дошли", + "year": "Year", + "yes": "yes" +} 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 new file mode 100644 index 0000000..bd43a3c --- /dev/null +++ b/public/kirby/i18n/translations/ca.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Change your name", + "account.delete": "Delete your account", + "account.delete.confirm": "Do you really want to delete your account? You will be logged out immediately. Your account cannot be recovered.", + + "activate": "Activate", + "add": "Afegir", + "alpha": "Alpha", + "author": "Author", + "avatar": "Imatge del perfil", + "back": "Tornar", + "cancel": "Cancel\u00b7lar", + "change": "Canviar", + "close": "Tancar", + "changes": "Changes", + "confirm": "Ok", + "collapse": "Col·lapsar", + "collapse.all": "Col·lapsar tot", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Copiar", + "copy.all": "Copy all", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Crear", + "custom": "Custom", + + "date": "Data", + "date.select": "Selecciona una data", + + "day": "Dia", + "days.fri": "dv.", + "days.mon": "dl.", + "days.sat": "ds.", + "days.sun": "dg.", + "days.thu": "dj.", + "days.tue": "dt.", + "days.wed": "dc.", + + "debugging": "Debugging", + + "delete": "Eliminar", + "delete.all": "Eliminar tot", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No hi ha cap fitxer per seleccionar", + "dialog.pages.empty": "No hi ha cap pàgina per seleccionar", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No hi ha cap usuari per seleccionar", + + "dimensions": "Dimensions", + "disable": "Disable", + "disabled": "Desactivat", + "discard": "Descartar", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Descarregar", + "duplicate": "Duplicar", + + "edit": "Editar", + + "email": "Email", + "email.placeholder": "mail@exemple.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Environment", + + "error": "Error", + "error.access.code": "Codi invàlid", + "error.access.login": "Inici de sessió no vàlid", + "error.access.panel": "No tens permís per accedir al panell", + "error.access.view": "No tens accés a aquesta part del tauler", + + "error.avatar.create.fail": "No s'ha pogut carregar la imatge del perfil", + "error.avatar.delete.fail": "La imatge del perfil no s'ha pogut eliminar", + "error.avatar.dimensions.invalid": "Mantingueu l'amplada i l'alçada de la imatge de perfil de menys de 3000 píxels", + "error.avatar.mime.forbidden": "La imatge del perfil ha de ser fitxers JPEG o PNG", + + "error.blueprint.notFound": "No s'ha potgut carregar el blueprint \"{name}\"", + + "error.blocks.max.plural": "You must not add more than {max} blocks", + "error.blocks.max.singular": "You must not add more than one block", + "error.blocks.min.plural": "You must add at least {min} blocks", + "error.blocks.min.singular": "You must add at least one block", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "No es pot trobar la configuració de correu electrònic \"{name}\"", + + "error.field.converter.invalid": "Convertidor no vàlid \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "El nom no pot estar buit", + "error.file.changeName.permission": "No tens permís per canviar el nom de \"{filename}\"", + "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": "Ja existeix un fitxer amb el nom \"{filename}\"", + "error.file.extension.forbidden": "L'extensió de l'arxiu \"{extension}\" no està permesa", + "error.file.extension.invalid": "Invalid extension: {extension}", + "error.file.extension.missing": "Falta l'extensió de l'arxiu \"{filename}\"", + "error.file.maxheight": "L'alçada de la imatge no ha de ser superior a {height} píxels", + "error.file.maxsize": "El fitxer és massa gran", + "error.file.maxwidth": "L'amplada de la imatge no ha de ser superior a {width} píxels", + "error.file.mime.differs": "L'arxiu carregat ha ha de ser del mateix tipus de mime \"{mime}\"", + "error.file.mime.forbidden": "El tipus de mitjà \"{mime}\" no està permès", + "error.file.mime.invalid": "Mime type no vàlid: {mime}", + "error.file.mime.missing": "El tipus de suport per a \"{filename}\" no es pot detectar", + "error.file.minheight": "L'alçada de la imatge ha de ser com a mínim de {height} píxels", + "error.file.minsize": "El fitxer és massa petit", + "error.file.minwidth": "L'amplada de la imatge ha de ser com a mínim de {width} píxels", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "El nom del fitxer no pot estar buit", + "error.file.notFound": "L'arxiu \"{filename}\" no s'ha trobat", + "error.file.orientation": "L’orientació de la imatge ha de ser \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "No tens permís per penjar fitxers {type}", + "error.file.type.invalid": "Invalid file type: {type}", + "error.file.undefined": "L'arxiu no s'ha trobat", + + "error.form.incomplete": "Si us plau, corregeix els errors del formulari ...", + "error.form.notSaved": "No s'ha pogut desar el formulari", + + "error.language.code": "Introdueix un codi vàlid per a l’idioma", + "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": "L'idioma ja existeix", + "error.language.name": "Introdueix un nom vàlid per a l'idioma", + "error.language.notFound": "The language could not be found", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "There's an error in layout {index} settings", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Si us plau, introdueix una adreça de correu electrònic vàlida", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "No s’ha pogut verificar la llicència", + + "error.login.totp.confirm.invalid": "Codi invàlid", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "The Panel is currently offline", + + "error.page.changeSlug.permission": "No teniu permís per canviar l'apèndix d'URL per a \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "La pàgina té errors i no es pot publicar", + "error.page.changeStatus.permission": "No es pot canviar l'estat d'aquesta pàgina", + "error.page.changeStatus.toDraft.invalid": "La pàgina \"{slug}\" no es pot convertir en un esborrany", + "error.page.changeTemplate.invalid": "La plantilla per a la pàgina \"{slug}\" no es pot canviar", + "error.page.changeTemplate.permission": "No tens permís per canviar la plantilla per \"{slug}\"", + "error.page.changeTitle.empty": "El títol no pot estar buit", + "error.page.changeTitle.permission": "No tens permís per canviar el títol de \"{slug}\"", + "error.page.create.permission": "No tens permís per crear \"{slug}\"", + "error.page.delete": "La pàgina \"{slug}\" no es pot esborrar", + "error.page.delete.confirm": "Si us plau, introdueix el títol de la pàgina per confirmar", + "error.page.delete.hasChildren": "La pàgina té subpàgines i no es pot esborrar", + "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": "No tens permís per esborrar \"{slug}\"", + "error.page.draft.duplicate": "Ja existeix un esborrany de pàgina amb l'apèndix d'URL \"{slug}\"", + "error.page.duplicate": "Ja existeix una pàgina amb l'apèndix d'URL \"{slug}\"", + "error.page.duplicate.permission": "No tens permís per duplicar \"{slug}\"", + "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.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}\"", + "error.page.notFound": "La pàgina \"{slug}\" no s'ha trobat", + "error.page.num.invalid": "Si us plau, introdueix un número d 'ordenació vàlid. Els números no poden ser negatius.", + "error.page.slug.invalid": "Please enter a valid URL appendix", + "error.page.slug.maxlength": "La longitud del nom ha de tenir menys de caràcters \"{length}\"", + "error.page.sort.permission": "La pàgina \"{slug}\" no es pot ordenar", + "error.page.status.invalid": "Si us plau, estableix un estat de pàgina vàlid", + "error.page.undefined": "La p\u00e0gina no s'ha trobat", + "error.page.update.permission": "No tens permís per actualitzar \"{slug}\"", + + "error.section.files.max.plural": "No has d'afegir més de {max} fitxers a la secció \"{section}\"", + "error.section.files.max.singular": "No podeu afegir més d'un fitxer a la secció \"{section}\"", + "error.section.files.min.plural": "La secció \"{section}\" requereix almenys {min} fitxer", + "error.section.files.min.singular": "La secció \"{section}\" requereix almenys un fitxer", + + "error.section.pages.max.plural": "No heu d'afegir més de {max} pàgines a la secció \"{section}\"", + "error.section.pages.max.singular": "No podeu afegir més d'una pàgina a la secció \"{section}\"", + "error.section.pages.min.plural": "La secció \"{section}\" requereix almenys {min} pàgines", + "error.section.pages.min.singular": "La secció \"{section}\" requereix almenys una pàgina", + + "error.section.notLoaded": "No s'ha pogut carregar la secció \"{name}\"", + "error.section.type.invalid": "La secció tipus \"{type}\" no és vàlida", + + "error.site.changeTitle.empty": "El títol no pot estar buit", + "error.site.changeTitle.permission": "No tens permís per canviar el títol del lloc web", + "error.site.update.permission": "No tens permís per actualitzar el lloc web", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "La plantilla predeterminada no existeix", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "No tens permís per canviar el correu electrònic per a l'usuari \"{name}\"", + "error.user.changeLanguage.permission": "No tens permís per canviar l'idioma de l'usuari \"{name}\"", + "error.user.changeName.permission": "No tens permís per canviar el nom de l'usuari \"{name}\"", + "error.user.changePassword.permission": "No tens permís per canviar la contrasenya de l'usuari \"{name}\"", + "error.user.changeRole.lastAdmin": "El rol del darrer administrador no es pot canviar", + "error.user.changeRole.permission": "No tens permís per canviar el rol de l'usuari \"{name}\"", + "error.user.changeRole.toAdmin": "No tens permís per promocionar algú al rol d’administrador", + "error.user.create.permission": "No tens permís per crear aquest usuari", + "error.user.delete": "L'usuari \"{name}\" no es pot eliminar", + "error.user.delete.lastAdmin": "No es pot eliminar l'\u00faltim administrador", + "error.user.delete.lastUser": "El darrer usuari no es pot eliminar", + "error.user.delete.permission": "No pots eliminar l'usuari \"{name}\"", + "error.user.duplicate": "Ja existeix un usuari amb l'adreça electrònica \"{email}\"", + "error.user.email.invalid": "Si us plau, introdueix una adreça de correu electrònic vàlida", + "error.user.language.invalid": "Introduïu un idioma vàlid", + "error.user.notFound": "L'usuari \"{name}\" no s'ha trobat", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Introduïu una contrasenya vàlida. Les contrasenyes han de tenir com a mínim 8 caràcters.", + "error.user.password.notSame": "Les contrasenyes no coincideixen", + "error.user.password.undefined": "L'usuari no té una contrasenya", + "error.user.password.wrong": "Wrong password", + "error.user.role.invalid": "Si us plau, introdueix un rol vàlid", + "error.user.undefined": "L'usuari no s'ha trobat", + "error.user.update.permission": "No tens permís per actualitzar l'usuari \"{name}\"", + + "error.validation.accepted": "Si us plau confirma", + "error.validation.alpha": "Si us plau, introdueix únicament caràcters entre a-z", + "error.validation.alphanum": "Si us plau, introdueix únicament caràcters entre a-z o números de 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Introdueix un valor entre \"{min}\" i \"{max}\"", + "error.validation.boolean": "Si us plau confirma o denega", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Si us plau, introduïu un valor que contingui \"{needle}\"", + "error.validation.date": "Si us plau, introdueix una data vàlida", + "error.validation.date.after": "Introdueix una data posterior {date}", + "error.validation.date.before": "Introdueix una data anterior {date}", + "error.validation.date.between": "Introdueix una data entre {min} i {max}", + "error.validation.denied": "Si us plau, denegui", + "error.validation.different": "El valor no ha de ser \"{other}\"", + "error.validation.email": "Si us plau, introdueix una adreça de correu electrònic vàlida", + "error.validation.endswith": "El valor ha de finalitzar amb \"{end}\"", + "error.validation.filename": "Si us plau, introdueix un nom de fitxer vàlid", + "error.validation.in": "Si us plau, introduïu una de les opcions següents: ({in})", + "error.validation.integer": "Si us plau, introduïu un nombre enter vàlid", + "error.validation.ip": "Si us plau, introduïu una adreça IP vàlida", + "error.validation.less": "Si us plau, introduïu un valor inferior a {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "El valor no coincideix amb el patró esperat", + "error.validation.max": "Si us plau, introduïu un valor igual o inferior a {max}", + "error.validation.maxlength": "Si us plau, introduïu un valor més curt. (màxim {max} caràcters)", + "error.validation.maxwords": "Si us plau, introduïu no més de {max} paraula(es)", + "error.validation.min": "Si us plau, introduïu un valor igual o superior a {min}", + "error.validation.minlength": "Si us plau, introduïu un valor més llarg. (min. {min} caràcters)", + "error.validation.minwords": "Si us plau, introduïu almenys {min} paraula(es)", + "error.validation.more": "Si us plau, introduïu un valor més gran que {min}", + "error.validation.notcontains": "Introduïu un valor que no contingui \"{needle}\"", + "error.validation.notin": "Si us plau, no introduïu cap d'aquests elements: ({notIn})", + "error.validation.option": "Si us plau, seleccioneu una opció vàlida", + "error.validation.num": "Si us plau, introduïu un número vàlid", + "error.validation.required": "Si us plau, introduïu alguna cosa", + "error.validation.same": "Si us plau, introduïu \"{other}\"", + "error.validation.size": "La mida del valor ha de ser \"{size}\"", + "error.validation.startswith": "El valor ha de començar amb \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Si us plau, introduïu una hora vàlida", + "error.validation.time.after": "Please enter a time after {time}", + "error.validation.time.before": "Please enter a time before {time}", + "error.validation.time.between": "Please enter a time between {min} and {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Si us plau, introduïu una URL vàlida", + + "expand": "Expandir", + "expand.all": "Expandir tot", + + "field.invalid": "The field is invalid", + "field.required": "El camp és obligatori", + "field.blocks.changeType": "Change type", + "field.blocks.code.name": "Codi", + "field.blocks.code.language": "Idioma", + "field.blocks.code.placeholder": "Your code …", + "field.blocks.delete.confirm": "Do you really want to delete this block?", + "field.blocks.delete.confirm.all": "Do you really want to delete all blocks?", + "field.blocks.delete.confirm.selected": "Do you really want to delete the selected blocks?", + "field.blocks.empty": "No blocks yet", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Please select a block type …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Gallery", + "field.blocks.gallery.images.empty": "No images yet", + "field.blocks.gallery.images.label": "Images", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Heading", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Heading …", + "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": "Alternative text", + "field.blocks.image.caption": "Caption", + "field.blocks.image.crop": "Crop", + "field.blocks.image.link": "Enllaç", + "field.blocks.image.location": "Location", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Imatge", + "field.blocks.image.placeholder": "Select an image", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Image URL", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "List", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Quote", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Quote …", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "by …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Caption", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Location", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Enter a video URL", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "Encara no hi ha entrades.", + + "field.files.empty": "Encara no hi ha cap fitxer seleccionat", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Delete layout", + "field.layout.delete.confirm": "Do you really want to delete this layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "No rows yet", + "field.layout.select": "Select a layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Encara no s'ha seleccionat cap pàgina", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Segur que voleu eliminar aquesta fila?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Encara no hi ha entrades.", + + "field.users.empty": "Encara no s'ha seleccionat cap usuari", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Arxiu", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Canviar la plantilla", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Esteu segurs d'eliminar
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Change position", + + "files": "Arxius", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Encara no hi ha fitxers", + + "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": "Hora", + "hue": "Hue", + "import": "Import", + "info": "Info", + "insert": "Insertar", + "insert.after": "Insert after", + "insert.before": "Insert before", + "install": "Instal·lar", + + "installation": "Instal·lació", + "installation.completed": "S'ha instal·lat el panell", + "installation.disabled": "L'instal·lador del panell està desactivat per defecte als servidors públics. Si us plau, executeu l'instal·lador en una màquina local o habiliteu-lo amb l'opció panel.install", + "installation.issues.accounts": "La carpeta /site/accounts no existeix o no es pot escriure", + "installation.issues.content": "La carpeta /content no existeix o no es pot escriure", + "installation.issues.curl": "Es requereix l'extensió CURL", + "installation.issues.headline": "El panell no es pot instal·lar", + "installation.issues.mbstring": "Es requereix l'extensió de MB String", + "installation.issues.media": "La carpeta /media no existeix o no es pot escriure", + "installation.issues.php": "Assegureu-vos d'utilitzar PHP 8+", + "installation.issues.sessions": "La carpeta /site/sessions no existeix o no es pot escriure", + + "language": "Idioma", + "language.code": "Codi", + "language.convert": "Fer per defecte", + "language.convert.confirm": "

Segur que voleu convertir {name} a l'idioma predeterminat? Això no es pot desfer.

Si {name} té contingut no traduït, ja no podreu tornar enrere i algunes parts del vostre lloc poden quedar buides.

", + "language.create": "Afegir un nou idioma", + "language.default": "Idioma per defecte", + "language.delete.confirm": "Segur que voleu eliminar l'idioma {name} incloent totes les traduccions? Això no es pot desfer!", + "language.deleted": "S'ha suprimit l'idioma", + "language.direction": "Direcció de lectura", + "language.direction.ltr": "Esquerra a dreta", + "language.direction.rtl": "De dreta a esquerra", + "language.locale": "Cadena local de PHP", + "language.locale.warning": "S'està fent servir una configuració regional personalitzada. Modifica el fitxer d'idioma a /site/languages", + "language.name": "Nom", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "S'ha actualitzat l'idioma", + "language.variables": "Language variables", + "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", + + "languages": "Idiomes", + "languages.default": "Idioma per defecte", + "languages.empty": "Encara no hi ha cap idioma", + "languages.secondary": "Idiomes secundaris", + "languages.secondary.empty": "Encara no hi ha idiomes secundaris", + + "license": "Llic\u00e8ncia Kirby", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Comprar una llicència", + "license.code": "Codi", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Si us plau, introdueixi el seu codi de llicència", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Gràcies per donar suport a Kirby", + "license.unregistered.label": "Unregistered", + + "link": "Enlla\u00e7", + "link.text": "Enllaç de text", + + "loading": "Carregant", + + "lock.unsaved": "Canvis no guardats", + "lock.unsaved.empty": "Ja no hi ha canvis no guardats", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Desbloquejar", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Entrar", + "login.code.label.login": "Login code", + "login.code.label.password-reset": "Password reset code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "If your email address is registered, the requested code was sent via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Your login code", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Your password reset code", + "login.remember": "Manten-me connectat", + "login.reset": "Reset password", + "login.toggleText.code.email": "Login via email", + "login.toggleText.code.email-password": "Login with password", + "login.toggleText.password-reset.email": "Forgot your password?", + "login.toggleText.password-reset.email-password": "← Back to login", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Tancar sessió", + + "merge": "Merge", + "menu": "Menú", + "meridiem": "AM/PM", + "mime": "Tipus de mitjà", + "minutes": "Minuts", + + "month": "Mes", + "months.april": "Abril", + "months.august": "Agost", + "months.december": "Desembre", + "months.february": "Febrer", + "months.january": "Gener", + "months.july": "Juliol", + "months.june": "Juny", + "months.march": "Mar\u00e7", + "months.may": "Maig", + "months.november": "Novembre", + "months.october": "Octubre", + "months.september": "Setembre", + + "more": "Més", + "move": "Move", + "name": "Nom", + "next": "Següent", + "night": "Night", + "no": "no", + "off": "apagat", + "on": "encès", + "open": "Obrir", + "open.newWindow": "Open in new window", + "option": "Option", + "options": "Opcions", + "options.none": "Sense opcions", + "options.all": "Show all {count} options", + + "orientation": "Orientació", + "orientation.landscape": "Horitzontal", + "orientation.portrait": "Vertical", + "orientation.square": "Quadrat", + + "page": "Page", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Canviar URL", + "page.changeSlug.fromTitle": "Crear a partir del t\u00edtol", + "page.changeStatus": "Canviar l'estat", + "page.changeStatus.position": "Si us plau, seleccioneu una posició", + "page.changeStatus.select": "Seleccioneu un nou estat", + "page.changeTemplate": "Canviar la plantilla", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Segur que voleu eliminar {title}?", + "page.delete.confirm.subpages": "Aquesta pàgina té subpàgines.
Totes les subpàgines també s'eliminaran.", + "page.delete.confirm.title": "Introduïu el títol de la pàgina per confirmar", + "page.duplicate.appendix": "Copiar", + "page.duplicate.files": "Copiar fitxers", + "page.duplicate.pages": "Copiar pàgines", + "page.move": "Move page", + "page.sort": "Change position", + "page.status": "Estat", + "page.status.draft": "Esborrany", + "page.status.draft.description": "La pàgina està en mode d'esborrany i només és visible per als editors registrats o a través d'un enllaç secret", + "page.status.listed": "Públic", + "page.status.listed.description": "La pàgina és pública per a tothom", + "page.status.unlisted": "Sense classificar", + "page.status.unlisted.description": "La pàgina només es pot accedir a través de l'URL", + + "pages": "Pàgines", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Encara no hi ha pàgines", + "pages.status.draft": "Esborranys", + "pages.status.listed": "Publicat", + "pages.status.unlisted": "Sense classificar", + + "pagination.page": "Pàgina", + + "password": "Contrasenya", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Anterior", + "preview": "Preview", + + "publish": "Publish", + "published": "Publicat", + + "remove": "Eliminar", + "rename": "Canviar el nom", + "renew": "Renew", + "replace": "Reempla\u00e7ar", + "replace.with": "Replace with", + "retry": "Reintentar", + "revert": "Revertir", + "revert.confirm": "Segur que voleu eliminar tots els canvis pendents desar?", + + "role": "Rol", + "role.admin.description": "L’administrador té tots els permisos", + "role.admin.title": "Administrador", + "role.all": "Tots", + "role.empty": "No hi ha usuaris amb aquest rol", + "role.description.placeholder": "Sense descripció", + "role.nobody.description": "Aquest és un rol per defecte sense permisos", + "role.nobody.title": "Ningú", + + "save": "Desar", + "saved": "Saved", + "search": "Cercar", + "searching": "Searching", + "search.min": "Introduïu {min} caràcters per cercar", + "search.all": "Show all {count} results", + "search.results.none": "Sense resultats", + + "section.invalid": "The section is invalid", + "section.required": "La secció és obligatòria", + + "security": "Security", + "select": "Seleccionar", + "server": "Server", + "settings": "Configuració", + "show": "Show", + "site.blueprint": "The site has no blueprint yet. You can define the setup in /site/blueprints/site.yml", + "size": "Tamany", + "slug": "URL-ap\u00e8ndix", + "sort": "Ordenar", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Estat", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Plantilla", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Títol", + "today": "Avui", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Codi", + "toolbar.button.bold": "Negreta", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Encapçalaments", + "toolbar.button.heading.1": "Encapçalament 1", + "toolbar.button.heading.2": "Encapçalament 2", + "toolbar.button.heading.3": "Encapçalament 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "Cursiva", + "toolbar.button.file": "Arxiu", + "toolbar.button.file.select": "Selecciona un fitxer", + "toolbar.button.file.upload": "Carrega un fitxer", + "toolbar.button.link": "Enlla\u00e7", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Strike-through", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Llista ordenada", + "toolbar.button.underline": "Underline", + "toolbar.button.ul": "Llista de vinyetes", + + "translation.author": "Equip Kirby", + "translation.direction": "ltr", + "translation.name": "Catalan", + "translation.locale": "ca_ES", + + "type": "Type", + + "upload": "Carregar", + "upload.error.cantMove": "El fitxer carregat no s'ha pogut moure", + "upload.error.cantWrite": "No s'ha pogut escriure el fitxer al disc", + "upload.error.default": "No s'ha pogut carregar el fitxer", + "upload.error.extension": "La càrrega del fitxer s'ha aturat per l'extensió", + "upload.error.formSize": "El fitxer carregat supera la directiva MAX_FILE_SIZE especificada en el formulari", + "upload.error.iniPostSize": "El fitxer carregat supera la directiva post_max_size especifiada al php.ini", + "upload.error.iniSize": "El fitxer carregat supera la directiva upload_max_filesize especifiada al php.ini", + "upload.error.noFile": "No s'ha carregat cap fitxer", + "upload.error.noFiles": "No s'ha penjat cap fitxer", + "upload.error.partial": "El fitxer carregat només s'ha carregat parcialment", + "upload.error.tmpDir": "Falta una carpeta temporal", + "upload.errors": "Error", + "upload.progress": "Carregant...", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Usuari", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Canviar e-mail", + "user.changeLanguage": "Canviar idioma", + "user.changeName": "Canviar el nom d'aquest usuari", + "user.changePassword": "Canviar contrasenya", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Nova contrasenya", + "user.changePassword.new.confirm": "Confirma la nova contrasenya ...", + "user.changeRole": "Canviar el rol", + "user.changeRole.select": "Seleccionar un nou rol", + "user.create": "Afegir un nou usuari", + "user.delete": "Eliminar aquest usuari", + "user.delete.confirm": "Segur que voleu eliminar
{email}?", + + "users": "Usuaris", + + "version": "Versi\u00f3 de Kirby", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "La teva compta", + "view.installation": "Instal·lació", + "view.languages": "Idiomes", + "view.resetPassword": "Reset password", + "view.site": "Lloc web", + "view.system": "System", + "view.users": "Usuaris", + + "welcome": "Benvinguda", + "year": "Any", + "yes": "yes" +} diff --git a/public/kirby/i18n/translations/cs.json b/public/kirby/i18n/translations/cs.json new file mode 100644 index 0000000..0d9bc10 --- /dev/null +++ b/public/kirby/i18n/translations/cs.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Změnit jméno", + "account.delete": "Smazat účet", + "account.delete.confirm": "Opravdu chcete smazat svůj účet? Budete okamžitě odhlášeni. Účet nemůže být zpětně obnoven.", + + "activate": "Aktivovat", + "add": "P\u0159idat", + "alpha": "Alfa", + "author": "Autor", + "avatar": "Profilov\u00fd obr\u00e1zek", + "back": "Zpět", + "cancel": "Zru\u0161it", + "change": "Zm\u011bnit", + "close": "Zavřít", + "changes": "Změny", + "confirm": "Ok", + "collapse": "Sbalit", + "collapse.all": "Sbalit vše", + "color": "Barva", + "coordinates": "Souřadnice", + "copy": "Kopírovat", + "copy.all": "Kopírovat vše", + "copy.success": "{count} zkopírováno!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Kopírovat URL", + "create": "Vytvořit", + "custom": "Vlastní", + + "date": "Datum", + "date.select": "Vyberte datum", + + "day": "Den", + "days.fri": "p\u00e1", + "days.mon": "po", + "days.sat": "so", + "days.sun": "ne", + "days.thu": "\u010dt", + "days.tue": "\u00fat", + "days.wed": "st", + + "debugging": "Ladění", + + "delete": "Smazat", + "delete.all": "Smazat vše", + + "dialog.fields.empty": "Tento dialog neobsahuje žádná pole", + "dialog.files.empty": "Žádné soubory k výběru", + "dialog.pages.empty": "Žádné stránky k výběru", + "dialog.text.empty": "Tento dialog nemá definovaný žádný text", + "dialog.users.empty": "Žádní uživatelé k výběru", + + "dimensions": "Rozměry", + "disable": "Deaktivovat", + "disabled": "Zakázáno", + "discard": "Zahodit", + + "drawer.fields.empty": "Tento vysouvací panel nemá žádná pole", + + "domain": "Doména", + "download": "Stáhnout", + "duplicate": "Duplikovat", + + "edit": "Upravit", + + "email": "Email", + "email.placeholder": "mail@example.com", + + "enter": "Zapsat", + "entries": "Záznamy", + "entry": "Záznam", + + "environment": "Prostředí", + + "error": "Chyba", + "error.access.code": "Neplatný kód", + "error.access.login": "Neplatné přihlášení", + "error.access.panel": "Nemáte oprávnění k přihlášení do panelu", + "error.access.view": "Nemáte oprávnění ke vstupu do této části panelu.", + + "error.avatar.create.fail": "Nebylo možné nahrát profilový obrázek", + "error.avatar.delete.fail": "Nebylo mo\u017en\u00e9 smazat profilov\u00fd obr\u00e1zek", + "error.avatar.dimensions.invalid": "Šířka a výška obrázku musí být pod 3000 pixelů", + "error.avatar.mime.forbidden": "Profilový obrázek musí být ve formátu JPEG nebo PNG", + + "error.blueprint.notFound": "Nelze načíst blueprint \"{name}\" ", + + "error.blocks.max.plural": "Nelze přidat více něž {max} bloků", + "error.blocks.max.singular": "Nelze přidat více než jeden blok", + "error.blocks.min.plural": "Musíte přidat alespoň {min} bloků", + "error.blocks.min.singular": "Musíte přidat alespoň jeden blok", + "error.blocks.validation": "V poli \"{field}\" v bloku {index} je při použití \"{fieldset}\" typu chyba", + + "error.cache.type.invalid": "Neplatný typ cache \"{type}\"", + + "error.content.lock.delete": "Tato verze je uzamčená a nelze jí smazat", + "error.content.lock.move": "Zdrojová verze je uzamčená a nemůže být přesunuta", + "error.content.lock.publish": "Tato verze je již zveřejněná", + "error.content.lock.replace": "Tato verze je uzamčená a nemůže být nahrazena", + "error.content.lock.update": "Tato verze je uzamčená a nemůže být aktualizována", + + "error.entries.max.plural": "Nelze přidat více než {max} záznamů", + "error.entries.max.singular": "Nelze přidat více než jeden záznam", + "error.entries.min.plural": "Musíte přidat alespoň {min} záznamů", + "error.entries.min.singular": "Musíte přidat alespoň jeden záznam", + "error.entries.supports": "\"{type}\" typ pole není pro záznamy podporován", + "error.entries.validation": "Chyba v poli \"{field}\" na řádku {index}", + + "error.email.preset.notFound": "Nelze nalézt emailové přednastavení \"{name}\"", + + "error.field.converter.invalid": "Neplatný konvertor \"{converter}\"", + "error.field.link.options": "Neplatné volby: {options}", + "error.field.type.missing": "Pole \"{ name }\": Typ pole \"{ type }\" neexistuje", + + "error.file.changeName.empty": "Toto jméno nesmí být prázdné", + "error.file.changeName.permission": "Nemáte povoleno změnit jméno souboru \"{filename}\"", + "error.file.changeTemplate.invalid": "Šablonu souboru \"{id}\" nelze změnit na \"{template}\" (platné: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Nemáte dovoleno změnit šablonu souboru \"{id}\"", + + "error.file.delete.multiple": "Nebylo možné vymazat všechny soubory. Zkuste zbývající soubory vymazat postupně, abyste nalezli chybu, která bránila hromadnému smazání.", + "error.file.duplicate": "Soubor s názvem \"{filename}\" již existuje", + "error.file.extension.forbidden": "Přípona souboru \"{extension}\" není povolena", + "error.file.extension.invalid": "Neplatná přípona souboru: {extension}", + "error.file.extension.missing": "Nem\u016f\u017eete nahr\u00e1t soubor bez p\u0159\u00edpony", + "error.file.maxheight": "Výška obrázku nesmí přesáhnout {height} pixelů", + "error.file.maxsize": "Soubor je příliš velký", + "error.file.maxwidth": "Šířka obrázku nesmí přesáhnout {width} pixelů", + "error.file.mime.differs": "Nahraný soubor musí být stejného typu \"{mime}\"", + "error.file.mime.forbidden": "Soubor typu \"{mime}\" není povolený", + "error.file.mime.invalid": "Neplatný MIME typ: {mime}", + "error.file.mime.missing": "Nelze rozeznat mime typ souboru \"{filename}\"", + "error.file.minheight": "Výška obrázku musí být alespoň {height} pixelů", + "error.file.minsize": "Soubor je příliš malý", + "error.file.minwidth": "Šířka obrázku musí být alespoň {width} pixelů", + "error.file.name.unique": "Název souboru musí být unikátní", + "error.file.name.missing": "Název souboru nesmí být prázdný", + "error.file.notFound": "Soubor se nepoda\u0159ilo nal\u00e9zt", + "error.file.orientation": "Orientace obrázku másí být \"{orientation}\"", + "error.file.sort.permission": "Nemáte dovoleno změnit pozici \"{filename}\"", + "error.file.type.forbidden": "Nemáte povoleno nahrávat soubory typu {type} ", + "error.file.type.invalid": "Neplatný typ souboru: {type}", + "error.file.undefined": "Soubor se nepoda\u0159ilo nal\u00e9zt", + + "error.form.incomplete": "Prosím opravte všechny chyby ve formuláři", + "error.form.notSaved": "Formulář nemohl být uložen", + + "error.language.code": "Zadejte prosím platný kód jazyka", + "error.language.create.permission": "Nemáte dovoleno vytvořit jazyk", + "error.language.delete.permission": "Nemáte dovoleno jazyk vymazat", + "error.language.duplicate": "Jazyk již existuje", + "error.language.name": "Zadejte prosím platné jméno jazyka", + "error.language.notFound": "Jazyk nebyl nalezen", + "error.language.update.permission": "Nemáte dovoleno aktualizovat jazyk", + + "error.layout.validation.block": "V rozvržení {layoutIndex} je v poli \"{field}\" v bloku {blockIndex} při použití \"{fieldset}\" typu chyba", + "error.layout.validation.settings": "Chyba v nastavení rozvržení {index}", + + "error.license.domain": "Licenčnímu klíči chybí doména", + "error.license.email": "Zadejte prosím platnou emailovou adresu", + "error.license.format": "Zadejte prosím platné licenční číslo", + "error.license.verification": "Licenci nelze ověřit", + + "error.login.totp.confirm.invalid": "Neplatný kód", + "error.login.totp.confirm.missing": "Zadejte prosím licenční kód", + + "error.object.validation": "V poli \"{label}\" je chyba:\n{message}", + + "error.offline": "Panel je v současnosti off-line", + + "error.page.changeSlug.permission": "Nem\u016f\u017eete zm\u011bnit URL t\u00e9to str\u00e1nky", + "error.page.changeSlug.reserved": "Cesta k stránkám na nevyšší úrovni nesmí začínat jako \"{path}\"", + "error.page.changeStatus.incomplete": "Stránka obsahuje chyby a nemohla být zveřejněna", + "error.page.changeStatus.permission": "Status této stránky nelze změnit", + "error.page.changeStatus.toDraft.invalid": "Stránka \"{slug}\" nemůže být převedena na koncept", + "error.page.changeTemplate.invalid": "Šablonu stránky \"{slug}\" nelze změnit", + "error.page.changeTemplate.permission": "Nemáte dovoleno změnit šablonu stránky \"{slug}\"", + "error.page.changeTitle.empty": "Titulek nesmí být prázdný", + "error.page.changeTitle.permission": "Nemáte dovoleno změnit titulek stránky \"{slug}\"", + "error.page.create.permission": "Nemáte dovoleno vytvořit \"{slug}\"", + "error.page.delete": "Stránku \"{slug}\" nelze vymazat", + "error.page.delete.confirm": "Pro potvrzení prosím zadejte titulek stránky", + "error.page.delete.hasChildren": "Stránka má podstránky, nemůže být vymazána", + "error.page.delete.multiple": "Nebylo možné vymazat všechny stránky. Zkuste zbývající stránky vymazat postupně, abyste nalezli chybu, která bránila hromadnému smazání.", + "error.page.delete.permission": "Nemáte dovoleno odstranit \"{slug}\"", + "error.page.draft.duplicate": "Koncept stránky, který obsahuje v adrese URL \"{slug}\" již existuje ", + "error.page.duplicate": "Stránka, která v adrese URL obsahuje \"{slug}\" již existuje", + "error.page.duplicate.permission": "Nemáte dovoleno duplikovat \"{slug}\"", + "error.page.move.ancestor": "Stránka nemůže být přesunuta sama do sebe", + "error.page.move.directory": "Adresář stránky nelze přesunout", + "error.page.move.duplicate": "Podstránka s URL \"{slug}\" již existuje", + "error.page.move.noSections": "Stránka \"{parent}\" nemůže být žádné jiné stránce nadřazená, protože neobsahuje sekci pro podstránky", + "error.page.move.notFound": "Přesunutá stránka nebyla nalezena", + "error.page.move.permission": "Nemáte dovoleno přesunout stránku \"{slug}\"", + "error.page.move.template": "Šablonu \"{template}\" nelze použít pro podstránku \"{parent}\"", + "error.page.notFound": "Str\u00e1nku se nepoda\u0159ilo nal\u00e9zt.", + "error.page.num.invalid": "Zadejte prosím platné pořadové číslo. Čísla nesmí být záporná.", + "error.page.slug.invalid": "Podtržení", + "error.page.slug.maxlength": "URL musí mít méně než \"{length}\" znaků", + "error.page.sort.permission": "Stránce \"{slug}\" nelze změnit pořadí", + "error.page.status.invalid": "Nastavte prosím platný status stránky", + "error.page.undefined": "Str\u00e1nku se nepoda\u0159ilo nal\u00e9zt.", + "error.page.update.permission": "Nemáte dovoleno upravit \"{slug}\"", + + "error.section.files.max.plural": "Sekce \"{section}\" nesmí obsahovat více jak {max} souborů", + "error.section.files.max.singular": "Sekce \"{section}\" může obsahovat nejvýše jeden soubor", + "error.section.files.min.plural": "Sekce \"{section}\" vyžaduje nejméně {min} souborů", + "error.section.files.min.singular": "Sekce \"{section}\" vyžaduje alespoň jeden soubor", + + "error.section.pages.max.plural": "Sekce \"{section}\" nesmí obsahovat více jak {max} stránek", + "error.section.pages.max.singular": "Sekce \"{section}\" může obsahovat nejvýše jednu stránku", + "error.section.pages.min.plural": "Sekce \"{section}\" vyžaduje alespoň {min} stránek", + "error.section.pages.min.singular": "Sekce \"{section}\" vyžaduje alespoň jednu stránku", + + "error.section.notLoaded": "Nelze načíst sekci \"{name}\"", + "error.section.type.invalid": "Typ sekce \"{type}\" není platný", + + "error.site.changeTitle.empty": "Titulek nesmí být prázdný", + "error.site.changeTitle.permission": "Nemáte dovoleno změnit titulek stránky", + "error.site.update.permission": "Nemáte dovoleno upravit stránku", + + "error.structure.validation": "Chyba v poli \"{field}\" na řádku {index}", + + "error.template.default.notFound": "Výchozí šablona neexistuje", + + "error.unexpected": "Vyskytla se neočekávaná chyba! Pro více informací povolte debug mód, viz: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Nemáte dovoleno měnit email uživatele \"{name}\"", + "error.user.changeLanguage.permission": "Nemáte dovoleno změnit jazyk uživatele \"{name}\"", + "error.user.changeName.permission": "Nemáte dovoleno změnit jméno uživatele \"{name}\"", + "error.user.changePassword.permission": "Nemáte dovoleno změnit heslo uživatele \"{name}\"", + "error.user.changeRole.lastAdmin": "Role posledního administrátora nemůže být změněna", + "error.user.changeRole.permission": "Nemáte dovoleno změnit roli uživatele \"{name}\"", + "error.user.changeRole.toAdmin": "Nemáte dovoleno povýšit uživatele do role administrátora.", + "error.user.create.permission": "Nemáte dovoleno vytvořit tohoto uživatele", + "error.user.delete": "U\u017eivatel nemohl b\u00fdt smaz\u00e1n", + "error.user.delete.lastAdmin": "Nem\u016f\u017eete smazat posledn\u00edho administr\u00e1tora", + "error.user.delete.lastUser": "Poslední uživatel nemůže být smazán", + "error.user.delete.permission": "Nem\u00e1te dovoleno smazat tohoto u\u017eivatele", + "error.user.duplicate": "Uživatel s emailovou adresou \"{email}\" již existuje", + "error.user.email.invalid": "Zadejte prosím platnou emailovou adresu", + "error.user.language.invalid": "Zadejte prosím platný jazyk", + "error.user.notFound": "U\u017eivatele se nepoda\u0159ilo nal\u00e9zt", + "error.user.password.excessive": "Zadejte prosím platné heslo. Heslo nesmí být delší než 1000 znaků.", + "error.user.password.invalid": "Zadejte prosím platné heslo. Heslo musí být dlouhé alespoň 8 znaků.", + "error.user.password.notSame": "Pros\u00edm potvr\u010fte heslo", + "error.user.password.undefined": "Uživatel nemá nastavené heslo.", + "error.user.password.wrong": "Špatné heslo", + "error.user.role.invalid": "Zadejte prosím platnou roli", + "error.user.undefined": "Uživatele se nepodařilo nalézt", + "error.user.update.permission": "Nemáte dovoleno upravit uživatele \"{name}\"", + + "error.validation.accepted": "Potvrďte prosím", + "error.validation.alpha": "Zadávejte prosím pouze znaky v rozmezí a-z", + "error.validation.alphanum": "Zadávejte prosím pouze znaky v rozmezí a-z nebo čísla v rozmezí 0-9", + "error.validation.anchor": "Zadejte správný název kotvy", + "error.validation.between": "Zadejte prosím hodnotu mez \"{min}\" a \"{max}\"", + "error.validation.boolean": "Potvrďte prosím, nebo odmítněte", + "error.validation.color": "Zadejte platnou barvu ve formátu {format}", + "error.validation.contains": "Zadejte prosím hodnotu, která obsahuje \"{needle}\"", + "error.validation.date": "Zadejte prosím platné datum", + "error.validation.date.after": "Zadejte prosím datum po {date}", + "error.validation.date.before": "Zadejte prosím datum před {date}", + "error.validation.date.between": "Zadejte prosím datum mezi {min} a {max}", + "error.validation.denied": "Prosím, odmítněte", + "error.validation.different": "Hodnota nesmí být \"{other}\"", + "error.validation.email": "Zadejte prosím platnou emailovou adresu", + "error.validation.endswith": "Hodnota nesmí končit \"{end}\"", + "error.validation.filename": "Zadejte prosím platný název souboru", + "error.validation.in": "Zadejte prosím některou z následujíích hodnot: ({in})", + "error.validation.integer": "Zadejte prosím platné celé číslo", + "error.validation.ip": "Zadejte prosím platnou IP adresu", + "error.validation.less": "Zadejte prosím hodnotu menší než {max}", + "error.validation.linkType": "Typ odkazu není povolen", + "error.validation.match": "Hodnota neodpovídá očekávanému vzoru", + "error.validation.max": "Zadejte prosím hodnotu rovnou, nebo menší než {max}", + "error.validation.maxlength": "Zadaná hodnota je příliš dlouhá. (Povoleno nejvýše {max} znaků)", + "error.validation.maxwords": "Nezadávejte prosím více jak {max} slov", + "error.validation.min": "Zadejte prosím hodnotu rovnou, nebo větší než {min}", + "error.validation.minlength": "Zadaná hodnota je příliš krátká. (Požadováno nejméně {min} znaků)", + "error.validation.minwords": "Zadejte prosím alespoň {min} slov", + "error.validation.more": "Zadejte prosím hodnotu větší než {min}", + "error.validation.notcontains": "Zadejte prosím hodnotu, která neobsahuje \"{needle}\"", + "error.validation.notin": "Nezadávejte prosím žádnou z následujíích hodnot: ({notIn})", + "error.validation.option": "Vyberte prosím platnou možnost", + "error.validation.num": "Zadejte prosím platné číslo", + "error.validation.required": "Zadejte prosím jakoukoli hodnotu", + "error.validation.same": "Zadejte prosím \"{other}\"", + "error.validation.size": "Velikost hodnoty musí být \"{size}\"", + "error.validation.startswith": "Hodnota musí začínat \"{start}\"", + "error.validation.tel": "Zadejte neformátované telefonní číslo ", + "error.validation.time": "Zadejte prosím platný čas", + "error.validation.time.after": "Zadejte prosím čas po {time}", + "error.validation.time.before": "Zadejte prosím čas před {time}", + "error.validation.time.between": "Zadejte prosím čas v rozmezí od {min} do {max}", + "error.validation.uuid": "Zadejte platné UUID", + "error.validation.url": "Zadejte prosím platnou adresu URL", + + "expand": "Rozbalit", + "expand.all": "Rozbalit vše", + + "field.invalid": "Pole není platné", + "field.required": "Pole musí být vyplněno.", + "field.blocks.changeType": "Změnit typ", + "field.blocks.code.name": "Kód", + "field.blocks.code.language": "Jazyk", + "field.blocks.code.placeholder": "Váš kód …", + "field.blocks.delete.confirm": "Opravdu chcete smazat tento blok?", + "field.blocks.delete.confirm.all": "Opravdu chcete smazat všechny bloky?", + "field.blocks.delete.confirm.selected": "Opravdu chcete smazat vybrané bloky?", + "field.blocks.empty": "Zatím žádné bloky", + "field.blocks.fieldsets.empty": "Zatím žádné fieldsets", + "field.blocks.fieldsets.label": "Vyberte prosím typ bloku …", + "field.blocks.fieldsets.paste": "Stiskněte{{ shortcut }} pro vložení rozvržení/bloků ze schránky Vloženy budou jen ty, které jsou v aktuálním poli povolené.", + "field.blocks.gallery.name": "Galerie", + "field.blocks.gallery.images.empty": "Zatím žádné obrázky", + "field.blocks.gallery.images.label": "Obrázky", + "field.blocks.heading.level": "Úroveň", + "field.blocks.heading.name": "Nadpis", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Nadpis …", + "field.blocks.figure.back.plain": "Čistý", + "field.blocks.figure.back.pattern.light": "Vzor (světlý)", + "field.blocks.figure.back.pattern.dark": "Vzor (tmavý)", + "field.blocks.image.alt": "Alternativní text", + "field.blocks.image.caption": "Titulek", + "field.blocks.image.crop": "Oříznout", + "field.blocks.image.link": "Odkaz", + "field.blocks.image.location": "Umístění", + "field.blocks.image.location.internal": "Tato webová stránka", + "field.blocks.image.location.external": "Externí zdroj", + "field.blocks.image.name": "Obrázek", + "field.blocks.image.placeholder": "Vyberte obrázek", + "field.blocks.image.ratio": "Poměr stran", + "field.blocks.image.url": "URL obrázku", + "field.blocks.line.name": "Čára", + "field.blocks.list.name": "Seznam", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Citát", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Citát …", + "field.blocks.quote.citation.label": "Citace", + "field.blocks.quote.citation.placeholder": "od …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Titulek", + "field.blocks.video.controls": "Ovládání", + "field.blocks.video.location": "Umístění", + "field.blocks.video.loop": "Smyčka", + "field.blocks.video.muted": "Ztlumené", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Zadejte URL adresu videa", + "field.blocks.video.poster": "Náhledový obrázek", + "field.blocks.video.preload": "Předběžně načíst", + "field.blocks.video.url.label": "URL adresa videa", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Opravdu chcete smazat všechny záznamy?", + "field.entries.empty": "Zatím nejsou žádné záznamy.", + + "field.files.empty": "Nebyly zatím vybrány žádné soubory", + "field.files.empty.single": "Nebyl zatím vybrán žádný soubor", + + "field.layout.change": "Změnit rozvržení", + "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é rozvržení", + "field.layout.select": "Vyberte rozvržení", + + "field.object.empty": "Zatím žádná informace", + + "field.pages.empty": "Nebyly zatím vybrány žádné stránky", + "field.pages.empty.single": "Nebyla zatím vybrána žádná stránka", + + "field.structure.delete.confirm": "Opravdu chcete smazat tento z\u00e1znam?", + "field.structure.delete.confirm.all": "Opravdu chcete smazat všechny záznamy?", + "field.structure.empty": "Zat\u00edm nejsou \u017e\u00e1dn\u00e9 z\u00e1znamy.", + + "field.users.empty": "Nebyli zatím vybráni žádní uživatelé", + "field.users.empty.single": "Nebyl zatím vybrán žádný uživatel", + + "fields.empty": "Zatím žádné pole", + + "file": "Soubor", + "file.blueprint": "Tento typ souboru nemá blueprint. Blueprint můžete definovat v /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Změnit šablonu", + "file.changeTemplate.notice": "Změna šablony souboru změní obsah pro pole, která mají odlišný typ. Pokud má nová šablona nastavena určitá pravidla, např. rozměry obrázku, tato pravidla budou také aplikována. Používejte obezřetně.", + "file.delete.confirm": "Opravdu chcete smazat tento soubor?", + "file.focus.placeholder": "Nastavit ohnisko", + "file.focus.reset": "Odstranit ohnisko", + "file.focus.title": "Zaměřit na", + "file.sort": "Změnit pozici", + + "files": "Soubory", + "files.delete.confirm.selected": "Opravdu chcete smazat vybrané soubory? Tuto akci nelze vzít zpět!", + "files.empty": "Zatím žádné soubory", + + "filter": "Filtr", + + "form.discard": "Zahodit změny", + "form.discard.confirm": "Opravdu chcete zahodit všechny změny?", + "form.locked": "Obsah je momentálně zablokován, protože ho upravuje jiný uživatel", + "form.unsaved": "Změny dosud nebyly uloženy", + "form.preview": "Náhled změn", + "form.preview.draft": "Náhled konceptu", + + "hide": "Skrýt", + "hour": "Hodina", + "hue": "Odstín", + "import": "Import", + "info": "Informace", + "insert": "Vlo\u017eit", + "insert.after": "Vložit za", + "insert.before": "Vložit před", + "install": "Instalovat", + + "installation": "Instalace", + "installation.completed": "Panel byl nainstalován", + "installation.disabled": "Instalátor panelu je ve výchozím nastavení na veřejných serverech zakázán. Spusťte prosím instalátor na lokálním počítači nebo jej povolte prostřednictvím panel.install.", + "installation.issues.accounts": "\/site\/accounts nen\u00ed zapisovateln\u00e9", + "installation.issues.content": "Slo\u017eka content a v\u0161echny soubory a slo\u017eky v n\u00ed mus\u00ed b\u00fdt zapisovateln\u00e9.", + "installation.issues.curl": "Je vyžadováno rozšířeníCURL", + "installation.issues.headline": "Panel nelze nainstalovat", + "installation.issues.mbstring": "Je vyžadováno rozšířeníMB String", + "installation.issues.media": "Složka/media neexistuje, nebo nemá povolený zápis", + "installation.issues.php": "Ujistěte se, že používátePHP 8+", + "installation.issues.sessions": "Složka/site/sessions neexistuje, nebo nemá povolený zápis", + + "language": "Jazyk", + "language.code": "Kód", + "language.convert": "Nastavte výchozí možnost", + "language.convert.confirm": "

Opravdu chcete převést{name} na výchozí jazyk? Tuto volbu nelze vzít zpátky.

Pokud {name} obsahuje nepřeložený text, nebude již k dispozici záložní varianta a části stránky mohou zůstat prázdné.

", + "language.create": "Přidat nový jazyk", + "language.default": "Výchozí jazyk", + "language.delete.confirm": "Opravdu chcete smazat jazyk {name} včetně všech překladů? Tuto volbu nelze vzít zpátky!", + "language.deleted": "Jazyk byl smazán", + "language.direction": "Směr čtení", + "language.direction.ltr": "Zleva doprava", + "language.direction.rtl": "Zprava doleva", + "language.locale": "Řetězec lokalizace PHP", + "language.locale.warning": "Používáte vlastní jazykové nastavení. Upravte prosím soubor s nastavením v /site/languages", + "language.name": "Jméno", + "language.secondary": "Sekundární jazyk", + "language.settings": "Nastavení jazyka", + "language.updated": "Jazyk byl aktualizován", + "language.variables": "Jazykové proměnné", + "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", + + "languages": "Jazyky", + "languages.default": "Výchozí jazyk", + "languages.empty": "Zatím neexistují žádné jazyky", + "languages.secondary": "Další jazyky", + "languages.secondary.empty": "Neexistují zatím žádné další jazyky", + + "license": "Kirby licence", + "license.activate": "Aktivovat nyní", + "license.activate.label": "Prosím aktivute svoji licenci", + "license.activate.domain": "Vaše licence bude zaregistrována na {host}.", + "license.activate.local": "Chystáte se registrovat licenci na Vaší lokální doméně {host}. Pokud bude tato stránka nasazena na veřejnou doménu, registrujte prosím licenci až tam. Pokud je {host} opravdu doménou, na které si přejete licenci registrovat, pokračujte prosím dále.", + "license.activated": "Aktivováno", + "license.buy": "Zakoupit licenci", + "license.code": "Kód", + "license.code.help": "Licenční kód jste po zakoupení obdrželi na email. Vložte prosím kód a zaregistrujte Vaší kopii.", + "license.code.label": "Zadejte prosím licenční kód", + "license.status.active.info": "Platí pro nové verze až do {date}", + "license.status.active.label": "Platná licence", + "license.status.demo.info": "Tato instalace je pouze demoverze", + "license.status.demo.label": "Demoverze", + "license.status.inactive.info": "Pro update na novou hlavní verzi musíte obnovit licenci", + "license.status.inactive.label": "Žádné nové hlavní verze", + "license.status.legacy.bubble": "Vše připraveno na obnovení licence?", + "license.status.legacy.info": "Vaše licence se nevztahuje na tuto verzi", + "license.status.legacy.label": "Prosím obnovte svoji licenci", + "license.status.missing.bubble": "Vše připraveno na spuštění vaši stránky?", + "license.status.missing.info": "Žádlná platná licence", + "license.status.missing.label": "Prosím aktivute svoji licenci", + "license.status.unknown.info": "Status licence je neznámý", + "license.status.unknown.label": "Neznámý", + "license.manage": "Spravovat licence", + "license.purchased": "Zakoupeno", + "license.success": "Děkujeme Vám za podporu Kirby", + "license.unregistered.label": "Neregistrovaný", + + "link": "Odkaz", + "link.text": "Text odkazu", + + "loading": "Načítám", + + "lock.unsaved": "Neuložené změny", + "lock.unsaved.empty": "Nezbývají již žádné neuložené změny.", + "lock.unsaved.files": "Neuložené soubory", + "lock.unsaved.pages": "Neuložené stránky", + "lock.unsaved.users": "Neuložené účty", + "lock.isLocked": "Neuložené změny od {email}", + "lock.unlock": "Odemknout", + "lock.unlock.submit": "Odemknout a přepsat neuložené změny od {email}", + "lock.isUnlocked": "Bylo odemknuto jiným uživatelem", + + "login": "Přihlásit se", + "login.code.label.login": "Kód pro přihlášení", + "login.code.label.password-reset": "Kód pro resetování hesla", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Vaše e-mailová adresa byla zaregistrována, kód byl odeslán do Vaší e-mailové schránky.", + "login.code.text.totp": "Prosím vložte jednorázový kód z vaší autentifikační aplikace.", + "login.email.login.body": "Ahoj {user.nameOrEmail},\n\nV nedávné době jsi zažádal(a) o kód pro přihlášení do Kirby Panelu na stránce {site}.\nNásledující kód pro přihlášení je platný {timeout} minut:\n\n{code}\n\nPokud jsi o kód pro přihlášení nežádal(a), tuto zprávu prosím ignoruj a v případě dotazů prosím kontaktuj svého administrátora.\nZ bezpečnostních důvodů prosím tuto zprávu nepřeposílej nikomu dalšímu.", + "login.email.login.subject": "Váš kód pro přihlášení", + "login.email.password-reset.body": "Ahoj {user.nameOrEmail},\n\nV nedávné době jsi zažádal(a) o kód pro resetování hesla do Kirby Panelu na stránce {site}.\nNásledující kód pro resetování hesla je platný {timeout} minut:\n\n{code}\n\nPokud jsi o kód pro resetování hesla nežádal(a), tuto zprávu prosím ignoruj a v případě dotazů prosím kontaktuj svého administrátora.\nZ bezpečnostních důvodů prosím tuto zprávu nepřeposílej nikomu dalšímu.", + "login.email.password-reset.subject": "Váš kód pro resetování hesla", + "login.remember": "Zůstat přihlášen", + "login.reset": "Resetovat heslo", + "login.toggleText.code.email": "Přihlásit se pomocí e-mailu", + "login.toggleText.code.email-password": "Přihlásit se pomocí hesla", + "login.toggleText.password-reset.email": "Zapomenuté heslo?", + "login.toggleText.password-reset.email-password": "← Zpět na přihlášení", + "login.totp.enable.option": "Nastavit jednorázové kódy", + "login.totp.enable.intro": "Autentifikační aplikace umí generovat jednorázové kódy, které jsou použitý jako druhý faktor při přihlášení do vašeho účtu.", + "login.totp.enable.qr.label": "1. Naskenujte tento QR kód", + "login.totp.enable.qr.help": "Nepodařilo se scanovat? Přidejte klíč pro nastavení  {secret} ručně do své autentifikační aplikace.", + "login.totp.enable.confirm.headline": "2. Potvrďte vygenerovaným kódem", + "login.totp.enable.confirm.text": "Vaše aplikace generuje každých 30 sekund nový jednorázový kód. Zadejte aktuální kód a dokončete nastavení:", + "login.totp.enable.confirm.label": "Současný kód", + "login.totp.enable.confirm.help": "Po tomto nastavení vás při každém přihlášení požádáme o jednorázový kód.", + "login.totp.enable.success": "Jednorázové kódy zapnuty", + "login.totp.disable.option": "Vypnutí jednorázových kódu", + "login.totp.disable.label": "Pro vypnutí jednorázových kódů zadejte svoje heslo", + "login.totp.disable.help": "V budoucnu bude při přihlašování vyžadován jiný druhý faktor, například přihlašovací kód zaslaný e-mailem. Jednorázové kódy můžete vždy později nastavit znovu.", + "login.totp.disable.admin": "

Tím se jednorázové kódy pro{user} zakážou.

V budoucnu bude při přihlášení vyžadován jiný druhý faktor, například přihlašovací kód zaslaný e-mailem. Jednorázové kódy si může {user} nastavit znovu po svém dalším přihlášení.", + "login.totp.disable.success": "Jednorázové kódy vypnuty", + + "logout": "Odhlásit se", + + "merge": "Spojit", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Typ média", + "minutes": "Minuty", + + "month": "Měsíc", + "months.april": "Duben", + "months.august": "Srpen", + "months.december": "Prosinec", + "months.february": "Únor", + "months.january": "Leden", + "months.july": "\u010cervenec", + "months.june": "\u010cerven", + "months.march": "B\u0159ezen", + "months.may": "Kv\u011bten", + "months.november": "Listopad", + "months.october": "\u0158\u00edjen", + "months.september": "Z\u00e1\u0159\u00ed", + + "more": "Více", + "move": "Přesunout", + "name": "Jméno", + "next": "Další", + "night": "Noc", + "no": "ne", + "off": "vypnuto", + "on": "zapnuto", + "open": "Otevřít", + "open.newWindow": "Otevřít v novém okně", + "option": "Možnost", + "options": "Možnosti", + "options.none": "Žádné možnosti", + "options.all": "Zobrazit všech {count} možností", + + "orientation": "Orientace", + "orientation.landscape": "Na šířku", + "orientation.portrait": "Na výšku", + "orientation.square": "Čtverec", + + "page": "Stránka", + "page.blueprint": "Tento typ stránky nemá blueprint. Blueprint můžete definovat v /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Zm\u011bnit URL", + "page.changeSlug.fromTitle": "Vytvo\u0159it z n\u00e1zvu", + "page.changeStatus": "Změnit status", + "page.changeStatus.position": "Vyberte prosím pozici", + "page.changeStatus.select": "Vybrat nový status", + "page.changeTemplate": "Změnit šablonu", + "page.changeTemplate.notice": "Změna šablony stránky odstraní obsah pro pole, jejichž typy se neshodují. Používejte obezřetně.", + "page.create": "Vytvořit jako {status}", + "page.delete.confirm": "Opravdu chcete smazat tuto str\u00e1nku?", + "page.delete.confirm.subpages": "Tato stránka má podstránky.
Všechny podstránky budou vymazány.", + "page.delete.confirm.title": "Pro potvrzení zadejte titulek stránky", + "page.duplicate.appendix": "Kopírovat", + "page.duplicate.files": "Kopírovat soubory", + "page.duplicate.pages": "Kopírovat stránky", + "page.move": "Přesunout stránku", + "page.sort": "Změnit pozici", + "page.status": "Stav", + "page.status.draft": "Koncept", + "page.status.draft.description": "Stránka je ve stavu konceptu a je viditelná pouze pro přihlášené editory, nebo přes tajný odkaz", + "page.status.listed": "Veřejná", + "page.status.listed.description": "Stránka je zveřejněná pro všechny", + "page.status.unlisted": "Neveřejná", + "page.status.unlisted.description": "Tato stránka je dostupná pouze přes URL.", + + "pages": "Stránky", + "pages.delete.confirm.selected": "Opravdu chcete smazat vybrané stránky? Tuto akci nelze vzít zpět!", + "pages.empty": "Zatím žádné stránky", + "pages.status.draft": "Koncepty", + "pages.status.listed": "Zveřejněno", + "pages.status.unlisted": "Neveřejná", + + "pagination.page": "Stránka", + + "password": "Heslo", + "paste": "Vložit", + "paste.after": "Vložit za", + "paste.success": "{count} vloženo!", + "pixel": "Pixel", + "plugin": "Doplněk", + "plugins": "Doplňky", + "prev": "Předchozí", + "preview": "Náhled", + + "publish": "Zveřejnit", + "published": "Zveřejněno", + + "remove": "Odstranit", + "rename": "Přejmenovat", + "renew": "Obnovit", + "replace": "Nahradit", + "replace.with": "Nahradit pomocí", + "retry": "Zkusit znovu", + "revert": "Zahodit", + "revert.confirm": "Opravdu chcete smazat všechny provedené změny?", + + "role": "Role", + "role.admin.description": "Administrátor má všechna práva", + "role.admin.title": "Administrátor", + "role.all": "Vše", + "role.empty": "Neexistují uživatelé s touto rolí", + "role.description.placeholder": "Žádný popis", + "role.nobody.description": "Toto je výchozí role bez jakýchkoli oprávnění", + "role.nobody.title": "Nikdo", + + "save": "Ulo\u017eit", + "saved": "Uloženo", + "search": "Hledat", + "searching": "Hledání", + "search.min": "Pro vyhledání zadejte alespoň {min} znaky", + "search.all": "Zobrazit všech {count} výsledků", + "search.results.none": "Žádné výsledky", + + "section.invalid": "Sekce je neplatná", + "section.required": "Sekce musí být vyplněna", + + "security": "Zabezpečení", + "select": "Vybrat", + "server": "Server", + "settings": "Nastavení", + "show": "Zobrazit", + "site.blueprint": "Hlavní panel nemá blueprint. Blueprint můžete definovat v /site/blueprints/site.yml", + "size": "Velikost", + "slug": "P\u0159\u00edpona URL", + "sort": "Řadit", + "sort.drag": "Táhnout pro změnu řazení ...", + "split": "Rozdělit", + + "stats.empty": "Žádná hlášení", + "status": "Stav", + + "system.info.copy": "Kopírovat informace", + "system.info.copied": "Systémové informace zkopírovány", + "system.issues.content": "Složka content je zřejmě přístupná zvenčí", + "system.issues.eol.kirby": "Instalovaná verze Kirby dosáhla konce životnosti a nebude již dále dostávat bezpečnostní aktualizace", + "system.issues.eol.plugin": "Instalovaná verze doplňku { plugin } dosáhla konce životnosti a nebude již dále dostávat bezpečnostní aktualizace", + "system.issues.eol.php": "Instalovaná verze PHP { release } dosálhla konce životnosti a nebude již dále dostávat bezpečností aktualizace", + "system.issues.debug": "Debug mode musí být v produkci vypnutý", + "system.issues.git": "Složka .git je zřejmě přístupná zvenčí", + "system.issues.https": "Pro všechny stránky doporučujeme používat protokol HTTPS", + "system.issues.kirby": "Složka kirby je zřejmě přístupná zvenčí", + "system.issues.local": "Stránka běží lokálně s mírnějšími bezpečnostními kontrolami", + "system.issues.site": "Složka site je zřejmě přístupná zvenčí", + "system.issues.vue.compiler": "Kompilátor Vue šablon je povolen", + "system.issues.vulnerability.kirby": "Vaše instalace může být ovlivněna následující zranitelností (stupeň vážnosti - { severity }): { description }", + "system.issues.vulnerability.plugin": "Vaše instalace může být ovlivněna následující zranitelností v doplňku { plugin } (stupeň vážnosti - { severity }): { description }", + "system.updateStatus": "Status aktualizací", + "system.updateStatus.error": "Nepodařilo se zkontrolovat aktualizace", + "system.updateStatus.not-vulnerable": "Žádné známé zranitelnosti", + "system.updateStatus.security-update": "Je dostupná bezplatná bezpečnostní aktualizace { version }", + "system.updateStatus.security-upgrade": "Je dostupný upgrade { version } s bezpečnostními opravami", + "system.updateStatus.unreleased": "Nevydaná verze", + "system.updateStatus.up-to-date": "Aktuální", + "system.updateStatus.update": "Je dostupná bezplatná nová verze { version }", + "system.updateStatus.upgrade": "Je dostupný upgrade na verzi { version }", + + "tel": "Telefon", + "tel.placeholder": "+49123456789", + "template": "\u0160ablona", + + "theme": "Motiv", + "theme.light": "Světlý motiv", + "theme.dark": "Tmavý motiv", + "theme.automatic": "Podle nastavení systému", + + "title": "Název", + "today": "Dnes", + + "toolbar.button.clear": "Odstranit formátování", + "toolbar.button.code": "Kód", + "toolbar.button.bold": "Tu\u010dn\u00fd text", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Nadpisy", + "toolbar.button.heading.1": "Nadpis 1", + "toolbar.button.heading.2": "Nadpis 2", + "toolbar.button.heading.3": "Nadpis 3", + "toolbar.button.heading.4": "Nadpis 4", + "toolbar.button.heading.5": "Nadpis 5", + "toolbar.button.heading.6": "Nadpis 6", + "toolbar.button.italic": "Kurz\u00edva", + "toolbar.button.file": "Soubor", + "toolbar.button.file.select": "Vyberte soubor", + "toolbar.button.file.upload": "Nahrajte soubor", + "toolbar.button.link": "Odkaz", + "toolbar.button.paragraph": "Odstavec", + "toolbar.button.strike": "Přeškrtnutí", + "toolbar.button.sub": "Dolní index", + "toolbar.button.sup": "Horní index", + "toolbar.button.ol": "Číslovaný seznam", + "toolbar.button.underline": "Podtržení", + "toolbar.button.ul": "Odrážkový seznam", + + "translation.author": "Kirby tým", + "translation.direction": "ltr", + "translation.name": "\u010cesky", + "translation.locale": "cs_CZ", + + "type": "Typ", + + "upload": "Nahrát", + "upload.error.cantMove": "Nahraný soubor nemohl být přesunut", + "upload.error.cantWrite": "Zápis souboru na disk se nezdařil", + "upload.error.default": "Soubor se nepodařilo nahrát", + "upload.error.extension": "Nahrávání souboru přerušeno rozšířením.", + "upload.error.formSize": "Velikost nahrávaného souboru převyšuje omezení stanovené direktivou MAX_FILE_SIZE", + "upload.error.iniPostSize": "Velikost nahrávaného souboru převyšuje omezení stanovené direktivou post_max_size, která je nastavena v php.ini", + "upload.error.iniSize": "Velikost nahrávaného souboru převyšuje omezení stanovené direktivou upload_max_filesize, která je nastavena v php.ini ", + "upload.error.noFile": "Nebyl nahrán žádný soubor", + "upload.error.noFiles": "Nebyly nahrány žádné soubory", + "upload.error.partial": "Soubor byl nahrán pouze z části", + "upload.error.tmpDir": "Chybí dočasná složka", + "upload.errors": "Chyba", + "upload.progress": "Nahrávání...", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Uživatel", + "user.blueprint": "Pro tuto uživatelskou roli můžete definovat další sekce a pole v /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Změnit email", + "user.changeLanguage": "Změnit jazyk", + "user.changeName": "Přejmenovat tohoto uživatele", + "user.changePassword": "Změnit heslo", + "user.changePassword.current": "Vaše současné heslo", + "user.changePassword.new": "Nové heslo", + "user.changePassword.new.confirm": "Potvrdit nové heslo...", + "user.changeRole": "Změnit roli", + "user.changeRole.select": "Vybrat novou roli", + "user.create": "Přidat nového uživatele", + "user.delete": "Smazat tohoto uživatele", + "user.delete.confirm": "Opravdu chcete smazat tohoto u\u017eivatele?", + + "users": "Uživatelé", + + "version": "Verze Kirby", + "version.changes": "Změnit verzi", + "version.compare": "Porovnat verze", + "version.current": "Současná verze", + "version.latest": "Poslední verze", + "versionInformation": "Informace o verzi", + + "view": "Pohled", + "view.account": "V\u00e1\u0161 \u00fa\u010det", + "view.installation": "Instalace", + "view.languages": "Jazyky", + "view.resetPassword": "Resetovat heslo", + "view.site": "Stránka", + "view.system": "Systém", + "view.users": "U\u017eivatel\u00e9", + + "welcome": "Vítejte", + "year": "Rok", + "yes": "ano" +} diff --git a/public/kirby/i18n/translations/da.json b/public/kirby/i18n/translations/da.json new file mode 100644 index 0000000..d6d97c3 --- /dev/null +++ b/public/kirby/i18n/translations/da.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Ændre dit navn", + "account.delete": "Slet din konto", + "account.delete.confirm": "Ønsker du virkelig at slette din konto? Du vil blive logget ud med det samme. Din konto kan ikke gendannes.", + + "activate": "Activate", + "add": "Ny", + "alpha": "Alpha", + "author": "Forfatter", + "avatar": "Profilbillede", + "back": "Tilbage", + "cancel": "Annuller", + "change": "\u00c6ndre", + "close": "Luk", + "changes": "Changes", + "confirm": "Gem", + "collapse": "Fold sammen", + "collapse.all": "Fold alle sammen", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Kopier", + "copy.all": "Kopier alle", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Opret", + "custom": "Custom", + + "date": "Dato", + "date.select": "Vælg en dato", + + "day": "Dag", + "days.fri": "Fre", + "days.mon": "Man", + "days.sat": "L\u00f8r", + "days.sun": "S\u00f8n", + "days.thu": "Tor", + "days.tue": "Tir", + "days.wed": "Ons", + + "debugging": "Fejlfinding", + + "delete": "Slet", + "delete.all": "Slet alle", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "Ingen filer kan vælges", + "dialog.pages.empty": "Ingen sider kan vælges", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Ingen brugere kan vælges", + + "dimensions": "Dimensioner", + "disable": "Disable", + "disabled": "Deaktiveret", + "discard": "Kass\u00e9r", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Download", + "duplicate": "Dupliker", + + "edit": "Rediger", + + "email": "Email", + "email.placeholder": "mail@eksempel.dk", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Miljø", + + "error": "Fejl", + "error.access.code": "Ugyldig kode", + "error.access.login": "Ugyldigt log ind", + "error.access.panel": "Du har ikke adgang til panelet", + "error.access.view": "Du har ikke adgang til denne del af panelet", + + "error.avatar.create.fail": "Profilbilledet kunne blev ikke uploadet ", + "error.avatar.delete.fail": "Profilbilledet kunne ikke slettes", + "error.avatar.dimensions.invalid": "Hold venligst bredte og højde på billedet under 3000 pixels", + "error.avatar.mime.forbidden": "Uacceptabel fil-type", + + "error.blueprint.notFound": "Blueprint \"{name}\" kunne ikke indlæses", + + "error.blocks.max.plural": "Du må ikke tilføje flere end {max} blokke", + "error.blocks.max.singular": "Du må ikke tilføje mere end een blok", + "error.blocks.min.plural": "Du skal tilføje minimum {min} blokke", + "error.blocks.min.singular": "Du skal tilføje minimum een blok", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "Email preset \"{name}\" findes ikke", + + "error.field.converter.invalid": "Ugyldig converter \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "Navn kan ikke efterlades tomt", + "error.file.changeName.permission": "Du har ikke tilladelse til at ændre navnet på filen \"{filename}\"", + "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": "En fil med navnet \"{filename}\" eksisterer allerede", + "error.file.extension.forbidden": "Uacceptabel fil-endelse", + "error.file.extension.invalid": "Ugyldig endelse: {extension}", + "error.file.extension.missing": "Du kan ikke uploade filer uden fil-endelse", + "error.file.maxheight": "Højden på billedet af billedet må ikke være større end {height} pixels", + "error.file.maxsize": "Filen er for stor", + "error.file.maxwidth": "Bredden af billedet må ikke være større end {width} pixels", + "error.file.mime.differs": "Den uploadede fil skal være af samme mime type \"{mime}\"", + "error.file.mime.forbidden": "Media typen \"{mime}\" er ikke tilladt", + "error.file.mime.invalid": "Ugyldig mime type: {mime}", + "error.file.mime.missing": "Media typen for \"{filename}\" kan ikke bestemmes", + "error.file.minheight": "Højden af billedet skal mindst være {height} pixels", + "error.file.minsize": "Filen er for lille", + "error.file.minwidth": "Bredden af billedet skal mindst være {width} pixels", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Filnavn må ikke være tomt", + "error.file.notFound": "Filen kunne ikke findes", + "error.file.orientation": "Formatet på billedet skal være \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Du har ikke tilladelse til at uploade {type} filer", + "error.file.type.invalid": "Ugyldig filtype: {type}", + "error.file.undefined": "Filen kunne ikke findes", + + "error.form.incomplete": "Ret venligst alle fejl i formularen...", + "error.form.notSaved": "Formularen kunne ikke gemmes", + + "error.language.code": "Indtast venligst en gyldig kode for sproget", + "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": "Sproget eksisterer allerede", + "error.language.name": "Indtast venligst et gyldigt navn for sproget", + "error.language.notFound": "Sproget fandtes ikke", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "Der er fejl i layout {index} indstillinger", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Indtast venligst en gyldig email adresse", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "Licensen kunne ikke verificeres", + + "error.login.totp.confirm.invalid": "Ugyldig kode", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "Panelet er i øjeblikket offline", + + "error.page.changeSlug.permission": "Du kan ikke ændre URL-endelse for \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "Siden indeholder fejl og kan derfor ikke udgives", + "error.page.changeStatus.permission": "Status for denne side kan ikke ændres", + "error.page.changeStatus.toDraft.invalid": "Siden \"{slug}\" kan ikke konverteres om til en kladde", + "error.page.changeTemplate.invalid": "Skabelonen for siden \"{slug}\" kan ikke ændres", + "error.page.changeTemplate.permission": "Du har ikke tilladelse til at ændre skabelonen for \"{slug}\"", + "error.page.changeTitle.empty": "Titlen kan ikke være tom", + "error.page.changeTitle.permission": "Du har ikke tilladelse til at ændre titlen for \"{slug}\"", + "error.page.create.permission": "Du har ikke tilladelse til at oprette \"{slug}\"", + "error.page.delete": "Siden \"{slug}\" kan ikke slettes", + "error.page.delete.confirm": "Indtast venligst sidens titel for at bekræfte", + "error.page.delete.hasChildren": "Siden har unsersider og kan derfor ikke slettes", + "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": "Du har ikke tilladelse til at slette \"{slug}\"", + "error.page.draft.duplicate": "En sidekladde med URL-endelsen \"{slug}\" eksisterer allerede", + "error.page.duplicate": "En side med URL-endelsen \"{slug}\" eksisterer allerede", + "error.page.duplicate.permission": "Du har ikke mulighed for at duplikere \"{slug}\"", + "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.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}\"", + "error.page.notFound": "Siden kunne ikke findes", + "error.page.num.invalid": "Indtast venligst et gyldigt sorteringsnummer. Nummeret kan ikke være negativt.", + "error.page.slug.invalid": "Indtast venligst et gyldigt URL appendix", + "error.page.slug.maxlength": "Navnet skal være kortere end \"{length}\" tegn", + "error.page.sort.permission": "Siden \"{slug}\" kan ikke sorteres", + "error.page.status.invalid": "Sæt venligst en gyldig status for siden", + "error.page.undefined": "Siden kunne ikke findes", + "error.page.update.permission": "Du har ikke tilladelse til at opdatere \"{slug}\"", + + "error.section.files.max.plural": "Du kan ikk tilføje mere end {max} filer til \"{section}\" sektionen", + "error.section.files.max.singular": "Du kan ikke tilføje mere end een fil til \"{section}\" sektionen", + "error.section.files.min.plural": "Sektionen \"{section}\" kræver mindst {min} filer", + "error.section.files.min.singular": "Sektionen \"{section}\" kræver mindst een fil", + + "error.section.pages.max.plural": "Du kan ikke tilføje flere end {max} sider til \"{section}\" sektionen", + "error.section.pages.max.singular": "Du kan ikke tilføje mere end een side til \"{section}\" sektionen", + "error.section.pages.min.plural": "Sektionen \"{section}\" kræver mindst {min} sider", + "error.section.pages.min.singular": "Sektionen \"{section}\" kræver mindst een side", + + "error.section.notLoaded": "Sektionen \"{section}\" kunne ikke indlæses", + "error.section.type.invalid": "Sektionstypen \"{type}\" er ikke gyldig", + + "error.site.changeTitle.empty": "Titlen kan ikke være tom", + "error.site.changeTitle.permission": "Du har ikke tilladelse til at ændre titlen på sitet", + "error.site.update.permission": "Du har ikke tilladelse til at opdatere sitet", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Standardskabelonen eksisterer ikke", + + "error.unexpected": "En uventet fejl opstod! Aktiver debug mode for mere info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Du har ikke tilladelse til at ændre emailen for brugeren \"{name}\"", + "error.user.changeLanguage.permission": "Du har ikke tilladelse til at ændre sproget for brugeren \"{name}\"", + "error.user.changeName.permission": "Du har ikke tilladelse til at ændre navn på brugeren \"{name}\"", + "error.user.changePassword.permission": "Du har ikke tilladelse til at ændre adgangskoden for brugeren \"{name}\"", + "error.user.changeRole.lastAdmin": "Rollen for den sidste admin kan ikke ændres", + "error.user.changeRole.permission": "Du har ikke tilladelse til at ændre rollen for brugeren \"{name}\"", + "error.user.changeRole.toAdmin": "Du har ikke tilladelse til at tildele nogen admin rollen", + "error.user.create.permission": "Du har ikke tilladelse til at oprette denne bruger", + "error.user.delete": "Brugeren kunne ikke slettes", + "error.user.delete.lastAdmin": "Du kan ikke slette den sidste admin", + "error.user.delete.lastUser": "Den sidste bruger kan ikke slettes", + "error.user.delete.permission": "Du har ikke tilladelse til at slette denne bruger", + "error.user.duplicate": "En bruger med email adresse \"{email}\" eksisterer allerede", + "error.user.email.invalid": "Indtast venligst en gyldig email adresse", + "error.user.language.invalid": "Indtast venligst et gyldigt sprog", + "error.user.notFound": "Brugeren kunne ikke findes", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Indtast venligst en gyldig adgangskode. Adgangskoder skal minimum være 8 tegn lange.", + "error.user.password.notSame": "Bekr\u00e6ft venligst adgangskoden", + "error.user.password.undefined": "Brugeren har ikke en adgangskode", + "error.user.password.wrong": "Forkert adgangskode", + "error.user.role.invalid": "Indtast venligst en gyldig rolle", + "error.user.undefined": "Brugeren kunne ikke findes", + "error.user.update.permission": "Du har ikke tilladelse til at opdatere brugeren \"{name}\"", + + "error.validation.accepted": "Bekræft venligst", + "error.validation.alpha": "Indtast venligst kun bogstaver imellem a-z", + "error.validation.alphanum": "Indtast venligst kun bogstaver og tal imellem a-z eller 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Indtast venligst en værdi imellem \"{min}\" og \"{max}\"", + "error.validation.boolean": "Venligst bekræft eller afvis", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Indtast venligst en værdi der indeholder \"{needle}\"", + "error.validation.date": "Indtast venligst en gyldig dato", + "error.validation.date.after": "Indtast venligst en dato efter {date}", + "error.validation.date.before": "Indtast venligst en dato før {date}", + "error.validation.date.between": "Indtast venligst en dato imellem {min} og {max}", + "error.validation.denied": "Venligst afvis", + "error.validation.different": "Værdien må ikke være \"{other}\"", + "error.validation.email": "Indtast venligst en gyldig email adresse", + "error.validation.endswith": "Værdi skal ende med \"{end}\"", + "error.validation.filename": "Indtast venligst et gyldigt filnavn", + "error.validation.in": "Indtast venligst en af følgende: ({in})", + "error.validation.integer": "Indtast et gyldigt tal", + "error.validation.ip": "Indtast en gyldig IP adresse", + "error.validation.less": "Indtast venligst en værdi mindre end {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Værdien matcher ikke det forventede mønster", + "error.validation.max": "Indtast venligst en værdi lig med eller lavere end {max}", + "error.validation.maxlength": "Indtast venligst en kortere værdi. (maks. {max} karakterer)", + "error.validation.maxwords": "Indtast ikke flere end {max} ord", + "error.validation.min": "Indtast en værdi lig med eller højere end {min}", + "error.validation.minlength": "Indtast venligst en længere værdi. (min. {min} karakterer)", + "error.validation.minwords": "Indtast venligst mindst {min} ord", + "error.validation.more": "Indtast venligst en værdi større end {min}", + "error.validation.notcontains": "Indtast venligst en værdi der ikke indeholder \"{needle}\"", + "error.validation.notin": "Indtast venligst ikke nogen af følgende: ({notIn})", + "error.validation.option": "Vælg venligst en gyldig mulighed", + "error.validation.num": "Indtast venligst et gyldigt nummer", + "error.validation.required": "Indtast venligst noget", + "error.validation.same": "Indtast venligst \"{other}\"", + "error.validation.size": "Størrelsen på værdien skal være \"{size}\"", + "error.validation.startswith": "Værdien skal starte med \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Indtast venligst et gyldigt tidspunkt", + "error.validation.time.after": "Indtast venligst et tidspunkt efter {time}", + "error.validation.time.before": "Indtast venligst et tidspunkt inden {time}", + "error.validation.time.between": "Indtast venligst et tidspunkt imellem {min} og {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Indtast venligst en gyldig URL", + + "expand": "Fold ud", + "expand.all": "Fold alle ud", + + "field.invalid": "The field is invalid", + "field.required": "Feltet er påkrævet", + "field.blocks.changeType": "Skift type", + "field.blocks.code.name": "Kode", + "field.blocks.code.language": "Sprog", + "field.blocks.code.placeholder": "Din kode …", + "field.blocks.delete.confirm": "Ønsker du virkelig at slette denne blok?", + "field.blocks.delete.confirm.all": "Ønsker du virkelig at slette alle blokke?", + "field.blocks.delete.confirm.selected": "Ønsker du virkelig at slette de valgte blokke?", + "field.blocks.empty": "Ingen blokke endnu", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Vælg venligst en blok type", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galleri", + "field.blocks.gallery.images.empty": "Ingen billeder endnu", + "field.blocks.gallery.images.label": "Billeder", + "field.blocks.heading.level": "Niveau", + "field.blocks.heading.name": "Overskrift", + "field.blocks.heading.text": "Tekst", + "field.blocks.heading.placeholder": "Overskrift …", + "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": "Alternativ tekst", + "field.blocks.image.caption": "Billedtekst", + "field.blocks.image.crop": "Beskær", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Placering", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Billede", + "field.blocks.image.placeholder": "Vælg et billede", + "field.blocks.image.ratio": "Størrelsesforhold", + "field.blocks.image.url": "Billede URL", + "field.blocks.line.name": "Linje", + "field.blocks.list.name": "Liste", + "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": "Citeret af", + "field.blocks.quote.citation.placeholder": "af …", + "field.blocks.text.name": "Tekst", + "field.blocks.text.placeholder": "Tekst …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Billedtekst", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Placering", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Indtast URL til en video", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "Ingen indtastninger endnu.", + + "field.files.empty": "Ingen filer valgt endnu", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Slet layout", + "field.layout.delete.confirm": "Ønsker du virkelig at slette dette layout", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Ingen rækker endnu", + "field.layout.select": "Vælg et layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Ingen sider valgt endnu", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "\u00d8nsker du virkelig at slette denne indtastning?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Ingen indtastninger endnu.", + + "field.users.empty": "Ingen brugere er valgt", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "File", + "file.blueprint": "Denne fil har intet blueprint endnu. Du kan definere opsætningen i /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Skift skabelon", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "\u00d8nsker du virkelig at slette denne fil?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Skift position", + + "files": "Filer", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Ingen filer endnu", + + "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": "Skjul", + "hour": "Time", + "hue": "Hue", + "import": "Importer", + "info": "Info", + "insert": "Inds\u00e6t", + "insert.after": "Indsæt efter", + "insert.before": "Indsæt før", + "install": "Installer", + + "installation": "Installation", + "installation.completed": "Panelet er blevet installeret", + "installation.disabled": "Panel installationen er deaktiveret på offentlige servere som standard. Kør venligst installationen på en lokal maskine eller aktiver det med panel.install panel.install muligheden.", + "installation.issues.accounts": "\/site\/accounts er ikke skrivbar", + "installation.issues.content": "Content mappen samt alle underliggende filer og mapper skal v\u00e6re skrivbare.", + "installation.issues.curl": "CURL extension er påkrævet", + "installation.issues.headline": "Panelet kan ikke installeres", + "installation.issues.mbstring": "MB String extension er påkrævet", + "installation.issues.media": "/media mappen eksisterer ikke eller er ikke skrivbar", + "installation.issues.php": "Sikre dig at der benyttes PHP 8+", + "installation.issues.sessions": "/site/sessions mappen eksisterer ikke eller er ikke skrivbar", + + "language": "Sprog", + "language.code": "Kode", + "language.convert": "Gør standard", + "language.convert.confirm": "

Ønsker du virkelig at konvertere {name} til standardsproget? Dette kan ikke fortrydes.

Hvis {name} har uoversat indhold, vil der ikke længere være et gyldigt tilbagefald og dele af dit website vil måske fremstå tomt.

", + "language.create": "Tilføj nyt sprog", + "language.default": "Standardsprog", + "language.delete.confirm": "Ønsker du virkelig at slette sproget {name} inklusiv alle oversættelser? Kan ikke fortrydes!", + "language.deleted": "Sproget er blevet slettet", + "language.direction": "Læseretning", + "language.direction.ltr": "Venstre mod højre", + "language.direction.rtl": "Højre mod venstre", + "language.locale": "PHP locale string", + "language.locale.warning": "Du benytter en brugerdefineret sprogopsætning. Rediger venligst dette i sprogfilen i /site/languages", + "language.name": "Navn", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Sproget er blevet opdateret", + "language.variables": "Language variables", + "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", + + "languages": "Sprog", + "languages.default": "Standardsprog", + "languages.empty": "Der er ingen sprog endnu", + "languages.secondary": "Sekundære sprog", + "languages.secondary.empty": "Der er ingen sekundære sprog endnu", + + "license": "Kirby licens", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Køb en licens", + "license.code": "Kode", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Indtast venligst din licenskode", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Tak for din støtte af Kirby", + "license.unregistered.label": "Unregistered", + + "link": "Link", + "link.text": "Link tekst", + + "loading": "Indlæser", + + "lock.unsaved": "Ugemte ændringer", + "lock.unsaved.empty": "Der er ikke flere ændringer der ikke er gamt", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Lås op", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Log ind", + "login.code.label.login": "Log ind kode", + "login.code.label.password-reset": "Sikkerhedskode", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Hvis din email adresse er registreret er en sikkerhedskode blevet sendt via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hej {user.nameOrEmail},\n\nDu har for nyligt anmodet om en log ind kode til panelet af {site}.\nFølgende log ind kode vil være gyldig i {timeout} minutter:\n\n{code}\n\nHvis du ikke har anmodet om en log ind kode, kan du blot ignorere denne email eller kontakte din administrator hvis du har spørgsmål.\nAf sikkerhedsmæssige årsager, bør du IKKE videresende denne email.", + "login.email.login.subject": "Din log ind kode", + "login.email.password-reset.body": "Hej {user.nameOrEmail},\n\nDu har for nyligt anmodet om kode til nulstilling af adgangskode til panelet af {site}.\nFølgende kode til nulstilling af adgangskode vil være gyldig i {timeout} minutter:\n\n{code}\n\nHvis du ikke har anmodet om kode til nulstilling af adgangskode, kan du blot ignorere denne email eller kontakte din administrator hvis du har spørgsmål.\nAf sikkerhedsmæssige årsager, bør du IKKE videresende denne email.", + "login.email.password-reset.subject": "Din kode til nulstilling af adgangskode", + "login.remember": "Forbliv logget ind", + "login.reset": "Nulstil adgangskode", + "login.toggleText.code.email": "Log ind via email", + "login.toggleText.code.email-password": "Log ind med adgangskode", + "login.toggleText.password-reset.email": "Glemt din adgangskode?", + "login.toggleText.password-reset.email-password": "← Tilbage til log ind", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Log ud", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Medie Type", + "minutes": "Minutter", + + "month": "Måned", + "months.april": "April", + "months.august": "August", + "months.december": "December", + "months.february": "Februar", + "months.january": "Januar", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "Marts", + "months.may": "Maj", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Mere", + "move": "Move", + "name": "Navn", + "next": "Næste", + "night": "Night", + "no": "nej", + "off": "Sluk", + "on": "Aktiveret", + "open": "Åben", + "open.newWindow": "Åben i et nyt vindue", + "option": "Option", + "options": "Indstillinger", + "options.none": "Ingen muligheder", + "options.all": "Show all {count} options", + + "orientation": "Orientering", + "orientation.landscape": "Landskab", + "orientation.portrait": "Portræt", + "orientation.square": "Kvadrat", + + "page": "Page", + "page.blueprint": "Denne side har intet blueprint endnu. Du kan definere opsætningen i /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "\u00c6ndre URL", + "page.changeSlug.fromTitle": "Generer udfra titel", + "page.changeStatus": "Skift status", + "page.changeStatus.position": "Vælg venligst position", + "page.changeStatus.select": "Vælg en ny status", + "page.changeTemplate": "Skift skabelon", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "\u00d8nsker du virkelig at slette denne side?", + "page.delete.confirm.subpages": "Denne side har undersider.
Alle undersider vil også blive slettet.", + "page.delete.confirm.title": "Indtast sidens titel for at bekræfte", + "page.duplicate.appendix": "Kopier", + "page.duplicate.files": "Kopier filer", + "page.duplicate.pages": "Kopier sider", + "page.move": "Move page", + "page.sort": "Skift position", + "page.status": "Status", + "page.status.draft": "Kladde", + "page.status.draft.description": "Siden er i kladde udgave og er kun synlig for redaktører der er logget ind eller via hemmeligt link", + "page.status.listed": "Offentlig", + "page.status.listed.description": "Siden er offentlig for enhver", + "page.status.unlisted": "Ulistede", + "page.status.unlisted.description": "Siden er kun tilgængelig via URL", + + "pages": "Sider", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Ingen sider endnu", + "pages.status.draft": "Kladder", + "pages.status.listed": "Udgivede", + "pages.status.unlisted": "Ulistede", + + "pagination.page": "Side", + + "password": "Adgangskode", + "paste": "Indsæt", + "paste.after": "Indsæt efter", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Forrige", + "preview": "Forhåndsvisning", + + "publish": "Publish", + "published": "Udgivede", + + "remove": "Fjern", + "rename": "Omdøb", + "renew": "Renew", + "replace": "Erstat", + "replace.with": "Replace with", + "retry": "Pr\u00f8v igen", + "revert": "Kass\u00e9r", + "revert.confirm": "Ønsker du virkelig at slette all ændringer der ikke er gemt?", + + "role": "Rolle", + "role.admin.description": "Admin har alle rettigheder", + "role.admin.title": "Admin", + "role.all": "All", + "role.empty": "Der er ingen bruger med denne rolle", + "role.description.placeholder": "Ingen beskrivelse", + "role.nobody.description": "Dette er en tilbagefaldsrolle uden rettigheder", + "role.nobody.title": "Ingen", + + "save": "Gem", + "saved": "Saved", + "search": "Søg", + "searching": "Searching", + "search.min": "Indtast {min} tegn for at søge", + "search.all": "Show all {count} results", + "search.results.none": "Ingen resultater", + + "section.invalid": "The section is invalid", + "section.required": "Sektionen er påkrævet", + + "security": "Security", + "select": "Vælg", + "server": "Server", + "settings": "Indstillinger", + "show": "Vis", + "site.blueprint": "Sitet har intet blueprint endnu. Du kan definere opsætningen i /site/blueprints/site.yml", + "size": "Størrelse", + "slug": "URL-appendiks", + "sort": "Sorter", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Skabelon", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Titel", + "today": "Idag", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Kode", + "toolbar.button.bold": "Fed tekst", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Overskrifter", + "toolbar.button.heading.1": "Overskrift 1", + "toolbar.button.heading.2": "Overskrift 2", + "toolbar.button.heading.3": "Overskrift 3", + "toolbar.button.heading.4": "Overskrift 4", + "toolbar.button.heading.5": "Overskrift 5", + "toolbar.button.heading.6": "Overskrift 6", + "toolbar.button.italic": "Kursiv tekst", + "toolbar.button.file": "Fil", + "toolbar.button.file.select": "Vælg en fil", + "toolbar.button.file.upload": "Upload en fil", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Afsnit", + "toolbar.button.strike": "Gennemstreg", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Ordnet liste", + "toolbar.button.underline": "Understreg", + "toolbar.button.ul": "Punktliste", + + "translation.author": "Kirby Team", + "translation.direction": "ltr", + "translation.name": "Dansk", + "translation.locale": "da_DK", + + "type": "Type", + + "upload": "Upload", + "upload.error.cantMove": "Den uploadede fil kunne ikke flyttes", + "upload.error.cantWrite": "Kunne ikke skrive fil til disk", + "upload.error.default": "Filen kunne ikke uploades", + "upload.error.extension": "Upload af filen blev stoppet af dens type", + "upload.error.formSize": "Filen overskrider MAX_FILE_SIZE direktivet der er specificeret for formularen", + "upload.error.iniPostSize": "FIlen overskrider post_max_size direktivet i php.ini", + "upload.error.iniSize": "FIlen overskrider upload_max_filesize direktivet i php.ini", + "upload.error.noFile": "Ingen fil blev uploadet", + "upload.error.noFiles": "Ingen filer blev uploadet", + "upload.error.partial": "Den uploadede fil blev kun delvist uploadet", + "upload.error.tmpDir": "Der mangler en midlertidig mappe", + "upload.errors": "Fejl", + "upload.progress": "Uploader...", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Bruger", + "user.blueprint": "Du kan definere yderligere sektioner og formular felter for denne brugerrolle i /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Skift email", + "user.changeLanguage": "Skift sprog", + "user.changeName": "Omdøb denne bruger", + "user.changePassword": "Skift adgangskode", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Ny adgangskode", + "user.changePassword.new.confirm": "Bekræft den nye adgangskode...", + "user.changeRole": "Skift rolle", + "user.changeRole.select": "Vælg en ny rolle", + "user.create": "Tilføj en ny bruger", + "user.delete": "Slet denne bruger", + "user.delete.confirm": "\u00d8nsker du virkelig at slette denne bruger?", + + "users": "Brugere", + + "version": "Kirby version", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "Din konto", + "view.installation": "Installation", + "view.languages": "Sprog", + "view.resetPassword": "Nulstil adgangskode", + "view.site": "Website", + "view.system": "System", + "view.users": "Brugere", + + "welcome": "Velkommen", + "year": "År", + "yes": "ja" +} diff --git a/public/kirby/i18n/translations/de.json b/public/kirby/i18n/translations/de.json new file mode 100644 index 0000000..56aa459 --- /dev/null +++ b/public/kirby/i18n/translations/de.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Deinen Namen ändern", + "account.delete": "Deinen Account löschen", + "account.delete.confirm": "Willst du deinen Account wirklich löschen? Du wirst sofort danach abgemeldet. Dein Account kann nicht wieder hergestellt werden.", + + "activate": "Aktivieren", + "add": "Hinzuf\u00fcgen", + "alpha": "Alpha", + "author": "Autor", + "avatar": "Profilbild", + "back": "Zurück", + "cancel": "Abbrechen", + "change": "\u00c4ndern", + "close": "Schlie\u00dfen", + "changes": "Änderungen", + "confirm": "OK", + "collapse": "Zusammenklappen", + "collapse.all": "Alle zusammenklappen", + "color": "Farbe", + "coordinates": "Koordinaten", + "copy": "Kopieren", + "copy.all": "Alle kopieren", + "copy.success": "Kopiert", + "copy.success.multiple": "{count} kopiert!", + "copy.url": "URL kopieren", + "create": "Erstellen", + "custom": "Benutzerdefiniert", + + "date": "Datum", + "date.select": "Datum auswählen", + + "day": "Tag", + "days.fri": "Fr", + "days.mon": "Mo", + "days.sat": "Sa", + "days.sun": "So", + "days.thu": "Do", + "days.tue": "Di", + "days.wed": "Mi", + + "debugging": "Debugging", + + "delete": "L\u00f6schen", + "delete.all": "Alle löschen", + + "dialog.fields.empty": "Der Dialog hat keine Felder", + "dialog.files.empty": "Keine verfügbaren Dateien", + "dialog.pages.empty": "Keine verfügbaren Seiten", + "dialog.text.empty": "Dieser Dialog definiert keinen Text", + "dialog.users.empty": "Keine verfügbaren Accounts", + + "dimensions": "Maße", + "disable": "Deaktivieren", + "disabled": "Gesperrt", + "discard": "Verwerfen", + + "drawer.fields.empty": "Die Schublade hat keine Felder", + + "domain": "Domain", + "download": "Download", + "duplicate": "Duplizieren", + + "edit": "Bearbeiten", + + "email": "E-Mail", + "email.placeholder": "mail@beispiel.de", + + "enter": "Enter", + "entries": "Einträge", + "entry": "Eintrag", + + "environment": "Umgebung", + + "error": "Fehler", + "error.access.code": "Ungültiger Code", + "error.access.login": "Ungültige Zugangsdaten", + "error.access.panel": "Du hast keinen Zugang zum Panel", + "error.access.view": "Du hast keinen Zugriff auf diesen Teil des Panels", + + "error.avatar.create.fail": "Das Profilbild konnte nicht hochgeladen werden", + "error.avatar.delete.fail": "Das Profilbild konnte nicht gel\u00f6scht werden", + "error.avatar.dimensions.invalid": "Bitte lade ein Profilbild hoch, das nicht breiter oder höher als 3000 Pixel ist.", + "error.avatar.mime.forbidden": "Das Profilbild muss vom Format JPEG oder PNG sein", + + "error.blueprint.notFound": "Das Blueprint \"{name}\" konnte nicht geladen werden.", + + "error.blocks.max.plural": "Bitte füge nicht mehr als {max} Blöcke hinzu", + "error.blocks.max.singular": "Bitte füge nicht mehr als einen Block hinzu", + "error.blocks.min.plural": "Bitte füge mindestens {min} Blöcke hinzu", + "error.blocks.min.singular": "Bitte füge mindestens einen Block hinzu", + "error.blocks.validation": "Fehler im \"{field}\" Feld in Block {index} mit dem Block Typ \"{fieldset}\"", + + "error.cache.type.invalid": "Ungültiger Cachetyp: \"{type}\"", + + "error.content.lock.delete": "Die Version ist blockiert und kann daher nicht gelöscht werden.", + "error.content.lock.move": "Die Ursprungsversion ist blockiert und kann daher nicht gelöscht werden", + "error.content.lock.publish": "Die Version wurde bereits veröffentlicht", + "error.content.lock.replace": "Die Version ist blockiert und kann daher nicht ersetzt werden", + "error.content.lock.update": "Die Version ist blockiert und kann daher nicht geändert werden", + + "error.entries.max.plural": "Bitte füge nicht mehr als {max} Einträge hinzu", + "error.entries.max.singular": "Bitte füge nicht mehr als einen Eintrag hinzu", + "error.entries.min.plural": "Bitte füge mindestens {min} Einträge hinzu", + "error.entries.min.singular": "Bitte füge mindestens einen Eintrag hinzu", + "error.entries.supports": "Der Feldtyp \"{type}\" wird im Entries Feld nicht unterstützt.", + "error.entries.validation": "Fehler im Feld \"{field}\" in Zeile {index}", + + "error.email.preset.notFound": "Die E-Mailvorlage \"{name}\" wurde nicht gefunden", + + "error.field.converter.invalid": "Ungültiger Konverter: \"{converter}\"", + "error.field.link.options": "Ungültige Optionen: {options}", + "error.field.type.missing": "Feld \"{ name }\": Der Feldtyp \"{ type }\" existiert nicht", + + "error.file.changeName.empty": "Bitte gib einen Namen an", + "error.file.changeName.permission": "Du darfst den Dateinamen von \"{filename}\" nicht ändern", + "error.file.changeTemplate.invalid": "Die Vorlage für die Datei \"{id}\" kann nicht zu \"{template}\" geändert werden (gültig: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Du kannst die Vorlage für die Datei \"{id}\" nicht ändern", + + "error.file.delete.multiple": "Es konnten nicht alle Dateien gelöscht werden. Versuche verbleibende Dateien einzeln zu löschen, um die Ursachen festzustellen.", + "error.file.duplicate": "Eine Datei mit dem Dateinamen \"{filename}\" besteht bereits", + "error.file.extension.forbidden": "Verbotene Dateiendung \"{extension}\"", + "error.file.extension.invalid": "Verbotene Dateiendung \"{extension}\"", + "error.file.extension.missing": "Du kannst keine Dateien ohne Dateiendung hochladen", + "error.file.maxheight": "Die Bildhöhe darf {height} Pixel nicht überschreiten", + "error.file.maxsize": "Die Datei ist zu groß", + "error.file.maxwidth": "Die Bildbreite darf {width} Pixel nicht überschreiten", + "error.file.mime.differs": "Die Datei muss den Medientyp \"{mime}\" haben.", + "error.file.mime.forbidden": "Der Medientyp \"{mime}\" ist nicht erlaubt", + "error.file.mime.invalid": "Ungültiger Dateityp: {mime}", + "error.file.mime.missing": "Der Medientyp für \"{filename}\" konnte nicht erkannt werden", + "error.file.minheight": "Die Bildhöhe muss mindestens {height} Pixel betragen", + "error.file.minsize": "Die Datei ist zu klein", + "error.file.minwidth": "Die Bildbreite muss mindestens {width} Pixel betragen", + "error.file.name.unique": "Der Dateiname besteht bereits", + "error.file.name.missing": "Bitte gib einen Dateinamen an", + "error.file.notFound": "Die Datei \"{filename}\" konnte nicht gefunden werden", + "error.file.orientation": "Das Bildformat ist ungültig. Erwartetes Format: \"{orientation}\"", + "error.file.sort.permission": "Du darfst die Sortierung für \"{filename}\" nicht ändern.", + "error.file.type.forbidden": "Du kannst keinen {type}-Dateien hochladen", + "error.file.type.invalid": "Ungültiger Dateityp: {mime}", + "error.file.undefined": "Die Datei konnte nicht gefunden werden", + + "error.form.incomplete": "Bitte behebe alle Fehler …", + "error.form.notSaved": "Das Formular konnte nicht gespeichert werden", + + "error.language.code": "Bitte gib einen gültigen Code für die Sprache an", + "error.language.create.permission": "Du darfst keine Sprache anlegen", + "error.language.delete.permission": "Du darfst diese Sprache nicht löschen", + "error.language.duplicate": "Die Sprache besteht bereits", + "error.language.name": "Bitte gib einen gültigen Namen für die Sprache an", + "error.language.notFound": "Die Sprache konnte nicht gefunden werden", + "error.language.update.permission": "Du darfst diese Sprache nicht bearbeiten", + + "error.layout.validation.block": "Fehler im \"{field}\" Feld in Block {blockIndex} mit dem Blocktyp \"{fieldset}\" in Layout {layoutIndex}", + "error.layout.validation.settings": "Fehler in den Einstellungen von Layout {index}", + + "error.license.domain": "Die Domain für die Lizenz fehlt", + "error.license.email": "Bitte gib eine gültige E-Mailadresse an", + "error.license.format": "Bitte gib einen gültigen Lizenzschlüssel ein", + "error.license.verification": "Die Lizenz konnte nicht verifiziert werden", + + "error.login.totp.confirm.invalid": "Ungültiger Code", + "error.login.totp.confirm.missing": "Bitte gib den aktuellen Code ein", + + "error.object.validation": "Fehler im \"{label}\" Feld:\n{message}", + + "error.offline": "Das Panel ist zur Zeit offline", + + "error.page.changeSlug.permission": "Du darfst die URL der Seite \"{slug}\" nicht ändern", + "error.page.changeSlug.reserved": "Der Pfad für Top-Level Seiten darf nicht mit \"{path}\" beginnen.", + "error.page.changeStatus.incomplete": "Die Seite ist nicht vollständig und kann daher nicht veröffentlicht werden", + "error.page.changeStatus.permission": "Der Status der Seite kann nicht geändert werden", + "error.page.changeStatus.toDraft.invalid": "Die Seite \"{slug}\" kann nicht in einen Entwurf umgewandelt werden", + "error.page.changeTemplate.invalid": "Die Vorlage für die Seite \"{slug}\" kann nicht geändert werden", + "error.page.changeTemplate.permission": "Du kannst die Vorlage für die Seite \"{slug}\" nicht ändern", + "error.page.changeTitle.empty": "Bitte gib einen Titel an", + "error.page.changeTitle.permission": "Du kannst den Titel für die Seite \"{slug}\" nicht ändern", + "error.page.create.permission": "Du kannst die Seite \"{slug}\" nicht anlegen", + "error.page.delete": "Die Seite \"{slug}\" kann nicht gelöscht werden", + "error.page.delete.confirm": "Bitte gib zur Bestätigung den Seitentitel ein", + "error.page.delete.hasChildren": "Die Seite hat Unterseiten und kann nicht gelöscht werden", + "error.page.delete.multiple": "Es konnten nicht alle Seiten gelöscht werden. Versuche verbleibende Seiten einzeln zu löschen, um die Ursachen festzustellen.", + "error.page.delete.permission": "Du kannst die Seite \"{slug}\" nicht löschen", + "error.page.draft.duplicate": "Ein Entwurf mit dem URL-Kürzel \"{slug}\" besteht bereits", + "error.page.duplicate": "Eine Seite mit dem URL-Kürzel \"{slug}\" besteht bereits", + "error.page.duplicate.permission": "Du kannst die Seite \"{slug}\" nicht duplizieren", + "error.page.move.ancestor": "Die Seite kann nicht in sich selbst verschoben werden", + "error.page.move.directory": "Der Ordner der Seite kann nicht verschoben werden", + "error.page.move.duplicate": "Eine Seite mit dem URL-Kürzel \"{slug}\" besteht bereits", + "error.page.move.noSections": "Die Seite \"{parent}\" kann nicht ausgewählt werden, weil sie keine Unterseiten haben kann. ", + "error.page.move.notFound": "Die verschobene Seite kann nicht gefunden werden", + "error.page.move.permission": "Du kannst die Seite \"{slug}\" nicht verschieben", + "error.page.move.template": "Die Vorlage \"{template}\" wird nicht als Unterseite von \"{parent}\" akzeptiert", + "error.page.notFound": "Die Seite \"{slug}\" konnte nicht gefunden werden", + "error.page.num.invalid": "Bitte gib eine gültige Sortierungszahl an. Negative Zahlen sind nicht erlaubt.", + "error.page.slug.invalid": "Bitte gib ein gültiges URL-Kürzel an", + "error.page.slug.maxlength": "Die Pfadlänge darf {length} Zeichen nicht überschreiten", + "error.page.sort.permission": "Die Seite \"{slug}\" kann nicht umsortiert werden", + "error.page.status.invalid": "Bitte gib einen gültigen Seitenstatus an", + "error.page.undefined": "Die Seite konnte nicht gefunden werden", + "error.page.update.permission": "Du kannst die Seite \"{slug}\" nicht editieren", + + "error.section.files.max.plural": "Bitte füge nicht mehr als {max} Dateien zum Bereich \"{section}\" hinzu", + "error.section.files.max.singular": "Bitte füge nicht mehr als eine Datei zum Bereich \"{section}\" hinzu", + "error.section.files.min.plural": "Der Bereich \"{section}\" benötigt mindestens {min} Dateien", + "error.section.files.min.singular": "Der Bereich \"{section}\" benötigt mindestens eine Datei", + + "error.section.pages.max.plural": "Bitte füge nicht mehr als {max} Seiten zum Bereich \"{section}\" hinzu", + "error.section.pages.max.singular": "Bitte füge nicht mehr als eine Seite zum Bereich \"{section}\" hinzu", + "error.section.pages.min.plural": "Der Bereich \"{section}\" benötigt mindestens {min} Seiten", + "error.section.pages.min.singular": "Der Bereich \"{section}\" benötigt mindestens eine Seite", + + "error.section.notLoaded": "Der Bereich \"{name}\" konnte nicht geladen werden", + "error.section.type.invalid": "Der Bereichstyp \"{type}\" ist nicht gültig", + + "error.site.changeTitle.empty": "Bitte gib einen Titel an", + "error.site.changeTitle.permission": "Du kannst den Titel der Seite nicht ändern", + "error.site.update.permission": "Du darfst die Seite nicht bearbeiten", + + "error.structure.validation": "Fehler im Feld \"{field}\" in Zeile {index}", + + "error.template.default.notFound": "Die \"Default\"-Vorlage existiert nicht", + + "error.unexpected": "Ein unerwarteter Fehler ist aufgetreten. Aktiviere den Debug-Modus für weitere Informationen: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Du kannst die E-Mailadresse für den Account \"{name}\" nicht ändern", + "error.user.changeLanguage.permission": "Du kannst die Sprache für den Account \"{name}\" nicht ändern", + "error.user.changeName.permission": "Du kannst den Namen für den Account \"{name}\" nicht ändern", + "error.user.changePassword.permission": "Du kannst das Passwort für den Account \"{name}\" nicht ändern", + "error.user.changeRole.lastAdmin": "Die Rolle des letzten Accounts mit Administrationsrechten kann nicht geändert werden", + "error.user.changeRole.permission": "Du kannst die Rolle für den Benutzer \"{name}\" nicht ändern", + "error.user.changeRole.toAdmin": "Du darfst die Admin-Rolle nicht an andere Accounts vergeben", + "error.user.create.permission": "Du darfst diesen Account nicht anlegen", + "error.user.delete": "Der Account \"{name}\" konnte nicht gelöscht werden", + "error.user.delete.lastAdmin": "Du kannst den letzten Account mit Administrationsrechten nicht löschen", + "error.user.delete.lastUser": "Der letzte Account kann nicht gelöscht werden", + "error.user.delete.permission": "Du darfst den Account \"{name}\" nicht löschen", + "error.user.duplicate": "Ein Account mit der E-Mailadresse \"{email}\" besteht bereits", + "error.user.email.invalid": "Bitte gib eine gültige E-Mailadresse an", + "error.user.language.invalid": "Bitte gib eine gültige Sprache an", + "error.user.notFound": "Der Account \"{name}\" wurde nicht gefunden", + "error.user.password.excessive": "Bitte gib ein gültiges Passwort ein. Passwörter dürfen nicht länger als 1000 Zeichen sein.", + "error.user.password.invalid": "Bitte gib ein gültiges Passwort ein. Passwörter müssen mindestens 8 Zeichen lang sein.", + "error.user.password.notSame": "Die Passwörter stimmen nicht überein", + "error.user.password.undefined": "Der Account hat kein Passwort", + "error.user.password.wrong": "Falsches Passwort", + "error.user.role.invalid": "Bitte gib eine gültige Rolle an", + "error.user.undefined": "Der Benutzer wurde nicht gefunden", + "error.user.update.permission": "Du darfst den den Account \"{name}\" nicht bearbeiten", + + "error.validation.accepted": "Bitte bestätige", + "error.validation.alpha": "Bitte gib nur Zeichen zwischen A und Z ein", + "error.validation.alphanum": "Bitte gib nur Zeichen zwischen A und Z und Zahlen zwischen 0 und 9 ein", + "error.validation.anchor": "Bitte gib einen korrekten Anker an", + "error.validation.between": "Bitte gib einen Wert zwischen \"{min}\" und \"{max}\" ein", + "error.validation.boolean": "Bitte bestätige oder lehne ab", + "error.validation.color": "Bitte gib eine gültige Farbe im Format {format} ein", + "error.validation.contains": "Bitte gib einen Wert ein, der \"{needle}\" enthält", + "error.validation.date": "Bitte gib ein gültiges Datum ein", + "error.validation.date.after": "Bitte gib ein Datum nach dem {date} ein", + "error.validation.date.before": "Bitte gib ein Datum vor dem {date} ein", + "error.validation.date.between": "Bitte gib ein Datum zwischen dem {min} und dem {max} ein", + "error.validation.denied": "Bitte lehne die Eingabe ab", + "error.validation.different": "Der Wert darf nicht \"{other}\" sein", + "error.validation.email": "Bitte gib eine gültige E-Mailadresse an", + "error.validation.endswith": "Der Wert muss auf \"{end}\" enden", + "error.validation.filename": "Bitte gib einen gültigen Dateinamen ein", + "error.validation.in": "Bitte gib einen der folgenden Werte ein: ({in})", + "error.validation.integer": "Bitte gib eine ganze Zahl ein", + "error.validation.ip": "Bitte gib eine gültige IP Adresse ein", + "error.validation.less": "Bitte gib einen Wert kleiner als {max} ein", + "error.validation.linkType": "Der Linktyp ist nicht erlaubt", + "error.validation.match": "Der Wert entspricht nicht dem erwarteten Muster", + "error.validation.max": "Bitte gib einen Wert ein, der nicht größer als {max} ist", + "error.validation.maxlength": "Bitte gib einen kürzeren Text ein (max. {max} Zeichen)", + "error.validation.maxwords": "Bitte nutze nicht mehr als {max} Wort(e)", + "error.validation.min": "Bitte gib einen Wert ein, der nicht kleiner als {min} ist", + "error.validation.minlength": "Bitte gib einen längeren Text ein. (min. {min} Zeichen)", + "error.validation.minwords": "Bitte nutze mindestens {min} Wort(e)", + "error.validation.more": "Bitte gib einen größeren Wert als {min} ein", + "error.validation.notcontains": "Bitte gib einen Wert ein, der nicht \"{needle}\" enthält", + "error.validation.notin": "Bitte gib keinen der folgenden Werte ein: ({notIn})", + "error.validation.option": "Bitte wähle eine gültige Option aus", + "error.validation.num": "Bitte gib eine gültige Zahl an", + "error.validation.required": "Bitte gib etwas ein", + "error.validation.same": "Bitte gib \"{other}\" ein", + "error.validation.size": "Die Größe des Wertes muss \"{size}\" sein", + "error.validation.startswith": "Der Wert muss mit \"{start}\" beginnen", + "error.validation.tel": "Bitte gib eine unformatierte Telefonnummer an", + "error.validation.time": "Bitte gib eine gültige Uhrzeit ein", + "error.validation.time.after": "Bitte gib eine Zeit nach {time} ein", + "error.validation.time.before": "Bitte gib eine Zeit vor {time} ein", + "error.validation.time.between": "Bitte gib eine Zeit zwischen {min} und {max} ein", + "error.validation.uuid": "Bitte gib eine gültige UUID an", + "error.validation.url": "Bitte gib eine gültige URL ein", + + "expand": "Aufklappen", + "expand.all": "Alle aufklappen", + + "field.invalid": "Das Feld ist ungültig", + "field.required": "Das Feld ist Pflicht", + "field.blocks.changeType": "Blocktyp ändern", + "field.blocks.code.name": "Code", + "field.blocks.code.language": "Sprache", + "field.blocks.code.placeholder": "Code …", + "field.blocks.delete.confirm": "Willst du diesen Block wirklich löschen?", + "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 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", + "field.blocks.gallery.images.empty": "Keine Bilder", + "field.blocks.gallery.images.label": "Bilder", + "field.blocks.heading.level": "Ebene", + "field.blocks.heading.name": "Überschrift", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Überschrift …", + "field.blocks.figure.back.plain": "Ohne", + "field.blocks.figure.back.pattern.light": "Muster (hell)", + "field.blocks.figure.back.pattern.dark": "Muster (dunkel)", + "field.blocks.image.alt": "Alternativer Text", + "field.blocks.image.caption": "Bildunterschrift", + "field.blocks.image.crop": "Beschneiden", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Ort", + "field.blocks.image.location.internal": "Diese Webseite", + "field.blocks.image.location.external": "Externe Quelle", + "field.blocks.image.name": "Bild", + "field.blocks.image.placeholder": "Bild auswählen", + "field.blocks.image.ratio": "Seitenverhältnis", + "field.blocks.image.url": "Bild URL", + "field.blocks.line.name": "Linie", + "field.blocks.list.name": "Liste", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Zitat", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Zitat …", + "field.blocks.quote.citation.label": "Quelle", + "field.blocks.quote.citation.placeholder": "Quelle …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Bildunterschrift", + "field.blocks.video.controls": "Steuerung", + "field.blocks.video.location": "Ort", + "field.blocks.video.loop": "Schleife", + "field.blocks.video.muted": "Stumm", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Video-URL eingeben", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Vorladen", + "field.blocks.video.url.label": "Video-URL", + "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": "Keine Einträge", + + "field.files.empty": "Keine Dateien ausgewählt", + "field.files.empty.single": "Keine Dateien ausgewählt", + + "field.layout.change": "Layout ändern", + "field.layout.delete": "Layout löschen", + "field.layout.delete.confirm": "Willst du dieses Layout wirklich löschen?", + "field.layout.delete.confirm.all": "Willst du wirklich alle Layouts löschen?", + "field.layout.empty": "Keine Layouts", + "field.layout.select": "Layout auswählen", + + "field.object.empty": "Noch keine Information", + + "field.pages.empty": "Keine Seiten ausgewählt", + "field.pages.empty.single": "Keine Seiten ausgewählt", + + "field.structure.delete.confirm": "Willst du diesen Eintrag wirklich l\u00f6schen?", + "field.structure.delete.confirm.all": "Möchtest du wirklich alle Einträge löschen?", + "field.structure.empty": "Es bestehen keine Eintr\u00e4ge.", + + "field.users.empty": "Keine Accounts ausgewählt", + "field.users.empty.single": "Keine Accounts ausgewählt", + + "fields.empty": "Keine Felder", + + "file": "Datei", + "file.blueprint": "Du kannst zusätzliche Felder und Bereiche für diese Datei in /site/blueprints/files/{blueprint}.yml anlegen", + "file.changeTemplate": "Vorlage ändern", + "file.changeTemplate.notice": "Das Ändern der Dateivorlage wird alle Inhalte von Feldern entfernen, deren Feldtypen nicht übereinstimmen. Wenn die neue Vorlage bestimmte Regeln definiert, z.B. Bildabmessungen, werden diese unwiderruflich angewandt. Benutze diese Funktion mit Vorsicht.", + "file.delete.confirm": "Willst du die Datei {filename}
wirklich löschen?", + "file.focus.placeholder": "Fokuspunkt setzen", + "file.focus.reset": "Fokuspunkt entfernen", + "file.focus.title": "Fokus", + "file.sort": "Position ändern", + + "files": "Dateien", + "files.delete.confirm.selected": "Willst du wirklich die ausgewählten Dateien löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "files.empty": "Keine Dateien", + + "filter": "Filter", + + "form.discard": "Änderungen verwerfen", + "form.discard.confirm": "Willst du wirklich alle ungespeicherten Änderungen verwerfen? ", + "form.locked": "Dieser Inhalt ist gesperrt, weil er aktuell von einem anderen Account bearbeitet wird", + "form.unsaved": "Die aktuellen Änderungen wurden noch nicht gespeichert", + "form.preview": "Änderungsvorschau", + "form.preview.draft": "Entwurfsvorschau", + + "hide": "Verbergen", + "hour": "Stunde", + "hue": "Farbton", + "import": "Importieren", + "info": "Info", + "insert": "Einf\u00fcgen", + "insert.after": "Danach einfügen", + "insert.before": "Davor einfügen", + "install": "Installieren", + + "installation": "Installation", + "installation.completed": "Das Panel wurde installiert", + "installation.disabled": "Die Panel-Installation ist auf öffentlichen Servern automatisch deaktiviert. Bitte installiere das Panel auf einem lokalen Server oder aktiviere die Installation gezielt mit der panel.install Option. ", + "installation.issues.accounts": "/site/accounts ist nicht beschreibbar", + "installation.issues.content": "/content existiert nicht oder ist nicht beschreibbar", + "installation.issues.curl": "Die CURL Erweiterung wird benötigt", + "installation.issues.headline": "Das Panel kann nicht installiert werden", + "installation.issues.mbstring": "Die MB String Erweiterung wird benötigt", + "installation.issues.media": "Der /media Ordner ist nicht beschreibbar", + "installation.issues.php": "Bitte verwende PHP 8+", + "installation.issues.sessions": "/site/sessions ist nicht beschreibbar", + + "language": "Sprache", + "language.code": "Code", + "language.convert": "Als Standard auswählen", + "language.convert.confirm": "

Willst du {name} wirklich in die Standardsprache umwandeln? Dieser Schritt kann nicht rückgängig gemacht werden.

Wenn {name} unübersetzte Felder hat, gibt es keine gültigen Standardwerte für diese Felder und Inhalte könnten verloren gehen.

", + "language.create": "Neue Sprache anlegen", + "language.default": "Standardsprache", + "language.delete.confirm": "Willst du {name} inklusive aller Übersetzungen wirklich löschen? Dieser Schritt kann nicht rückgängig gemacht werden!", + "language.deleted": "Die Sprache wurde gelöscht", + "language.direction": "Leserichtung", + "language.direction.ltr": "Von links nach rechts", + "language.direction.rtl": "Von rechts nach links", + "language.locale": "PHP locale string", + "language.locale.warning": "Du nutzt ein angepasstes Setup for PHP Locales. Bitte bearbeite dieses direkt in der entsprechenden Sprachdatei in /site/languages", + "language.name": "Name", + "language.secondary": "Sekundäre Sprachen", + "language.settings": "Spracheinstellungen", + "language.updated": "Die Sprache wurde gespeichert", + "language.variables": "Sprachvariablen", + "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", + + "languages": "Sprachen", + "languages.default": "Standardsprache", + "languages.empty": "Noch keine Sprachen", + "languages.secondary": "Sekundäre Sprachen", + "languages.secondary.empty": "Noch keine sekundären Sprachen", + + "license": "Lizenz", + "license.activate": "Aktiviere Kirby jetzt", + "license.activate.label": "Bitte aktiviere deine Lizenz", + "license.activate.domain": "Deine Lizenz wird für die Domain {host} aktiviert.", + "license.activate.local": "Du bist dabei, deine Kirby Lizenz für die lokale Domain {host} zu aktivieren. Falls diese Seite später unter einer anderen Domain veröffentlicht wird, solltest du sie erst dort aktivieren. Falls {host} die Domain ist, die du für deine Lizenz nutzen möchtest, fahre bitte fort. ", + "license.activated": "Aktiviert", + "license.buy": "Kaufe eine Lizenz", + "license.code": "Code", + "license.code.help": "Du hast deinen Lizenz Code nach dem Kauf per Email bekommen. Bitte kopiere sie aus der Email und füge sie hier ein. ", + "license.code.label": "Bitte gib deinen Lizenzcode ein", + "license.status.active.info": "Beinhaltet neue Major Versionen bis {date}", + "license.status.active.label": "Gültige Lizenz", + "license.status.demo.info": "Dies ist eine Demo Installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Lizenz erneuern, um auf neue Major Versionen upzudaten", + "license.status.inactive.label": "Keine neuen Major Versionen", + "license.status.legacy.bubble": "Bereit, die Lizenz zu erneuern? ", + "license.status.legacy.info": "Deine Lizenz deckt diese Version nicht ab", + "license.status.legacy.label": "Bitte erneuere deine Lizenz", + "license.status.missing.bubble": "Bereit, deine Seite zu veröffentlichen?", + "license.status.missing.info": "Keine gültige Lizenz", + "license.status.missing.label": "Bitte aktiviere deine Lizenz", + "license.status.unknown.info": "Der Lizenzstatus ist unbekannt", + "license.status.unknown.label": "Unbekannt", + "license.manage": "Verwalte deine Lizenzen", + "license.purchased": "Gekauft", + "license.success": "Vielen Dank für deine Unterstützung", + "license.unregistered.label": "Unregistriert", + + "link": "Link", + "link.text": "Linktext", + + "loading": "Laden", + + "lock.unsaved": "Ungespeicherte Änderungen", + "lock.unsaved.empty": "Keine ungespeicherten Änderungen", + "lock.unsaved.files": "Geänderte Dateien", + "lock.unsaved.pages": "Geänderte Seiten", + "lock.unsaved.users": "Geänderte Accounts", + "lock.isLocked": "Ungespeicherte Änderungen von {email}", + "lock.unlock": "Entsperren", + "lock.unlock.submit": "Entsperre und überschreibe ungespeicherte Änderungen von {email}", + "lock.isUnlocked": "Wurde von einem*r anderen Benutzer*in überschrieben", + + "login": "Anmelden", + "login.code.label.login": "Anmeldecode", + "login.code.label.password-reset": "Anmeldecode", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Wenn deine E-Mail-Adresse registriert ist, wurde der angeforderte Code per E-Mail versendet.", + "login.code.text.totp": "Bitte gib den Einmal-Code von deiner Authentifizierungs-App ein. ", + "login.email.login.body": "Hallo {user.nameOrEmail},\n\ndu hast gerade einen Anmeldecode für das Kirby Panel von {site} angefordert.\n\nDer folgende Anmeldecode ist für die nächsten {timeout} Minuten gültig:\n\n{code}\n\nWenn du keinen Anmeldecode angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere bei Fragen deinen Administrator.\nBitte leite diese E-Mail aus Sicherheitsgründen NICHT weiter.", + "login.email.login.subject": "Dein Anmeldecode", + "login.email.password-reset.body": "Hallo {user.nameOrEmail},\n\ndu hast gerade einen Anmeldecode für das Kirby Panel von {site} angefordert.\n\nDer folgende Anmeldecode ist für die nächsten {timeout} Minuten gültig:\n\n{code}\n\nWenn du keinen Anmeldecode angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere bei Fragen deinen Administrator.\nBitte leite diese E-Mail aus Sicherheitsgründen NICHT weiter.", + "login.email.password-reset.subject": "Dein Anmeldecode", + "login.remember": "Angemeldet bleiben", + "login.reset": "Passwort zurücksetzen", + "login.toggleText.code.email": "Anmelden über E-Mail", + "login.toggleText.code.email-password": "Anmelden mit Passwort", + "login.toggleText.password-reset.email": "Passwort vergessen?", + "login.toggleText.password-reset.email-password": "← Zurück zur Anmeldung", + "login.totp.enable.option": "Einmal-Codes einrichten", + "login.totp.enable.intro": "Authentifizierungs-Apps können Einmal-Codes erstellen, die als zweiter Faktor für die Anmeldung dienen. ", + "login.totp.enable.qr.label": "1. Scanne diesen QR Code", + "login.totp.enable.qr.help": "Scannen funktioniert nicht? Gib den Setup-Schlüssel {secret} manuell in deiner Authentifizierungs-App ein. ", + "login.totp.enable.confirm.headline": "2. Bestätige den erstellten Code.", + "login.totp.enable.confirm.text": "Deine App erstellt alle 30 Sekunden einen neuen Einmal-Code. Gib den aktuellen Code ein, um das Setup abzuschliessen. ", + "login.totp.enable.confirm.label": "Aktueller Code", + "login.totp.enable.confirm.help": "Nach dem Setup werden wir dich bei jeder Anmeldung nach einem Einmal-Code fragen. ", + "login.totp.enable.success": "Einmal-Codes aktiviert", + "login.totp.disable.option": "Einmal-Codes deaktivieren", + "login.totp.disable.label": "Gib dein Passwort ein, um die Einmal-Codes zu deaktivieren. ", + "login.totp.disable.help": "In Zukunft wird bei der Anmeldung ein anderer zweiter Faktor abgefragt. Z.B. ein Login-Code der per Email zugeschickt wird. Du kannst die Einmal-Codes jeder Zeit später wieder neu einrichten. ", + "login.totp.disable.admin": "

Einmal-Codes für {user} werden hiermit deaktiviert.

In Zukunft wird für die Anmeldung ein anderer zweiter Faktor abgefragt. Z.B. ein Login-Code, der per Email zugeschickt wird. {user} kann nach der nächsten Anmeldung jeder Zeit wieder Einmal-Codes für den Account aktivieren.

", + "login.totp.disable.success": "Einmal-Codes deaktiviert", + + "logout": "Abmelden", + + "merge": "Zusammenfügen", + "menu": "Menü", + "meridiem": "AM/PM", + "mime": "Medientyp", + "minutes": "Minuten", + + "month": "Monat", + "months.april": "April", + "months.august": "August", + "months.december": "Dezember", + "months.february": "Februar", + "months.january": "Januar", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "M\u00e4rz", + "months.may": "Mai", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Mehr", + "move": "Bewegen", + "name": "Name", + "next": "Nächster Eintrag", + "night": "Nacht", + "no": "nein", + "off": "aus", + "on": "an", + "open": "Öffnen", + "open.newWindow": "In neuem Fenster öffnen", + "option": "Option", + "options": "Optionen", + "options.none": "Keine Optionen", + "options.all": "Zeige alle {count} Optionen", + + "orientation": "Ausrichtung", + "orientation.landscape": "Querformat", + "orientation.portrait": "Hochformat", + "orientation.square": "Quadratisch", + + "page": "Seite", + "page.blueprint": "Du kannst zusätzliche Felder und Bereiche für diese Seite in /site/blueprints/pages/{blueprint}.yml anlegen", + "page.changeSlug": "URL \u00e4ndern", + "page.changeSlug.fromTitle": "Aus Titel erzeugen", + "page.changeStatus": "Status ändern", + "page.changeStatus.position": "Bitte wähle eine Position aus", + "page.changeStatus.select": "Wähle einen neuen Status aus", + "page.changeTemplate": "Vorlage ändern", + "page.changeTemplate.notice": "Das Ändern der Vorlage wird Inhalte entfernen, deren Feldtypen nicht übereinstimmen. Verwende diese Funktion mit Vorsicht.", + "page.create": "Anlegen als \"{status}\"", + "page.delete.confirm": "Willst du die Seite {title} wirklich löschen?", + "page.delete.confirm.subpages": "Diese Seite hat Unterseiten.
Alle Unterseiten werden ebenfalls gelöscht.", + "page.delete.confirm.title": "Gib zur Bestätigung den Seitentitel ein", + "page.duplicate.appendix": "Kopie", + "page.duplicate.files": "Dateien kopieren", + "page.duplicate.pages": "Seiten kopieren", + "page.move": "Seite bewegen", + "page.sort": "Position ändern", + "page.status": "Status", + "page.status.draft": "Entwurf", + "page.status.draft.description": "Die Seite ist im Entwurfsmodus und ist nur nach Anmeldung oder über den geheimen Link sichtbar", + "page.status.listed": "Öffentlich", + "page.status.listed.description": "Die Seite ist öffentlich für alle", + "page.status.unlisted": "Ungelistet", + "page.status.unlisted.description": "Die Seite kann nur über die URL aufgerufen werden", + + "pages": "Seiten", + "pages.delete.confirm.selected": "Willst du wirklich die ausgewählten Seiten löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "pages.empty": "Keine Seiten", + "pages.status.draft": "Entwürfe", + "pages.status.listed": "Veröffentlicht", + "pages.status.unlisted": "Ungelistet", + + "pagination.page": "Seite", + + "password": "Passwort", + "paste": "Einfügen", + "paste.after": "Danach einfügen", + "paste.success": "{count} eingefügt!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Vorheriger Eintrag", + "preview": "Vorschau", + + "publish": "Veröffentlichen", + "published": "Veröffentlicht", + + "remove": "Entfernen", + "rename": "Umbenennen", + "renew": "Erneuern", + "replace": "Ersetzen", + "replace.with": "Ersetzen mit", + "retry": "Wiederholen", + "revert": "Verwerfen", + "revert.confirm": "Willst du wirklich alle ungespeicherten Änderungen verwerfen? ", + + "role": "Rolle", + "role.admin.description": "Admins haben alle Rechte", + "role.admin.title": "Admin", + "role.all": "Alle", + "role.empty": "Keine Accounts mit dieser Rolle", + "role.description.placeholder": "Keine Beschreibung", + "role.nobody.description": "Dies ist die Platzhalterrolle ohne Rechte", + "role.nobody.title": "Niemand", + + "save": "Speichern", + "saved": "Gespeichert", + "search": "Suchen", + "searching": "Suchen", + "search.min": "Gib mindestens {min}  Zeichen ein, um zu suchen", + "search.all": "Zeige alle {count} Ergebnisse", + "search.results.none": "Keine Ergebnisse", + + "section.invalid": "Der Bereich ist ungültig", + "section.required": "Der Bereich ist Pflicht", + + "security": "Sicherheit", + "select": "Auswählen", + "server": "Server", + "settings": "Einstellungen", + "show": "Anzeigen", + "site.blueprint": "Du kannst zusätzliche Felder und Bereiche für die Seite in /site/blueprints/site.yml anlegen", + "size": "Größe", + "slug": "URL-Anhang", + "sort": "Sortieren", + "sort.drag": "Bewegen um zu sortieren …", + "split": "Teilen", + + "stats.empty": "Keine Daten", + "status": "Status", + + "system.info.copy": "Info kopieren", + "system.info.copied": "System Info wurde kopiert", + "system.issues.content": "Der content Ordner scheint öffentlich zugänglich zu sein", + "system.issues.eol.kirby": "Deine Kirby Installation ist veraltet und erhält keine weiteren Sicherheitsupdates", + "system.issues.eol.plugin": "Deine Version des { plugin } Plugins ist veraltet und erhält keine weiteren Sicherheitsupdates", + "system.issues.eol.php": "Deine installierte PHP-Version { release } ist veraltet und erhält keinen Sicherheits-Updates mehr", + "system.issues.debug": "Debugging muss im öffentlichen Betrieb ausgeschaltet sein", + "system.issues.git": "Der .git Ordner scheint öffentlich zugänglich zu sein", + "system.issues.https": "Wir empfehlen HTTPS für alle deine Seiten", + "system.issues.kirby": "Der kirby Ordner scheint öffentlich zugänglich zu sein", + "system.issues.local": "Die Seite läuft lokal mit abgeschwächten Sicherheitschecks", + "system.issues.site": "Der site Ordner scheint öffentlich zugänglich zu sein", + "system.issues.vue.compiler": "Der Vue Template Compiler ist aktiviert", + "system.issues.vulnerability.kirby": "Deine Installation könnte von folgender Sicherheitslücke betroffen sein ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Deine Installation könnte von folgender Sicherheitslücke im { plugin } Plugin betroffen sein ({ severity } severity): { description }", + "system.updateStatus": "Update Status", + "system.updateStatus.error": "Update Check nicht möglich", + "system.updateStatus.not-vulnerable": "Keine bekannten Sicherheitslücken", + "system.updateStatus.security-update": "Kostenloses Sicherheitsupdate { version } verfügbar", + "system.updateStatus.security-upgrade": "Upgrade { version } mit Sicherheitsverbesserungen verfügbar ", + "system.updateStatus.unreleased": "Unveröffentlichte Version", + "system.updateStatus.up-to-date": "Aktuell", + "system.updateStatus.update": "Kostenloses Update { version } verfügbar", + "system.updateStatus.upgrade": "Upgrade { version } verfügbar", + + "tel": "Telefon", + "tel.placeholder": "+49123456789", + "template": "Vorlage", + + "theme": "Thema", + "theme.light": "Licht an", + "theme.dark": "Licht aus", + "theme.automatic": "Systemeinstellung übernehmen", + + "title": "Titel", + "today": "Heute", + + "toolbar.button.clear": "Formatierung entfernen", + "toolbar.button.code": "Code", + "toolbar.button.bold": "Fetter Text", + "toolbar.button.email": "E-Mail", + "toolbar.button.headings": "Überschriften", + "toolbar.button.heading.1": "Überschrift 1", + "toolbar.button.heading.2": "Überschrift 2", + "toolbar.button.heading.3": "Überschrift 3", + "toolbar.button.heading.4": "Überschrift 4", + "toolbar.button.heading.5": "Überschrift 5", + "toolbar.button.heading.6": "Überschrift 6", + "toolbar.button.italic": "Kursiver Text", + "toolbar.button.file": "Datei", + "toolbar.button.file.select": "Datei auswählen", + "toolbar.button.file.upload": "Datei hochladen", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Absatz", + "toolbar.button.strike": "Durchgestrichen", + "toolbar.button.sub": "Tiefgestellt", + "toolbar.button.sup": "Hochgestellt", + "toolbar.button.ol": "Geordnete Liste", + "toolbar.button.underline": "Unterstrichen", + "toolbar.button.ul": "Ungeordnete Liste", + + "translation.author": "Kirby Team", + "translation.direction": "ltr", + "translation.name": "Deutsch", + "translation.locale": "de_DE", + + "type": "Typ", + + "upload": "Hochladen", + "upload.error.cantMove": "Die Datei konnte nicht an ihren Zielort bewegt werden", + "upload.error.cantWrite": "Die Datei konnte nicht auf der Festplatte gespeichert werden", + "upload.error.default": "Die Datei konnte nicht hochgeladen werden", + "upload.error.extension": "Der Dateiupload wurde durch eine Erweiterung verhindert", + "upload.error.formSize": "Die Datei ist größer als die MAX_FILE_SIZE Einstellung im Formular", + "upload.error.iniPostSize": "Die Datei ist größer als die post_max_size Einstellung in der php.ini", + "upload.error.iniSize": "Die Datei ist größer als die upload_max_filesize Einstellung in der php.ini", + "upload.error.noFile": "Es wurde keine Datei hochgeladen", + "upload.error.noFiles": "Es wurden keine Dateien hochgeladen", + "upload.error.partial": "Die Datei wurde nur teilweise hochgeladen", + "upload.error.tmpDir": "Der temporäre Ordner für den Dateiupload existiert leider nicht", + "upload.errors": "Fehler", + "upload.progress": "Hochladen …", + + "url": "Url", + "url.placeholder": "https://beispiel.de", + + "user": "Account", + "user.blueprint": "Du kannst zusätzliche Felder und Bereiche für diese Rolle in /site/blueprints/users/{blueprint}.yml anlegen", + "user.changeEmail": "E-Mail ändern", + "user.changeLanguage": "Sprache ändern", + "user.changeName": "Account umbenennen", + "user.changePassword": "Passwort ändern", + "user.changePassword.current": "Dein aktuelles Passwort", + "user.changePassword.new": "Neues Passwort", + "user.changePassword.new.confirm": "Wiederhole das Passwort …", + "user.changeRole": "Rolle ändern", + "user.changeRole.select": "Neue Rolle auswählen", + "user.create": "Neuen Account anlegen", + "user.delete": "Account löschen", + "user.delete.confirm": "Willst du den Account
{email} wirklich löschen?", + + "users": "Accounts", + + "version": "Version", + "version.changes": "Geänderte Version", + "version.compare": "Versionen vergleichen", + "version.current": "Aktuelle Version", + "version.latest": "Neueste Version", + "versionInformation": "Informationen zur Version", + + "view": "Ansicht", + "view.account": "Dein Account", + "view.installation": "Installation", + "view.languages": "Sprachen", + "view.resetPassword": "Passwort zurücksetzen", + "view.site": "Seite", + "view.system": "System", + "view.users": "Accounts", + + "welcome": "Willkommen", + "year": "Jahr", + "yes": "ja" +} diff --git a/public/kirby/i18n/translations/el.json b/public/kirby/i18n/translations/el.json new file mode 100644 index 0000000..9659dbc --- /dev/null +++ b/public/kirby/i18n/translations/el.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Change your name", + "account.delete": "Delete your account", + "account.delete.confirm": "Do you really want to delete your account? You will be logged out immediately. Your account cannot be recovered.", + + "activate": "Activate", + "add": "\u03a0\u03c1\u03bf\u03c3\u03b8\u03ae\u03ba\u03b7", + "alpha": "Alpha", + "author": "Author", + "avatar": "\u0395\u03b9\u03ba\u03cc\u03bd\u03b1 \u03c0\u03c1\u03bf\u03c6\u03af\u03bb", + "back": "Πίσω", + "cancel": "\u0391\u03ba\u03cd\u03c1\u03c9\u03c3\u03b7", + "change": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae", + "close": "\u039a\u03bb\u03b5\u03af\u03c3\u03b9\u03bc\u03bf", + "changes": "Changes", + "confirm": "Εντάξει", + "collapse": "Collapse", + "collapse.all": "Collapse All", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Αντιγραφή", + "copy.all": "Copy all", + "copy.success": "Copied", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Δημιουργία", + "custom": "Custom", + + "date": "Ημερομηνία", + "date.select": "Επιλογή ημερομηνίας", + + "day": "Ημέρα", + "days.fri": "\u03a0\u03b1\u03c1", + "days.mon": "\u0394\u03b5\u03c5", + "days.sat": "\u03a3\u03ac\u03b2", + "days.sun": "\u039a\u03c5\u03c1", + "days.thu": "\u03a0\u03ad\u03bc", + "days.tue": "\u03a4\u03c1\u03af", + "days.wed": "\u03a4\u03b5\u03c4", + + "debugging": "Debugging", + + "delete": "\u0394\u03b9\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae", + "delete.all": "Delete all", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No files to select", + "dialog.pages.empty": "No pages to select", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No users to select", + + "dimensions": "Διαστάσεις", + "disable": "Disable", + "disabled": "Disabled", + "discard": "Απόρριψη", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Λήψη", + "duplicate": "Αντίγραφο", + + "edit": "\u0395\u03c0\u03b5\u03be\u03b5\u03c1\u03b3\u03b1\u03c3\u03af\u03b1", + + "email": "Διεύθυνση ηλεκτρονικού ταχυδρομείου", + "email.placeholder": "mail@example.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Environment", + + "error": "Error", + "error.access.code": "Mη έγκυρος κωδικός", + "error.access.login": "Mη έγκυρη σύνδεση", + "error.access.panel": "Δεν επιτρέπεται η πρόσβαση στον πίνακα ελέγχου", + "error.access.view": "Δεν επιτρέπεται η πρόσβαση σε αυτό το τμήμα του πίνακα ελέγχου", + + "error.avatar.create.fail": "Δεν ήταν δυνατή η μεταφόρτωση της εικόνας προφίλ", + "error.avatar.delete.fail": "Δεν ήταν δυνατή η διαγραφή της εικόνας προφίλ", + "error.avatar.dimensions.invalid": "Διατηρήστε το πλάτος και το ύψος της εικόνας προφίλ κάτω από 3000 εικονοστοιχεία", + "error.avatar.mime.forbidden": "\u039c\u03b7 \u03b1\u03c0\u03bf\u03b4\u03b5\u03ba\u03c4\u03cc\u03c2 \u03c4\u03cd\u03c0\u03bf\u03c2 \u03b1\u03c1\u03c7\u03b5\u03af\u03bf\u03c5", + + "error.blueprint.notFound": "Δεν ήταν δυνατή η φόρτωση του προσχεδίου \"{name}\"", + + "error.blocks.max.plural": "You must not add more than {max} blocks", + "error.blocks.max.singular": "You must not add more than one block", + "error.blocks.min.plural": "You must add at least {min} blocks", + "error.blocks.min.singular": "You must add at least one block", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "Δεν είναι δυνατή η εύρεση της προεπιλογής διεύθινσης ηλεκτρονικού ταχυδρομείου \"{name}\"", + + "error.field.converter.invalid": "Μη έγκυρος μετατροπέας \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "The name must not be empty", + "error.file.changeName.permission": "Δεν επιτρέπεται να αλλάξετε το όνομα του \"{filename}\"", + "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": "Ένα αρχείο με το όνομα \"{filename}\" υπάρχει ήδη", + "error.file.extension.forbidden": "\u039c\u03b7 \u03b1\u03c0\u03bf\u03b4\u03b5\u03ba\u03c4\u03ae \u03b5\u03c0\u03ad\u03ba\u03c4\u03b1\u03c3\u03b7 \u03b1\u03c1\u03c7\u03b5\u03af\u03bf\u03c5", + "error.file.extension.invalid": "Invalid extension: {extension}", + "error.file.extension.missing": "Λείπει η επέκταση για το \"{filename}\"", + "error.file.maxheight": "The height of the image must not exceed {height} pixels", + "error.file.maxsize": "The file is too large", + "error.file.maxwidth": "The width of the image must not exceed {width} pixels", + "error.file.mime.differs": "Το αρχείο πρέπει να είναι του ίδιου τύπου mime \"{mime}\"", + "error.file.mime.forbidden": "Ο τύπος μέσου \"{mime}\" δεν επιτρέπεται", + "error.file.mime.invalid": "Invalid mime type: {mime}", + "error.file.mime.missing": "Δεν είναι δυνατό να εντοπιστεί ο τύπος μέσου για το \"{filename}\"", + "error.file.minheight": "The height of the image must be at least {height} pixels", + "error.file.minsize": "The file is too small", + "error.file.minwidth": "The width of the image must be at least {width} pixels", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Το όνομα αρχείου δεν μπορεί να είναι άδειο", + "error.file.notFound": "Δεν είναι δυνατό να βρεθεί το αρχείο \"{filename}\"", + "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": "Δεν επιτρέπεται η μεταφόρτωση αρχείων {type}", + "error.file.type.invalid": "Invalid file type: {type}", + "error.file.undefined": "Δεν ήταν δυνατή η εύρεση του αρχείου", + + "error.form.incomplete": "Παρακαλώ διορθώστε τα σφάλματα στη φόρμα...", + "error.form.notSaved": "Δεν ήταν δυνατή η αποθήκευση της φόρμας", + + "error.language.code": "Please enter a valid code for the language", + "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": "The language already exists", + "error.language.name": "Please enter a valid name for the language", + "error.language.notFound": "The language could not be found", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "There's an error in layout {index} settings", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "The license could not be verified", + + "error.login.totp.confirm.invalid": "Mη έγκυρος κωδικός", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "The Panel is currently offline", + + "error.page.changeSlug.permission": "Δεν επιτρέπεται να αλλάξετε το URL της σελίδας \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{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": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.", + "error.page.delete.permission": "Δεν επιτρέπεται η διαγραφή της σελίδας \"{slug}\"", + "error.page.draft.duplicate": "Υπάρχει ήδη ένα προσχέδιο σελίδας με την διεύθυνση URL \"{slug}\"", + "error.page.duplicate": "Υπάρχει ήδη μια σελίδα με την διεύθυνση URL \"{slug}\"", + "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"", + "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.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}\"", + "error.page.notFound": "Δεν ήταν δυνατή η εύρεση της σελίδας \"{slug}\"", + "error.page.num.invalid": "Παρακαλώ εισάγετε έναν έγκυρο αριθμό ταξινόμησης. Οι αριθμοί δεν μπορεί να είναι αρνητικοί.", + "error.page.slug.invalid": "Please enter a valid URL appendix", + "error.page.slug.maxlength": "Slug length must be less than \"{length}\" characters", + "error.page.sort.permission": "Δεν είναι δυνατή η ταξινόμηση της σελίδας \"{slug}\"", + "error.page.status.invalid": "Ορίστε μια έγκυρη κατάσταση σελίδας", + "error.page.undefined": "Δεν ήταν δυνατή η εύρεση της σελίδας", + "error.page.update.permission": "Δεν επιτρέπεται η ενημέρωση της σελίδας \"{slug}\"", + + "error.section.files.max.plural": "Δεν πρέπει να προσθέσετε περισσότερα από {max} αρχεία στην ενότητα \"{section}\"", + "error.section.files.max.singular": "Δεν πρέπει να προσθέσετε περισσότερα από ένα αρχεία στην ενότητα \"{section}\"", + "error.section.files.min.plural": "The \"{section}\" section requires at least {min} files", + "error.section.files.min.singular": "The \"{section}\" section requires at least one file", + + "error.section.pages.max.plural": "Δεν μπορείτε να προσθέσετε περισσότερες από {max} σελίδες στην ενότητα \"{section}\"", + "error.section.pages.max.singular": "Δεν μπορείτε να προσθέσετε περισσότερες από μία σελίδες στην ενότητα \"{section}\"", + "error.section.pages.min.plural": "The \"{section}\" section requires at least {min} pages", + "error.section.pages.min.singular": "The \"{section}\" section requires at least one page", + + "error.section.notLoaded": "Δεν ήταν δυνατή η φόρτωση της ενότητας \"{name}\"", + "error.section.type.invalid": "Ο τύπος ενότητας \"{type}\" δεν είναι έγκυρος", + + "error.site.changeTitle.empty": "Ο τίτλος δεν μπορεί να είναι κενός", + "error.site.changeTitle.permission": "Δεν επιτρέπεται να αλλάξετε τον τίτλο του ιστότοπου", + "error.site.update.permission": "Δεν επιτρέπεται η ενημέρωση του ιστότοπου", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Το προεπιλεγμένο πρότυπο δεν υπάρχει", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: 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": "You are not allowed to promote someone to the admin role", + "error.user.create.permission": "Δεν επιτρέπεται η δημιουργία αυτού του χρήστη", + "error.user.delete": "\u039f \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7\u03c2 \u03b4\u03b5\u03bd \u03bc\u03c0\u03bf\u03c1\u03bf\u03cd\u03c3\u03b5 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03b1\u03c6\u03b5\u03af", + "error.user.delete.lastAdmin": "Δεν είναι δυνατή η διαγραφή του τελευταίου διαχειριστή", + "error.user.delete.lastUser": "Δεν είναι δυνατή η διαγραφή του τελευταίου χρήστη", + "error.user.delete.permission": "Δεν επιτρέπεται να διαγράψετ τον χρήστη \"{name}\"", + "error.user.duplicate": "Ένας χρήστης με τη διεύθυνση ηλεκτρονικού ταχυδρομείου \"{email}\" υπάρχει ήδη", + "error.user.email.invalid": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου", + "error.user.language.invalid": "Παρακαλώ εισαγάγετε μια έγκυρη γλώσσα", + "error.user.notFound": "Δεν είναι δυνατή η εύρεση του χρήστη \"{name}\"", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Παρακαλώ εισάγετε έναν έγκυρο κωδικό πρόσβασης. Οι κωδικοί πρόσβασης πρέπει να έχουν μήκος τουλάχιστον 8 χαρακτήρων.", + "error.user.password.notSame": "\u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b5\u03c0\u03b9\u03b2\u03b5\u03b2\u03b1\u03b9\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf\u03bd \u039a\u03c9\u03b4\u03b9\u03ba\u03cc \u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "error.user.password.undefined": "Ο χρήστης δεν έχει κωδικό πρόσβασης", + "error.user.password.wrong": "Wrong password", + "error.user.role.invalid": "Παρακαλώ εισαγάγετε έναν έγκυρο ρόλο", + "error.user.undefined": "Δεν είναι δυνατή η εύρεση του χρήστη", + "error.user.update.permission": "Δεν επιτρέπεται η ενημέρωση του χρήστη \"{name}\"", + + "error.validation.accepted": "Παρακαλώ επιβεβαιώστε", + "error.validation.alpha": "Παρακαλώ εισάγετε μόνο χαρακτήρες μεταξύ των a-z", + "error.validation.alphanum": "Παρακαλώ εισάγετε μόνο χαρακτήρες μεταξύ των a-z ή αριθμούς απο το 0 έως το 9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Παρακαλώ εισάγετε μια τιμή μεταξύ \"{min}\" και \"{max}\"", + "error.validation.boolean": "Παρακαλώ επιβεβαιώστε ή αρνηθείτε", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Παρακαλώ καταχωρίστε μια τιμή που περιέχει \"{needle}\"", + "error.validation.date": "Παρακαλώ εισάγετε μία έγκυρη ημερομηνία", + "error.validation.date.after": "Please enter a date after {date}", + "error.validation.date.before": "Please enter a date before {date}", + "error.validation.date.between": "Please enter a date between {min} and {max}", + "error.validation.denied": "Παρακαλώ αρνηθείτε", + "error.validation.different": "Η τιμή δεν μπορεί να είναι \"{other}\"", + "error.validation.email": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου", + "error.validation.endswith": "Η τιμή πρέπει να τελειώνει με \"{end}\"", + "error.validation.filename": "Παρακαλώ εισάγετε ένα έγκυρο όνομα αρχείου", + "error.validation.in": "Παρακαλώ εισάγετε ένα από τα παρακάτω: ({in})", + "error.validation.integer": "Παρακαλώ εισάγετε έναν έγκυρο ακέραιο αριθμό", + "error.validation.ip": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση IP", + "error.validation.less": "Παρακαλώ εισάγετε μια τιμή μικρότερη από {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Η τιμή δεν ταιριάζει με το αναμενόμενο πρότυπο", + "error.validation.max": "Παρακαλώ εισάγετε μια τιμή ίση ή μικρότερη από {max}", + "error.validation.maxlength": "Παρακαλώ εισάγετε μια μικρότερη τιμή. (max. {max} χαρακτήρες)", + "error.validation.maxwords": "Παρακαλώ εισάγετε το πολύ {max} λέξεις", + "error.validation.min": "Παρακαλώ εισάγετε μια τιμή ίση ή μεγαλύτερη από {min}", + "error.validation.minlength": "Παρακαλώ εισάγετε μεγαλύτερη τιμή. (τουλάχιστον {min} χαρακτήρες)", + "error.validation.minwords": "Παρακαλώ εισάγετε τουλάχιστον {min} λέξεις", + "error.validation.more": "Παρακαλώ εισάγετε τουλάχιστον {min} λέξεις", + "error.validation.notcontains": "Παρακαλώ εισάγετε μια τιμή που δεν περιέχει \"{needle}\"", + "error.validation.notin": "Παρακαλώ μην εισάγετε κανένα από τα παρακάτω: ({notIn})", + "error.validation.option": "Παρακαλώ κάντε μια έγκυρη επιλογή", + "error.validation.num": "Παρακαλώ εισάγετε έναν έγκυρο αριθμό", + "error.validation.required": "Παρακαλώ εισάγετε κάτι", + "error.validation.same": "Παρακαλώ εισάγετε \"{other}\"", + "error.validation.size": "Το μέγεθος της τιμής πρέπει να είναι \"{size}\"", + "error.validation.startswith": "Η τιμή πρέπει να αρχίζει με \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Παρακαλώ εισάγετε μια έγκυρη ώρα", + "error.validation.time.after": "Please enter a time after {time}", + "error.validation.time.before": "Please enter a time before {time}", + "error.validation.time.between": "Please enter a time between {min} and {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση URL", + + "expand": "Expand", + "expand.all": "Expand All", + + "field.invalid": "The field is invalid", + "field.required": "The field is required", + "field.blocks.changeType": "Change type", + "field.blocks.code.name": "Κώδικας", + "field.blocks.code.language": "Γλώσσα", + "field.blocks.code.placeholder": "Your code …", + "field.blocks.delete.confirm": "Do you really want to delete this block?", + "field.blocks.delete.confirm.all": "Do you really want to delete all blocks?", + "field.blocks.delete.confirm.selected": "Do you really want to delete the selected blocks?", + "field.blocks.empty": "No blocks yet", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Please select a block type …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Gallery", + "field.blocks.gallery.images.empty": "No images yet", + "field.blocks.gallery.images.label": "Images", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Heading", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Heading …", + "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": "Alternative text", + "field.blocks.image.caption": "Caption", + "field.blocks.image.crop": "Crop", + "field.blocks.image.link": "Σύνδεσμος", + "field.blocks.image.location": "Location", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Εικόνα", + "field.blocks.image.placeholder": "Select an image", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Image URL", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "List", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Quote", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Quote …", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "by …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Caption", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Location", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Enter a video URL", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "Δεν υπάρχουν ακόμη καταχωρίσεις.", + + "field.files.empty": "Δεν έχουν επιλεγεί αρχεία ακόμα", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Delete layout", + "field.layout.delete.confirm": "Do you really want to delete this layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "No rows yet", + "field.layout.select": "Select a layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Δεν έχουν επιλεγεί ακόμη σελίδες", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "\u0395\u03af\u03c3\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03bf\u03c2 \u03cc\u03c4\u03b9 \u03b8\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03ae\u03bd \u03c4\u03b7\u03bd \u03ba\u03b1\u03c4\u03b1\u03c7\u03ce\u03c1\u03b9\u03c3\u03b7;", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "\u0394\u03b5\u03bd \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03bd \u03b1\u03ba\u03cc\u03bc\u03b7 \u03ba\u03b1\u03c4\u03b1\u03c7\u03c9\u03c1\u03af\u03c3\u03b5\u03b9\u03c2.", + + "field.users.empty": "Δεν έχουν επιλεγεί ακόμη χρήστες", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Αρχείο", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Αλλαγή προτύπου", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf;", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Change position", + + "files": "Αρχεία", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Δεν υπάρχουν ακόμα αρχεία", + + "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": "Ώρα", + "hue": "Hue", + "import": "Import", + "info": "Info", + "insert": "\u0395\u03b9\u03c3\u03b1\u03b3\u03c9\u03b3\u03ae", + "insert.after": "Insert after", + "insert.before": "Insert before", + "install": "Εγκατάσταση", + + "installation": "Εγκατάσταση", + "installation.completed": "Ο πίνακας ελέγχου έχει εγκατασταθεί", + "installation.disabled": "Η εγκατάσταση του πίνακα ελέγχου είναι απενεργοποιημένη για δημόσιους διακομιστές από προεπιλογή. Εκτελέστε την εγκατάσταση σε ένα τοπικό μηχάνημα ή ενεργοποιήστε την με την επιλογή panel.install.", + "installation.issues.accounts": "\u039f \u03c6\u03ac\u03ba\u03b5\u03bb\u03bf\u03c2 \/site\/accounts \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03b3\u03b3\u03c1\u03ac\u03c8\u03b9\u03bc\u03bf\u03c2", + "installation.issues.content": "\u039f \u03c6\u03ac\u03ba\u03b5\u03bb\u03bf\u03c2 content \u03ba\u03b1\u03b9 \u03cc\u03bb\u03bf\u03b9 \u03bf\u03b9 \u03c5\u03c0\u03bf\u03c6\u03ac\u03ba\u03b5\u03bb\u03bf\u03b9 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03b3\u03b3\u03c1\u03ac\u03c8\u03b9\u03bc\u03bf\u03b9.", + "installation.issues.curl": "Απαιτείται η επέκταση CURL", + "installation.issues.headline": "Ο πίνακας ελέγχου δεν μπορεί να εγκατασταθεί", + "installation.issues.mbstring": "Απαιτείται η επέκταση MB String ", + "installation.issues.media": "Ο φάκελος /media δεν υπάρχει ή δεν είναι εγγράψιμος", + "installation.issues.php": "Βεβαιωθείτε ότι χρησιμοποιήτε PHP 8+", + "installation.issues.sessions": "Ο φάκελος /site/sessions δεν υπάρχει ή δεν είναι εγγράψιμος", + + "language": "\u0393\u03bb\u03ce\u03c3\u03c3\u03b1", + "language.code": "Κώδικας", + "language.convert": "Χρήση ως προεπιλογή", + "language.convert.confirm": "

Θέλετε πραγματικά να μετατρέψετε τη {name} στην προεπιλεγμένη γλώσσα; Αυτό δεν μπορεί να ανακληθεί.

Αν το {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": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Ονομασία", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Η γλώσσα έχει ενημερωθεί", + "language.variables": "Language variables", + "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", + + "languages": "Γλώσσες", + "languages.default": "Προεπιλεγμένη γλώσσα", + "languages.empty": "Δεν υπάρχουν ακόμη γλώσσες", + "languages.secondary": "Δευτερεύουσες γλώσσες", + "languages.secondary.empty": "Δεν υπάρχουν ακόμα δευτερεύουσες γλώσσες", + + "license": "\u0386\u03b4\u03b5\u03b9\u03b1 \u03a7\u03c1\u03ae\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 Kirby", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Αγοράστε μια άδεια", + "license.code": "Κώδικας", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Παρακαλώ εισαγάγετε τον κωδικό άδειας χρήσης", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Σας ευχαριστούμε για την υποστήριξη του Kirby", + "license.unregistered.label": "Unregistered", + + "link": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03bc\u03bf\u03c2", + "link.text": "\u039a\u03b5\u03af\u03bc\u03b5\u03bd\u03bf \u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03bc\u03bf\u03c5", + + "loading": "Φόρτωση", + + "lock.unsaved": "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}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Σύνδεση", + "login.code.label.login": "Login code", + "login.code.label.password-reset": "Password reset code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "If your email address is registered, the requested code was sent via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Your login code", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Your password reset code", + "login.remember": "Κρατήστε με συνδεδεμένο", + "login.reset": "Reset password", + "login.toggleText.code.email": "Login via email", + "login.toggleText.code.email-password": "Login with password", + "login.toggleText.password-reset.email": "Forgot your password?", + "login.toggleText.password-reset.email-password": "← Back to login", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Αποσύνδεση", + + "merge": "Merge", + "menu": "Μενού", + "meridiem": "Π.Μ./Μ.Μ", + "mime": "Τύπος πολυμέσων", + "minutes": "Λεπτά", + + "month": "Μήνας", + "months.april": "\u0391\u03c0\u03c1\u03af\u03bb\u03b9\u03bf\u03c2", + "months.august": "\u0391\u03cd\u03b3\u03bf\u03c5\u03c3\u03c4\u03bf\u03c2", + "months.december": "\u0394\u03b5\u03ba\u03ad\u03bc\u03b2\u03c1\u03b9\u03bf\u03c2", + "months.february": "Φεβρουάριος", + "months.january": "\u0399\u03b1\u03bd\u03bf\u03c5\u03ac\u03c1\u03b9\u03bf\u03c2", + "months.july": "\u0399\u03bf\u03cd\u03bb\u03b9\u03bf\u03c2", + "months.june": "\u0399\u03bf\u03cd\u03bd\u03b9\u03bf\u03c2", + "months.march": "\u039c\u03ac\u03c1\u03c4\u03b9\u03bf\u03c2", + "months.may": "\u039c\u03ac\u03b9\u03bf\u03c2", + "months.november": "\u039d\u03bf\u03ad\u03bc\u03b2\u03c1\u03b9\u03bf\u03c2", + "months.october": "\u039f\u03ba\u03c4\u03ce\u03b2\u03c1\u03b9\u03bf\u03c2", + "months.september": "\u03a3\u03b5\u03c0\u03c4\u03ad\u03bc\u03b2\u03c1\u03b9\u03bf\u03c2", + + "more": "Περισσότερα", + "move": "Move", + "name": "Ονομασία", + "next": "Επόμενο", + "night": "Night", + "no": "no", + "off": "off", + "on": "on", + "open": "Άνοιγμα", + "open.newWindow": "Open in new window", + "option": "Option", + "options": "Eπιλογές", + "options.none": "No options", + "options.all": "Show all {count} options", + + "orientation": "Προσανατολισμός", + "orientation.landscape": "Οριζόντιος", + "orientation.portrait": "Κάθετος", + "orientation.square": "Τετράγωνος", + + "page": "Page", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae URL", + "page.changeSlug.fromTitle": "\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03c4\u03af\u03c4\u03bb\u03bf", + "page.changeStatus": "Αλλαγή κατάστασης", + "page.changeStatus.position": "Επιλέξτε μια θέση", + "page.changeStatus.select": "Επιλέξτε μια νέα κατάσταση", + "page.changeTemplate": "Αλλαγή προτύπου", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03ae\u03bd \u03c4\u03b7 \u03c3\u03b5\u03bb\u03af\u03b4\u03b1;", + "page.delete.confirm.subpages": "Αυτή η σελίδα έχει υποσελίδες.
Όλες οι υποσελίδες θα διαγραφούν επίσης.", + "page.delete.confirm.title": "Εισάγετε τον τίτλο της σελίδας για επιβεβαίωση", + "page.duplicate.appendix": "Αντιγραφή", + "page.duplicate.files": "Copy files", + "page.duplicate.pages": "Copy pages", + "page.move": "Move page", + "page.sort": "Change position", + "page.status": "Kατάσταση", + "page.status.draft": "Προσχέδιο", + "page.status.draft.description": "The page is in draft mode and only visible for logged in editors or via secret link", + "page.status.listed": "Δημοσιευμένο", + "page.status.listed.description": "Αυτή η σελίδα είναι δημοσιευμένη για οποιονδήποτε", + "page.status.unlisted": "Μη καταχωρημένο", + "page.status.unlisted.description": "Η σελίδα είναι προσβάσιμη μόνο μέσω της διεύθυνσης URL", + + "pages": "Σελίδες", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Δεν υπάρχουν ακόμα σελίδες", + "pages.status.draft": "Προσχέδια", + "pages.status.listed": "Δημοσιευμένο", + "pages.status.unlisted": "Μη καταχωρημένο", + + "pagination.page": "Σελίδα", + + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "Εικονοστοιχέιο", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Προηγούμενο", + "preview": "Preview", + + "publish": "Publish", + "published": "Δημοσιευμένο", + + "remove": "Αφαίρεση", + "rename": "Μετονομασία", + "renew": "Renew", + "replace": "\u0391\u03bd\u03c4\u03b9\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7", + "replace.with": "Replace with", + "retry": "\u0395\u03c0\u03b1\u03bd\u03ac\u03bb\u03b7\u03c8\u03b7", + "revert": "\u0391\u03b3\u03bd\u03cc\u03b7\u03c3\u03b7", + "revert.confirm": "Do you really want to delete all unsaved changes?", + + "role": "\u03a1\u03cc\u03bb\u03bf\u03c2", + "role.admin.description": "The admin has all rights", + "role.admin.title": "Admin", + "role.all": "Όλα", + "role.empty": "Δεν υπάρχουν χρήστες με αυτόν τον ρόλο", + "role.description.placeholder": "Χωρίς περιγραφή", + "role.nobody.description": "This is a fallback role without any permissions", + "role.nobody.title": "Nobody", + + "save": "\u0391\u03c0\u03bf\u03b8\u03ae\u03ba\u03b5\u03c5\u03c3\u03b7", + "saved": "Saved", + "search": "Αναζήτηση", + "searching": "Searching", + "search.min": "Enter {min} characters to search", + "search.all": "Show all {count} results", + "search.results.none": "No results", + + "section.invalid": "The section is invalid", + "section.required": "The section is required", + + "security": "Security", + "select": "Επιλογή", + "server": "Server", + "settings": "Ρυθμίσεις", + "show": "Show", + "site.blueprint": "The site has no blueprint yet. You can define the setup in /site/blueprints/site.yml", + "size": "Μέγεθος", + "slug": "\u0395\u03c0\u03af\u03b8\u03b5\u03bc\u03b1 URL", + "sort": "Ταξινόμηση", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Kατάσταση", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "\u03a0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Τίτλος", + "today": "Σήμερα", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Κώδικας", + "toolbar.button.bold": "\u0388\u03bd\u03c4\u03bf\u03bd\u03b7 \u03b3\u03c1\u03b1\u03c6\u03ae", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Επικεφαλίδες", + "toolbar.button.heading.1": "Επικεφαλίδα 1", + "toolbar.button.heading.2": "Επικεφαλίδα 2", + "toolbar.button.heading.3": "Επικεφαλίδα 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "\u03a0\u03bb\u03ac\u03b3\u03b9\u03b1 \u03b3\u03c1\u03b1\u03c6\u03ae", + "toolbar.button.file": "Αρχείο", + "toolbar.button.file.select": "Select a file", + "toolbar.button.file.upload": "Upload a file", + "toolbar.button.link": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03bc\u03bf\u03c2", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Strike-through", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Ταξινομημένη λίστα", + "toolbar.button.underline": "Underline", + "toolbar.button.ul": "Λίστα κουκκίδων", + + "translation.author": "Ομάδα Kirby", + "translation.direction": "ltr", + "translation.name": "\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac", + "translation.locale": "el_GR", + + "type": "Type", + + "upload": "Μεταφόρτωση", + "upload.error.cantMove": "The uploaded file could not be moved", + "upload.error.cantWrite": "Failed to write file to disk", + "upload.error.default": "The file could not be uploaded", + "upload.error.extension": "File upload stopped by extension", + "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form", + "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini", + "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini", + "upload.error.noFile": "No file was uploaded", + "upload.error.noFiles": "No files were uploaded", + "upload.error.partial": "The uploaded file was only partially uploaded", + "upload.error.tmpDir": "Missing a temporary folder", + "upload.errors": "Σφάλμα", + "upload.progress": "Μεταφόρτωση...", + + "url": "Διεύθινση url", + "url.placeholder": "https://example.com", + + "user": "Χρήστης", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Αλλαγή διεύθινσης ηλεκτρονικού ταχυδρομείου", + "user.changeLanguage": "Αλλαγή γλώσσας", + "user.changeName": "Μετονομασία χρήστη", + "user.changePassword": "Αλλαγή κωδικού πρόσβασης", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Νέος Κωδικός Πρόσβασης", + "user.changePassword.new.confirm": "Επαληθεύση κωδικού πρόσβασης", + "user.changeRole": "Αλλαγή ρόλου", + "user.changeRole.select": "Επιλογή νέου ρόλου", + "user.create": "Προσθήκη νέου χρήστη", + "user.delete": "Διαγραφή χρήστη", + "user.delete.confirm": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc\u03bd \u03c4\u03bf\u03bd \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7;", + + "users": "Χρήστες", + + "version": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 Kirby", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03c3\u03b1\u03c2", + "view.installation": "\u0395\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7", + "view.languages": "Γλώσσες", + "view.resetPassword": "Reset password", + "view.site": "Iστοσελίδα", + "view.system": "System", + "view.users": "\u03a7\u03c1\u03ae\u03c3\u03c4\u03b5\u03c2", + + "welcome": "Καλώς ήρθατε", + "year": "Έτος", + "yes": "yes" +} diff --git a/public/kirby/i18n/translations/en.json b/public/kirby/i18n/translations/en.json new file mode 100644 index 0000000..dc951a9 --- /dev/null +++ b/public/kirby/i18n/translations/en.json @@ -0,0 +1,803 @@ +{ + "account": "Account", + "account.changeName": "Change your name", + "account.delete": "Delete your account", + "account.delete.confirm": "Do you really want to delete your account? You will be logged out immediately. Your account cannot be recovered.", + + "activate": "Activate", + "add": "Add", + "alpha": "Alpha", + "author": "Author", + "avatar": "Profile picture", + "back": "Back", + "cancel": "Cancel", + "change": "Change", + "close": "Close", + "changes": "Changes", + "confirm": "Ok", + "collapse": "Collapse", + "collapse.all": "Collapse All", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Copy", + "copy.all": "Copy all", + "copy.success": "Copied", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Create", + "custom": "Custom", + + "date": "Date", + "date.select": "Select a date", + + "day": "Day", + "days.fri": "Fri", + "days.mon": "Mon", + "days.sat": "Sat", + "days.sun": "Sun", + "days.thu": "Thu", + "days.tue": "Tue", + "days.wed": "Wed", + + "debugging": "Debugging", + + "delete": "Delete", + "delete.all": "Delete all", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No files to select", + "dialog.pages.empty": "No pages to select", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No users to select", + + "dimensions": "Dimensions", + "disable": "Disable", + "disabled": "Disabled", + "discard": "Discard", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Download", + "duplicate": "Duplicate", + + "edit": "Edit", + + "email": "Email", + "email.placeholder": "mail@example.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Environment", + + "error": "Error", + "error.access.code": "Invalid code", + "error.access.login": "Invalid login", + "error.access.panel": "You are not allowed to access the panel", + "error.access.view": "You are not allowed to access this part of the panel", + + "error.avatar.create.fail": "The profile picture could not be uploaded", + "error.avatar.delete.fail": "The profile picture could not be deleted", + "error.avatar.dimensions.invalid": "Please keep the width and height of the profile picture under 3000 pixels", + "error.avatar.mime.forbidden": "The profile picture must be JPEG or PNG files", + + "error.blueprint.notFound": "The blueprint \"{name}\" could not be loaded", + + "error.blocks.max.plural": "You must not add more than {max} blocks", + "error.blocks.max.singular": "You must not add more than one block", + "error.blocks.min.plural": "You must add at least {min} blocks", + "error.blocks.min.singular": "You must add at least one block", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "The name must not be empty", + "error.file.changeName.permission": "You are not allowed to change the name of \"{filename}\"", + "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}", + "error.file.extension.missing": "The extensions for \"{filename}\" is missing", + "error.file.maxheight": "The height of the image must not exceed {height} pixels", + "error.file.maxsize": "The file is too large", + "error.file.maxwidth": "The width of the image must not exceed {width} pixels", + "error.file.mime.differs": "The uploaded file must be of the same mime type \"{mime}\"", + "error.file.mime.forbidden": "The media type \"{mime}\" is not allowed", + "error.file.mime.invalid": "Invalid mime type: {mime}", + "error.file.mime.missing": "The media type for \"{filename}\" cannot be detected", + "error.file.minheight": "The height of the image must be at least {height} pixels", + "error.file.minsize": "The file is too small", + "error.file.minwidth": "The width of the image must be at least {width} pixels", + "error.file.name.unique": "The filename must be unique", + "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", + + "error.form.incomplete": "Please fix all form errors…", + "error.form.notSaved": "The form could not be saved", + + "error.language.code": "Please enter a valid code for the language", + "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": "The language already exists", + "error.language.name": "Please enter a valid name for the language", + "error.language.notFound": "The language could not be found", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "There's an error in layout {index} settings", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Please enter a valid email address", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "The license could not be verified", + + "error.login.totp.confirm.invalid": "Invalid code", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "The Panel is currently offline", + + "error.page.changeSlug.permission": "You are not allowed to change the URL appendix for \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "The page has errors and cannot be published", + "error.page.changeStatus.permission": "The status for this page cannot be changed", + "error.page.changeStatus.toDraft.invalid": "The page \"{slug}\" cannot be converted to a draft", + "error.page.changeTemplate.invalid": "The template for the page \"{slug}\" cannot be changed", + "error.page.changeTemplate.permission": "You are not allowed to change the template for \"{slug}\"", + "error.page.changeTitle.empty": "The title must not be empty", + "error.page.changeTitle.permission": "You are not allowed to change the title for \"{slug}\"", + "error.page.create.permission": "You are not allowed to create \"{slug}\"", + "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", + "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"", + "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.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}\"", + "error.page.notFound": "The page \"{slug}\" cannot be found", + "error.page.num.invalid": "Please enter a valid sorting number. Numbers must not be negative.", + "error.page.slug.invalid": "Please enter a valid URL appendix", + "error.page.slug.maxlength": "Slug length must be less than \"{length}\" characters", + "error.page.sort.permission": "The page \"{slug}\" cannot be sorted", + "error.page.status.invalid": "Please set a valid page status", + "error.page.undefined": "The page cannot be found", + "error.page.update.permission": "You are not allowed to update \"{slug}\"", + + "error.section.files.max.plural": "You must not add more than {max} files to the \"{section}\" section", + "error.section.files.max.singular": "You must not add more than one file to the \"{section}\" section", + "error.section.files.min.plural": "The \"{section}\" section requires at least {min} files", + "error.section.files.min.singular": "The \"{section}\" section requires at least one file", + + "error.section.pages.max.plural": "You must not add more than {max} pages to the \"{section}\" section", + "error.section.pages.max.singular": "You must not add more than one page to the \"{section}\" section", + "error.section.pages.min.plural": "The \"{section}\" section requires at least {min} pages", + "error.section.pages.min.singular": "The \"{section}\" section requires at least one page", + + "error.section.notLoaded": "The section \"{name}\" could not be loaded", + "error.section.type.invalid": "The section type \"{type}\" is not valid", + + "error.site.changeTitle.empty": "The title must not be empty", + "error.site.changeTitle.permission": "You are not allowed to change the title of the site", + "error.site.update.permission": "You are not allowed to update the site", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "The default template does not exist", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "You are not allowed to change the email for the user \"{name}\"", + "error.user.changeLanguage.permission": "You are not allowed to change the language for the user \"{name}\"", + "error.user.changeName.permission": "You are not allowed to change the name for the user \"{name}\"", + "error.user.changePassword.permission": "You are not allowed to change the password for the user \"{name}\"", + "error.user.changeRole.lastAdmin": "The role for the last admin cannot be changed", + "error.user.changeRole.permission": "You are not allowed to change the role for the user \"{name}\"", + "error.user.changeRole.toAdmin": "You are not allowed to promote someone to the admin role", + "error.user.create.permission": "You are not allowed to create this user", + "error.user.delete": "The user \"{name}\" cannot be deleted", + "error.user.delete.lastAdmin": "The last admin cannot be deleted", + "error.user.delete.lastUser": "The last user cannot be deleted", + "error.user.delete.permission": "You are not allowed to delete the user \"{name}\"", + "error.user.duplicate": "A user with the email address \"{email}\" already exists", + "error.user.email.invalid": "Please enter a valid email address", + "error.user.language.invalid": "Please enter a valid language", + "error.user.notFound": "The user \"{name}\" cannot be found", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Please enter a valid password. Passwords must be at least 8 characters long.", + "error.user.password.notSame": "The passwords do not match", + "error.user.password.undefined": "The user does not have a password", + "error.user.password.wrong": "Wrong password", + "error.user.role.invalid": "Please enter a valid role", + "error.user.undefined": "The user cannot be found", + "error.user.update.permission": "You are not allowed to update the user \"{name}\"", + + "error.validation.accepted": "Please confirm", + "error.validation.alpha": "Please only enter characters between a-z", + "error.validation.alphanum": "Please only enter characters between a-z or numerals 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Please enter a value between \"{min}\" and \"{max}\"", + "error.validation.boolean": "Please confirm or deny", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Please enter a value that contains \"{needle}\"", + "error.validation.date": "Please enter a valid date", + "error.validation.date.after": "Please enter a date after {date}", + "error.validation.date.before": "Please enter a date before {date}", + "error.validation.date.between": "Please enter a date between {min} and {max}", + "error.validation.denied": "Please deny", + "error.validation.different": "The value must not be \"{other}\"", + "error.validation.email": "Please enter a valid email address", + "error.validation.endswith": "The value must end with \"{end}\"", + "error.validation.filename": "Please enter a valid filename", + "error.validation.in": "Please enter one of the following: ({in})", + "error.validation.integer": "Please enter a valid integer", + "error.validation.ip": "Please enter a valid IP address", + "error.validation.less": "Please enter a value lower than {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "The value does not match the expected pattern", + "error.validation.max": "Please enter a value equal to or lower than {max}", + "error.validation.maxlength": "Please enter a shorter value. (max. {max} characters)", + "error.validation.maxwords": "Please enter no more than {max} word(s)", + "error.validation.min": "Please enter a value equal to or greater than {min}", + "error.validation.minlength": "Please enter a longer value. (min. {min} characters)", + "error.validation.minwords": "Please enter at least {min} word(s)", + "error.validation.more": "Please enter a greater value than {min}", + "error.validation.notcontains": "Please enter a value that does not contain \"{needle}\"", + "error.validation.notin": "Please don't enter any of the following: ({notIn})", + "error.validation.option": "Please select a valid option", + "error.validation.num": "Please enter a valid number", + "error.validation.required": "Please enter something", + "error.validation.same": "Please enter \"{other}\"", + "error.validation.size": "The size of the value must be \"{size}\"", + "error.validation.startswith": "The value must start with \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Please enter a valid time", + "error.validation.time.after": "Please enter a time after {time}", + "error.validation.time.before": "Please enter a time before {time}", + "error.validation.time.between": "Please enter a time between {min} and {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Please enter a valid URL", + + "expand": "Expand", + "expand.all": "Expand All", + + "field.invalid": "The field is invalid", + "field.required": "The field is required", + "field.blocks.changeType": "Change type", + "field.blocks.code.name": "Code", + "field.blocks.code.language": "Language", + "field.blocks.code.placeholder": "Your code …", + "field.blocks.delete.confirm": "Do you really want to delete this block?", + "field.blocks.delete.confirm.all": "Do you really want to delete all blocks?", + "field.blocks.delete.confirm.selected": "Do you really want to delete the selected blocks?", + "field.blocks.empty": "No blocks yet", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Please select a block type …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Gallery", + "field.blocks.gallery.images.empty": "No images yet", + "field.blocks.gallery.images.label": "Images", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Heading", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Heading …", + "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": "Alternative text", + "field.blocks.image.caption": "Caption", + "field.blocks.image.crop": "Crop", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Location", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Image", + "field.blocks.image.placeholder": "Select an image", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Image URL", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "List", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Quote", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Quote …", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "by …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Caption", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Location", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Enter a video URL", + "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": "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", + + "field.layout.change": "Change layout", + "field.layout.delete": "Delete layout", + "field.layout.delete.confirm": "Do you really want to delete this layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "No rows yet", + "field.layout.select": "Select a layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "No pages selected yet", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Do you really want to delete this row?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.delete.confirm.selected": "Do you really want to delete the selected entries?", + "field.structure.empty": "No entries yet", + + "field.users.empty": "No users selected yet", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "File", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Change template", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Do you really want to delete
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "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", + "import": "Import", + "info": "Info", + "insert": "Insert", + "insert.after": "Insert after", + "insert.before": "Insert before", + "install": "Install", + + "installation": "Installation", + "installation.completed": "The panel has been installed", + "installation.disabled": "The panel installer is disabled on public servers by default. Please run the installer on a local machine or enable it with the panel.install option.", + "installation.issues.accounts": "The /site/accounts folder does not exist or is not writable", + "installation.issues.content": "The /content folder does not exist or is not writable", + "installation.issues.curl": "The CURL extension is required", + "installation.issues.headline": "The panel cannot be installed", + "installation.issues.mbstring": "The MB String extension is required", + "installation.issues.media": "The /media folder does not exist or is not writable", + "installation.issues.php": "Make sure to use PHP 8+", + "installation.issues.sessions": "The /site/sessions folder does not exist or is not writable", + + "language": "Language", + "language.code": "Code", + "language.convert": "Make default", + "language.convert.confirm": "

Do you really want to convert {name} to the default language? This cannot be undone.

If {name} has untranslated content, there will no longer be a valid fallback and parts of your site might be empty.

", + "language.create": "Add a new language", + "language.default": "Default language", + "language.delete.confirm": "Do you really want to delete the language {name} including all translations? This cannot be undone!", + "language.deleted": "The language has been deleted", + "language.direction": "Reading direction", + "language.direction.ltr": "Left to right", + "language.direction.rtl": "Right to left", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Name", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "The language has been updated", + "language.variables": "Language variables", + "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", + + "languages": "Languages", + "languages.default": "Default language", + "languages.empty": "There are no languages yet", + "languages.secondary": "Secondary languages", + "languages.secondary.empty": "There are no secondary languages yet", + + "license": "License", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Buy a license", + "license.code": "Code", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Please enter your license code", + "license.remove.text": "

Removing the license will irreversibly delete the license file from this site. You can then activate this site with a different license key or re-register the same license key if the domain remains the same.

To change the domain associated with the license, please contact the Kirby team. Read more →

", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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", + "license.unregistered.label": "Unregistered", + + "link": "Link", + "link.text": "Link text", + + "loading": "Loading", + + "lock.unsaved": "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}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Log in", + "login.code.label.login": "Login code", + "login.code.label.password-reset": "Password reset code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "If your email address is registered, the requested code was sent via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Your login code", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Your password reset code", + "login.remember": "Keep me logged in", + "login.reset": "Reset password", + "login.toggleText.code.email": "Login via email", + "login.toggleText.code.email-password": "Login with password", + "login.toggleText.password-reset.email": "Forgot your password?", + "login.toggleText.password-reset.email-password": "← Back to login", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Log out", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Media Type", + "minutes": "Minutes", + + "month": "Month", + "months.april": "April", + "months.august": "August", + "months.december": "December", + "months.february": "February", + "months.january": "January", + "months.july": "July", + "months.june": "June", + "months.march": "March", + "months.may": "May", + "months.november": "November", + "months.october": "October", + "months.september": "September", + + "more": "More", + "move": "Move", + "name": "Name", + "next": "Next", + "night": "Night", + "no": "no", + "off": "off", + "on": "on", + "open": "Open", + "open.newWindow": "Open in new window", + "option": "Option", + "options": "Options", + "options.none": "No options", + "options.all": "Show all {count} options", + + "orientation": "Orientation", + "orientation.landscape": "Landscape", + "orientation.portrait": "Portrait", + "orientation.square": "Square", + + "page": "Page", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Change URL", + "page.changeSlug.fromTitle": "Create from title", + "page.changeStatus": "Change status", + "page.changeStatus.position": "Please select a position", + "page.changeStatus.select": "Select a new status", + "page.changeTemplate": "Change template", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Do you really want to delete {title}?", + "page.delete.confirm.subpages": "This page has subpages.
All subpages will be deleted as well.", + "page.delete.confirm.title": "Enter the page title to confirm", + "page.duplicate.appendix": "Copy", + "page.duplicate.files": "Copy files", + "page.duplicate.pages": "Copy pages", + "page.move": "Move page", + "page.sort": "Change position", + "page.status": "Status", + "page.status.draft": "Draft", + "page.status.draft.description": "The page is in draft mode and only visible for logged in editors or via secret link", + "page.status.listed": "Public", + "page.status.listed.description": "The page is public for anyone", + "page.status.unlisted": "Unlisted", + "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", + "pages.status.unlisted": "Unlisted", + + "pagination.page": "Page", + + "password": "Password", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Previous", + "preview": "Preview", + + "publish": "Publish", + "published": "Published", + + "remove": "Remove", + "rename": "Rename", + "renew": "Renew", + "replace": "Replace", + "replace.with": "Replace with", + "retry": "Try again", + "revert": "Revert", + "revert.confirm": "Do you really want to delete all unsaved changes?", + + "role": "Role", + "role.admin.description": "The admin has all rights", + "role.admin.title": "Admin", + "role.all": "All", + "role.empty": "There are no users with this role", + "role.description.placeholder": "No description", + "role.nobody.description": "This is a fallback role without any permissions", + "role.nobody.title": "Nobody", + + "save": "Save", + "saved": "Saved", + "search": "Search", + "searching": "Searching", + "search.min": "Enter {min} characters to search", + "search.all": "Show all {count} results", + "search.results.none": "No results", + + "section.invalid": "The section is invalid", + "section.required": "The section is required", + + "security": "Security", + "select": "Select", + "server": "Server", + "settings": "Settings", + "show": "Show", + "site.blueprint": "The site has no blueprint yet. You can define the setup in /site/blueprints/site.yml", + "size": "Size", + "slug": "URL appendix", + "sort": "Sort", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "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", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Code", + "toolbar.button.bold": "Bold", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Headings", + "toolbar.button.heading.1": "Heading 1", + "toolbar.button.heading.2": "Heading 2", + "toolbar.button.heading.3": "Heading 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "Italic", + "toolbar.button.file": "File", + "toolbar.button.file.select": "Select a file", + "toolbar.button.file.upload": "Upload a file", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Strike-through", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Ordered list", + "toolbar.button.underline": "Underline", + "toolbar.button.ul": "Bullet list", + + "translation.author": "Kirby Team", + "translation.direction": "ltr", + "translation.name": "English", + "translation.locale": "en_US", + + "type": "Type", + + "upload": "Upload", + "upload.error.cantMove": "The uploaded file could not be moved", + "upload.error.cantWrite": "Failed to write file to disk", + "upload.error.default": "The file could not be uploaded", + "upload.error.extension": "File upload stopped by extension", + "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form", + "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini", + "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini", + "upload.error.noFile": "No file was uploaded", + "upload.error.noFiles": "No files were uploaded", + "upload.error.partial": "The uploaded file was only partially uploaded", + "upload.error.tmpDir": "Missing a temporary folder", + "upload.errors": "Error", + "upload.progress": "Uploading…", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "User", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Change email", + "user.changeLanguage": "Change language", + "user.changeName": "Rename this user", + "user.changePassword": "Change password", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "New password", + "user.changePassword.new.confirm": "Confirm the new password…", + "user.changePassword.own": "Your own password", + "user.changeRole": "Change role", + "user.changeRole.select": "Select a new role", + "user.create": "Add a new user", + "user.delete": "Delete this user", + "user.delete.confirm": "Do you really want to delete
{email}?", + + "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", + "view.resetPassword": "Reset password", + "view.site": "Site", + "view.system": "System", + "view.users": "Users", + + "welcome": "Welcome", + "year": "Year", + "yes": "yes" +} diff --git a/public/kirby/i18n/translations/eo.json b/public/kirby/i18n/translations/eo.json new file mode 100644 index 0000000..2262bcb --- /dev/null +++ b/public/kirby/i18n/translations/eo.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Ŝanĝi vian nomon", + "account.delete": "Forigi vian konton", + "account.delete.confirm": "Ĉu vi certe deziras forigi vian konton? Vi estos tuj elsalutita. Ne eblos malforigi vian konton.", + + "activate": "Activate", + "add": "Aldoni", + "alpha": "Alpha", + "author": "Aŭtoro", + "avatar": "Profilbildo", + "back": "Reen", + "cancel": "Nuligi", + "change": "Ŝanĝi", + "close": "Fermi", + "changes": "Changes", + "confirm": "Bone", + "collapse": "Fermi", + "collapse.all": "Fermi ĉiujn", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Kopii", + "copy.all": "Kopii ĉiujn", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Krei", + "custom": "Custom", + + "date": "Dato", + "date.select": "Elekti daton", + + "day": "Tago", + "days.fri": "Ven", + "days.mon": "Lun", + "days.sat": "Sab", + "days.sun": "Dim", + "days.thu": "Ĵaŭ", + "days.tue": "Mar", + "days.wed": "Mer", + + "debugging": "Sencimigado", + + "delete": "Forigi", + "delete.all": "Forigi ĉiujn", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "Neniu dosiero por elekti", + "dialog.pages.empty": "Neniu paĝo por elekti", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Neniu uzanto por elekti", + + "dimensions": "Dimensioj", + "disable": "Disable", + "disabled": "Malebligita", + "discard": "Forĵeti", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Elŝuti", + "duplicate": "Duobligi", + + "edit": "Modifi", + + "email": "Retpoŝto", + "email.placeholder": "retpoŝto@ekzemplo.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Medio", + + "error": "Eraro", + "error.access.code": "Nevalida kodo", + "error.access.login": "Nevalida ensaluto", + "error.access.panel": "Vi ne rajtas eniri la administran panelon", + "error.access.view": "Vi ne rajtas eniri ĉi tiun areon de la panelo", + + "error.avatar.create.fail": "La profilbildo ne povis esti alŝutita", + "error.avatar.delete.fail": "La profilbildo ne povis esti forigita", + "error.avatar.dimensions.invalid": "Bonvolu certigi ke la profilbildo ne estas pli ol 3000 bilderojn larĝa kaj alta", + "error.avatar.mime.forbidden": "La profilbildo devas esti dosiero en dosierformo aŭ JPEG aŭ PNG", + + "error.blueprint.notFound": "La plano \"{name}\" ne povis esti ŝargita", + + "error.blocks.max.plural": "Oni devas ne aldoni pli ol {max} blokoj", + "error.blocks.max.singular": "Vi devas ne aldoni pli ol unu bloko", + "error.blocks.min.plural": "Oni devas aldoni almenaŭ {min} blokojn", + "error.blocks.min.singular": "Oni devas aldoni almenaŭ unu blokon", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "La retpoŝta antaŭagordo \"{name}\" ne estas trovebla", + + "error.field.converter.invalid": "Nevalida konvertilo \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "La nomo ne rajtas esti malplena", + "error.file.changeName.permission": "Vi ne rajtas ŝanĝi la nomon de \"{filename}\"", + "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": "Jam ekzistas dosiero nomita \"{filename}\"", + "error.file.extension.forbidden": "La dosiersufikso \"{extension}\" ne estas permesita", + "error.file.extension.invalid": "Nevalida dosiersufikso: {extension}", + "error.file.extension.missing": "Mankas la dosiersufiksoj por \"{filename}\"", + "error.file.maxheight": "La bildo ne povas esti pli ol {height} bilderojn alta ", + "error.file.maxsize": "La dosiero estas tro granda", + "error.file.maxwidth": "La bildo ne povas esti pli oll {width} bilderojn larĝa", + "error.file.mime.differs": "La alŝutata dosiero devas havi la saman MIME-tipon \"{mime}\"", + "error.file.mime.forbidden": "La MIME-tipo \"{mime}\" ne povas esti uzata ĉi tie", + "error.file.mime.invalid": "Nevalida MIME-tipo: {mime}", + "error.file.mime.missing": "La MIME-tipo for \"{filename}\" ne estas detektebla", + "error.file.minheight": "La bildo devas esti almenaŭ {height} bilderojn alta", + "error.file.minsize": "La dosiero estas tro malgranda", + "error.file.minwidth": "La bildo devas esti almenaŭ {width} bilderojn larĝa", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "La dosiernomo ne rajtas esti malplena", + "error.file.notFound": "La dosiero \"{filename}\" ne troveblas", + "error.file.orientation": "La orientiĝo de la bildo devas esti \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Vi ne rajtas alŝuti dosiertipon {type}", + "error.file.type.invalid": "Nevalida dosiertipo: {type}", + "error.file.undefined": "La dosiero ne troveblas", + + "error.form.incomplete": "Bonvolu korekti ĉiujn erarojn en formularo...", + "error.form.notSaved": "Ne eblis konservi la formularon", + + "error.language.code": "Bonvolu entajpi validan kodon por la lingvo", + "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": "La lingvo jam ekzistas", + "error.language.name": "Bonvolu entajpi validan nomon por la lingvo", + "error.language.notFound": "La lingvo ne troveblas", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "Estas eraro en la agordoj de blokaranĝo {index}", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Bonvolu entajpi validan retpoŝtadreson", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "Ne eblis kontroli la permisilon", + + "error.login.totp.confirm.invalid": "Nevalida kodo", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "La panelo estas ĉi-momente nekonektita", + + "error.page.changeSlug.permission": "Vi ne rajtas ŝanĝi la URL-nomon de \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "La paĝo havas erarojn, kaj tiel ne povas esti publikigita", + "error.page.changeStatus.permission": "La paĝstato ne estas ŝanĝebla", + "error.page.changeStatus.toDraft.invalid": "Ne eblas konverti la paĝon \"{slug}\" al malneto", + "error.page.changeTemplate.invalid": "Ne eblas ŝanĝi la ŝablonon de la paĝo \"{slug}\"", + "error.page.changeTemplate.permission": "Vi ne rajtas ŝanĝi la ŝablonon de \"{slug}\"", + "error.page.changeTitle.empty": "La titolo ne rajtas esti malplena", + "error.page.changeTitle.permission": "Vi ne rajtas ŝanĝi la titolon de \"{slug}\"", + "error.page.create.permission": "Vi ne rajtas krei \"{slug}\"", + "error.page.delete": "Ne eblas forigi la paĝon \"{slug}\"", + "error.page.delete.confirm": "Bonvolu entajpi la titolon de la paĝo for konfirmi", + "error.page.delete.hasChildren": "Ne eblas forigi la paĝon ĉar ĝi havas subpaĝojn", + "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": "Vi ne rajtas forigi \"{slug}\"", + "error.page.draft.duplicate": "Malneto uzanta la URL-nomon \"{slug}\" jam ekzistas", + "error.page.duplicate": "Paĝo uzanta la URL-nomon \"{slug}\" jam ekzistas", + "error.page.duplicate.permission": "Vi ne rajtas duobligi \"{slug}\"", + "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.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}\"", + "error.page.notFound": "La paĝo \"{slug}\" ne troveblas", + "error.page.num.invalid": "Bonvolu entajpi validan ord-numeron. Numeroj devas esti pozitivaj.", + "error.page.slug.invalid": "Bonvolu entajpi validan URL-nomon", + "error.page.slug.maxlength": "URL-nomo devas esti malpli ol \"{length}\" literojn longa", + "error.page.sort.permission": "Ne eblas ordigi la paĝon \"{slug}\" ", + "error.page.status.invalid": "Bonvolu elekti validan paĝstaton", + "error.page.undefined": "La paĝo ne estas trovebla", + "error.page.update.permission": "Vi ne rajtas ĝisdatigi \"{slug}\"", + + "error.section.files.max.plural": "Vi devas aldoni maksimume {max} dosierojn al sekcio \"{section}\"", + "error.section.files.max.singular": "Vi devas aldoni maksimume unu dosieron al sekcio \"{section}\"", + "error.section.files.min.plural": "La sekcio \"{section}\" bezonas almenaŭ {min} dosierojn", + "error.section.files.min.singular": "La sekcio \"{section}\" bezonas almenaŭ unu dosieron", + + "error.section.pages.max.plural": "Vi devas aldoni maksimume {max} paĝojn al sekcio \"{section}\"", + "error.section.pages.max.singular": "Vi devas aldoni maksimume unu paĝon al sekcio \"{section}\"", + "error.section.pages.min.plural": "La sekcio \"{section}\" bezonas almenaŭ {min} paĝojn", + "error.section.pages.min.singular": "La sekcio \"{section}\" bezonas almenaŭ unu paĝon", + + "error.section.notLoaded": "Ne eblis ŝarĝi la sekcion \"{section}\"", + "error.section.type.invalid": "La sekcia tipo \"{type}\" ne estas valida", + + "error.site.changeTitle.empty": "La titolo ne rajtas esti malplena", + "error.site.changeTitle.permission": "Vi ne rajtas ŝanĝi la titolon de la retejo", + "error.site.update.permission": "Vi ne rajtas ĝisdatigi la retejon", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "La defaŭlta ŝablono ne ekzistas", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Vi ne rajtas ŝanĝi la retpoŝtadreson de la uzanto \"{name}\"", + "error.user.changeLanguage.permission": "Vi ne rajtas ŝanĝi la lingvon de la uzanto \"{name}\"", + "error.user.changeName.permission": "Vi ne rajtas ŝanĝi la nomon de la uzanto \"{name}\"", + "error.user.changePassword.permission": "Vi ne rajtas ŝanĝi la pasvorton de la uzanto \"{name}\"", + "error.user.changeRole.lastAdmin": "Ne eblas ŝanĝi la rolon de la lasta administranto", + "error.user.changeRole.permission": "Vi ne rajtas ŝanĝi la rolon de la uzanto \"{name}\"", + "error.user.changeRole.toAdmin": "Vi ne rajtas promocii uzanton al rolo 'administranto'", + "error.user.create.permission": "Vi ne rajtas krei ĉi-tiun uzanton", + "error.user.delete": "Ne eblas forigi uzanton \"{name}\"", + "error.user.delete.lastAdmin": "Ne eblas forigi la lastan administranton", + "error.user.delete.lastUser": "Ne eblas forigi la lastan uzanton", + "error.user.delete.permission": "Vi ne rajtas forigi la uzanton \"{name}\"", + "error.user.duplicate": "Jam ekzistas uzanto kies retpoŝtadreso estas \"{email}\"", + "error.user.email.invalid": "Bonvolu entajpi validan retpoŝtadreson", + "error.user.language.invalid": "Bonvolu entajpi validan lingvon", + "error.user.notFound": "La uzanto \"{name}\" ne troveblas", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Bonvolu entajpi validan pasvorton. Pasvortoj devas esti almenaŭ 8 literojn longaj.", + "error.user.password.notSame": "La pasvortoj ne estas kongruantaj", + "error.user.password.undefined": "La uzanto ne havas pasvorton", + "error.user.password.wrong": "Malĝusta pasvorto", + "error.user.role.invalid": "Bonvolu entajpi validan rolon", + "error.user.undefined": "La uzanto ne troveblas", + "error.user.update.permission": "Vi ne rajtas ĝisdatigi la uzanton \"{name}\"", + + "error.validation.accepted": "Bonvolu konfirmi", + "error.validation.alpha": "Bonvolu entajpi nur literojn inter a-z", + "error.validation.alphanum": "Bonvolu entajpi nur aŭ literojn inter a-z aũ numerojn inter 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Bonvolu entajpi valoron inter \"{min}\" kaj \"{max}\"", + "error.validation.boolean": "Bonvolu konfirmi aŭ malkonfirmi", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Bonvolu entajpi valoron kiu enhavas \"{needle}\"", + "error.validation.date": "Bonvolu entajpi validan daton", + "error.validation.date.after": "Bonvolu entajpi daton post {date}", + "error.validation.date.before": "Bonvolu entajpi daton antaũ {date}", + "error.validation.date.between": "Bonvolu entajpi daton inter {min} kaj {max}", + "error.validation.denied": "Bonvolu malkonfirmi", + "error.validation.different": "La valoro ne rajtas esti \"{other}\"", + "error.validation.email": "Bonvolu entajpi validan retpoŝtadreson", + "error.validation.endswith": "La valoro devas finiĝi per \"{end}\"", + "error.validation.filename": "Bonvolu entajpi validan dosiernomon", + "error.validation.in": "Bonvolu entajpi unu el la sekvaj: ({in})", + "error.validation.integer": "Bonvolu entajpi validan entjeron", + "error.validation.ip": "Bonvolu entajpi validan IP-adreson", + "error.validation.less": "Bonvolu entajpi valoron malpli ol {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "La valoro ne kongruas al la atendata ŝablono", + "error.validation.max": "Bonvolu entajpi valoron egalan al aũ malpli ol {max}", + "error.validation.maxlength": "Bonvolu entajpi pli mallongan valoron (maksimume {max} literojn)", + "error.validation.maxwords": "Bonvolu entajpi maksimume {max} vorto(j)n", + "error.validation.min": "Bonvolu entajpi valoron egalan al aŭ pli granda ol {min}", + "error.validation.minlength": "Bonvolu entajpi pli longan valoron (minimume {min} literojn)", + "error.validation.minwords": "Bonvolu entajpi almenaŭ {min} vorto(j)n", + "error.validation.more": "Bonvolu entajpi valoron pli grandan ol {min}", + "error.validation.notcontains": "Bonvolu entajpi valoron kiu ne enhavas \"{needle}\"", + "error.validation.notin": "Bonvolu entajpi neniu ajn el la sekvaj: ({notin})", + "error.validation.option": "Bonvolu fari validan elekton", + "error.validation.num": "Bonvolu entajpi validan numeron", + "error.validation.required": "Bonvolu entajpi ion", + "error.validation.same": "Bonvolu entajpi \"{other}\"", + "error.validation.size": "La grando de la valoro devas esti \"{size}\"", + "error.validation.startswith": "La valoro devas komenciĝi per \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Bonvolu entajpi validan horaron", + "error.validation.time.after": "Bonvolu entajpi horaron post {time}", + "error.validation.time.before": "Bonvolu entajpi horaron antaŭ {time}", + "error.validation.time.between": "Bonvolu entajpi horaron inter {min} kaj {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Bonvolu entajpi validan URL", + + "expand": "Etendi", + "expand.all": "Etendi ĉiujn", + + "field.invalid": "The field is invalid", + "field.required": "La kampo ne rajtas esti malplena", + "field.blocks.changeType": "Ŝanĝi tipon", + "field.blocks.code.name": "Kodo", + "field.blocks.code.language": "Lingvo", + "field.blocks.code.placeholder": "Via kodo ...", + "field.blocks.delete.confirm": "Ĉu vi certe volas forigi ĉi tiun blokon?", + "field.blocks.delete.confirm.all": "Ĉu vi certe volas forigi ĉiujn blokojn?", + "field.blocks.delete.confirm.selected": "Ĉu vi certe volas forigi la elektitajn blokojn?", + "field.blocks.empty": "Ankoraŭ neniu bloko", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Bonvolu elekti tipon de bloko ...", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galerio", + "field.blocks.gallery.images.empty": "Ankoraŭ neniu bildo", + "field.blocks.gallery.images.label": "Bildoj", + "field.blocks.heading.level": "Nivelo", + "field.blocks.heading.name": "Titolo", + "field.blocks.heading.text": "Teksto", + "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.image.alt": "Alternativa titolo", + "field.blocks.image.caption": "Apudskribo", + "field.blocks.image.crop": "Stuci", + "field.blocks.image.link": "Ligilo", + "field.blocks.image.location": "Loko", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Bildo", + "field.blocks.image.placeholder": "Elekti bildon", + "field.blocks.image.ratio": "Proporcio", + "field.blocks.image.url": "URL de la bildo", + "field.blocks.line.name": "Linio", + "field.blocks.list.name": "Listo", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Teksto", + "field.blocks.markdown.placeholder": "Markdown ...", + "field.blocks.quote.name": "Citaĵo", + "field.blocks.quote.text.label": "Teksto", + "field.blocks.quote.text.placeholder": "Citaĵo ...", + "field.blocks.quote.citation.label": "Citaĵo", + "field.blocks.quote.citation.placeholder": "de ...", + "field.blocks.text.name": "Teksto", + "field.blocks.text.placeholder": "Teksto ...", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Apudskribo", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Loko", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Videâjo", + "field.blocks.video.placeholder": "Entajpi URL de videaĵo", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "Ankoraŭ neniu enigo", + + "field.files.empty": "Ankoraŭ neniu dosiero elektita", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Forigi blokaranĝo", + "field.layout.delete.confirm": "Ĉu vi certe volas forigi ĉi tiun blokaranĝon?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Ankoraŭ neniu vico", + "field.layout.select": "Elekti blokaranĝon", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Ankoraŭ neniu paĝo elektita", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Ĉu vi certe volas forigi ĉi tiun vicon?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Ankoraŭ neniu enigo", + + "field.users.empty": "Ankoraŭ neniu uzanto elektita", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Dosiero", + "file.blueprint": "Ĉi tiu dosiero ankoraŭ havas neniun planon. Vi povas difini planon ĉe /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Ŝanĝi ŝablonon", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Ĉu vi certe vollas forigi
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Ŝanĝi ordon", + + "files": "Dosieroj", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Ankoraŭ neniu dosiero", + + "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": "Kaŝi", + "hour": "Horo", + "hue": "Hue", + "import": "Importi", + "info": "Info", + "insert": "Enmeti", + "insert.after": "Enmeti post", + "insert.before": "Enmeti antaŭ", + "install": "Instali", + + "installation": "Instalado", + "installation.completed": "La panelo estas instalita", + "installation.disabled": "La instalilo de la panelo estas norme malebligita en publikaj serviloj. Bonvolu uzi la instalilon en via loka komputilo, aŭ ebligu ĝin per la opcio panel.install", + "installation.issues.accounts": "La dosierujo /site/accounts ne ekzistas, aŭ ne estas skribebla", + "installation.issues.content": "La dosierujo /content ne ekzistas, aŭ ne estas skribebla", + "installation.issues.curl": "La kromprogramo CURL estas deviga", + "installation.issues.headline": "Ne eblas instali la panelon", + "installation.issues.mbstring": "La kromprogramo MB String estas deviga", + "installation.issues.media": "La dosierujo /media ne ekzistas, aũ ne estas skribebla", + "installation.issues.php": "Nepre uzu PHP 8+", + "installation.issues.sessions": "La dosierujo /site/sessions ne ekzistas, aŭ ne estas skribebla", + + "language": "Lingvo", + "language.code": "Kodo", + "language.convert": "Farigi defaŭlton", + "language.convert.confirm": "

Ĉu vi certe volas konverti {name} al la defaŭlta lingvo? Ĉi tion vi ne povos malfari.

Se {name} havas netradukitan enhavon, tiuj tekstoj nun ne havos defaŭlton, kaj simple ne aperos en via retejo.

", + "language.create": "Aldoni novan lingvon", + "language.default": "Defaŭlta lingvo", + "language.delete.confirm": "Ĉu vi certe volas forigi la lingvon {name}, inkluzive de ĉiuj tradukoj? Vi ne povos malfari tion!", + "language.deleted": "La lingvo estas forigita", + "language.direction": "Direkto de leĝado", + "language.direction.ltr": "Dekstren", + "language.direction.rtl": "Maldesktren", + "language.locale": "Lokaĵaro de PHP", + "language.locale.warning": "Vi uzas tajloritan agordon de lokaĵaro. Bonvolu ŝanĝi viajn agordojn laŭmende en la lingva dosiero ĉe /site/languages", + "language.name": "Nomo", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "La lingvo estas ĝisdatigita", + "language.variables": "Language variables", + "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", + + "languages": "Lingvoj", + "languages.default": "Defaŭlta lingvo", + "languages.empty": "Ankoraũ estas neniu lingvo", + "languages.secondary": "Kromlingvoj", + "languages.secondary.empty": "Ankoraŭ estas neniu kromlingvoj", + + "license": "Permisilo", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Aĉeti permisilon", + "license.code": "Kodo", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Bonvolu entajpi vian kodon de permisilo", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Dankon pro subteni Kirby", + "license.unregistered.label": "Unregistered", + + "link": "Ligilo", + "link.text": "Ligila teksto", + + "loading": "Ŝargante", + + "lock.unsaved": "Nekonservitaj ŝanĝoj", + "lock.unsaved.empty": "Ĉiuj ŝanĝoj estas nun konservitaj", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Malŝlosi", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Log in", + "login.code.label.login": "Ensaluta kodo", + "login.code.label.password-reset": "Kodo por restarigi pasvorton", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Se via retpoŝtadreso estas enregistrita, via kodo estis sendita retpoŝte", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Saluton {user.nameOrEmail},\n\nVi petis ensalutan kodon por la panelo de la retejo {site}.\nLa sekvanta kodo validos dum {timeout} minutoj:\n\n{code}\n\nSe vi ne petis ensalutan kodon, bonvolu ignori ĉi tiun mesaĝon, aŭ kontaktu vian sistem-administranton se vi havas demandojn.\nPro sekureco, bonvolu NE plusendi ĉi tiun mesaĝon.", + "login.email.login.subject": "Via ensaluta kodo", + "login.email.password-reset.body": "Saluton {user.nameOrEmail},\n\nVi petis kodon por restarigi vian pasvorton por la panelo de la retejo {site}.\nLa sekvanta kodo validos dum {timeout} minutoj:\n\n{code}\n\nSe vi ne petis kodon por restarigi vian pasvorton, bonvolu ignori ĉi tiun mesaĝon, aŭ kontaktu vian sistem-administranton se vi havas demandojn.\nPro sekureco, bonvolu NE plusendi ĉi tiun mesaĝon.", + "login.email.password-reset.subject": "Kodo por restarigi pasvorton", + "login.remember": "Daŭre tenu min ensalutita", + "login.reset": "Restarigi pasvorton", + "login.toggleText.code.email": "Ensaluti retpoŝte", + "login.toggleText.code.email-password": "Ensaluti per pasvorto", + "login.toggleText.password-reset.email": "Ĉu vi forgesis vian pasvorton?", + "login.toggleText.password-reset.email-password": "← Reen al ensaluto", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Elsaluti", + + "merge": "Merge", + "menu": "Menuo", + "meridiem": "atm/ptm", + "mime": "Tipo de aŭdvidaĵo", + "minutes": "Minutoj", + + "month": "Monato", + "months.april": "aprilo", + "months.august": "aŭgusto", + "months.december": "decembro", + "months.february": "februaro", + "months.january": "januaro", + "months.july": "julio", + "months.june": "junio", + "months.march": "marto", + "months.may": "majo", + "months.november": "novembro", + "months.october": "oktobro", + "months.september": "septembro", + + "more": "Pli", + "move": "Move", + "name": "Nomo", + "next": "Sekve", + "night": "Night", + "no": "ne", + "off": "ne", + "on": "jes", + "open": "Malfermi", + "open.newWindow": "Malfermi novan fenestron", + "option": "Option", + "options": "Opcioj", + "options.none": "Neniu opcio", + "options.all": "Show all {count} options", + + "orientation": "Orientiĝo", + "orientation.landscape": "Horizontala", + "orientation.portrait": "Vertikala", + "orientation.square": "Kvadrata", + + "page": "Paĝo", + "page.blueprint": "Ĉi tiu paĝo ankoraŭ ne havas planon. Vi povas difini planon ĉe /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Ŝanĝi URL", + "page.changeSlug.fromTitle": "Krei el titolo", + "page.changeStatus": "Ŝanĝi staton", + "page.changeStatus.position": "Bonvolu elekti ordon", + "page.changeStatus.select": "Elekti novan staton", + "page.changeTemplate": "Ŝanĝi ŝablonon", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Ĉu vi certe volas forigi {title}?", + "page.delete.confirm.subpages": "Ĉi tiu paĝo havas subpaĝojn.
Ĉiuj subpaĝoj estos ankaŭ forigitaj.", + "page.delete.confirm.title": "Entajpu la titolon de la paĝo por konfirmi", + "page.duplicate.appendix": "Kopii", + "page.duplicate.files": "Kopii dosierojn", + "page.duplicate.pages": "Kopii paĝojn", + "page.move": "Move page", + "page.sort": "Ŝanĝi ordon", + "page.status": "Stato", + "page.status.draft": "Malneto", + "page.status.draft.description": "La paĝo estas malneto, kaj nur atingebla de ensalutitaj redaktantoj, aŭ per sekreta ligilo", + "page.status.listed": "Publika", + "page.status.listed.description": "La paĝo estas publika por ĉiuj ajn", + "page.status.unlisted": "Nelistata", + "page.status.unlisted.description": "La paĝo estas atingebla nur per URL", + + "pages": "Paĝoj", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Ankoraŭ neniu paĝo", + "pages.status.draft": "Malnetoj", + "pages.status.listed": "Publikigita", + "pages.status.unlisted": "Nelistata", + + "pagination.page": "Paĝo", + + "password": "Pasvorto", + "paste": "Alglui", + "paste.after": "Alglui post", + "paste.success": "{count} pasted!", + "pixel": "Pikselo", + "plugin": "Plugin", + "plugins": "Kromprogramoj", + "prev": "Antaŭe", + "preview": "Antaŭrigardi", + + "publish": "Publish", + "published": "Publikigita", + + "remove": "Forigi", + "rename": "Ŝanĝi nomon", + "renew": "Renew", + "replace": "Anstataŭi", + "replace.with": "Replace with", + "retry": "Provi denove", + "revert": "Malfari", + "revert.confirm": "Ĉu vi certe volas forigi ĉiujn nekonservitajn ŝanĝojn?", + + "role": "Rolo", + "role.admin.description": "La administranto havas ĉiujn rajtojn", + "role.admin.title": "Administranto", + "role.all": "Ĉiuj", + "role.empty": "Neniu uzanto havas ĉi tiun rolon", + "role.description.placeholder": "Neniu priskribo", + "role.nobody.description": "Ĉi tiu estas retrodefaŭlta rolo sen permesoj", + "role.nobody.title": "Neniu", + + "save": "Konservi", + "saved": "Saved", + "search": "Serĉi", + "searching": "Searching", + "search.min": "Entajpu {min} literojn por serĉi", + "search.all": "Show all {count} results", + "search.results.none": "Neniu rezulto", + + "section.invalid": "The section is invalid", + "section.required": "La sekcio estas deviga", + + "security": "Security", + "select": "Elekti", + "server": "Servilo", + "settings": "Agordoj", + "show": "Montri", + "site.blueprint": "La retejo ankoraŭ ne havas planon. Vi povas difini planon ĉe /site/blueprints/site.yml", + "size": "Grando", + "slug": "URL-nomo", + "sort": "Ordigi", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Stato", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Ŝablono", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Titolo", + "today": "Hodiaŭ", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Kodo", + "toolbar.button.bold": "Grasa", + "toolbar.button.email": "Retpoŝto", + "toolbar.button.headings": "Titoloj", + "toolbar.button.heading.1": "Titolo 1", + "toolbar.button.heading.2": "Titolo 2", + "toolbar.button.heading.3": "Titolo 3", + "toolbar.button.heading.4": "Titolo 4", + "toolbar.button.heading.5": "Titolo 5", + "toolbar.button.heading.6": "Titolo 6", + "toolbar.button.italic": "Kursiva", + "toolbar.button.file": "Dosiero", + "toolbar.button.file.select": "Elekti dosieron", + "toolbar.button.file.upload": "Alŝuti dosieron", + "toolbar.button.link": "Ligilo", + "toolbar.button.paragraph": "Paragrafo", + "toolbar.button.strike": "Trastrekita", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Numerita listo", + "toolbar.button.underline": "Substrekita", + "toolbar.button.ul": "Bula listo", + + "translation.author": "Teamo Kirby", + "translation.direction": "ltr", + "translation.name": "Esperanto", + "translation.locale": "eo", + + "type": "Type", + + "upload": "Alŝuti", + "upload.error.cantMove": "Ne eblis movi la alŝutita dosiero", + "upload.error.cantWrite": "Ne eblis registri la dosieron en la diskon", + "upload.error.default": "Ne eblis alŝuti la dosieron", + "upload.error.extension": "Alŝutado haltita pro la dosiersufikso", + "upload.error.formSize": "La alŝutita dosiero estas pli granda ol la direktivo MAX_FILE_SIZE indikata en la formularo", + "upload.error.iniPostSize": "La alŝutita dosiero estas pli granda ol la direktivo post_max_size de php.ini", + "upload.error.iniSize": "La alŝutita dosiero estas pli granda ol la direktivo upload_max_filesize de php.ini", + "upload.error.noFile": "Neniu dosiero alŝutita", + "upload.error.noFiles": "Neniuj dosieroj alŝutitaj", + "upload.error.partial": "La dosiero estis nur parte alŝutita", + "upload.error.tmpDir": "Mankas provizora dosierujo", + "upload.errors": "Eraro", + "upload.progress": "Alŝutante...", + + "url": "URL", + "url.placeholder": "https://ekzemplo.com", + + "user": "Uzanto", + "user.blueprint": "Vi povas difini pluajn sekciojn kaj kampojn de formularo por ĉi tiu rolo de uzanto ĉe /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Ŝanĝi retpoŝtadreson", + "user.changeLanguage": "Ŝanĝi lingvon", + "user.changeName": "Ŝangi la nomon de la uzanto", + "user.changePassword": "Ŝanĝi pasvorton", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Nova pasvorto", + "user.changePassword.new.confirm": "Konfirmi la novan pasvorton...", + "user.changeRole": "Ŝanĝi rolon", + "user.changeRole.select": "Elekti novan rolon", + "user.create": "Aldoni novan uzanton", + "user.delete": "Forigi ĉi tiun uzanton", + "user.delete.confirm": "Ĉu vi certe volas forigi
{email}?", + + "users": "Uzantoj", + + "version": "Versio", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "Via konto", + "view.installation": "Instalado", + "view.languages": "Lingvoj", + "view.resetPassword": "Restarigi pasvorton", + "view.site": "Retejo", + "view.system": "Sistemo", + "view.users": "Uzantoj", + + "welcome": "Bonvenon", + "year": "Jaro", + "yes": "jes" +} diff --git a/public/kirby/i18n/translations/es_419.json b/public/kirby/i18n/translations/es_419.json new file mode 100644 index 0000000..0acdc1c --- /dev/null +++ b/public/kirby/i18n/translations/es_419.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Cambiar nombre", + "account.delete": "Eliminar cuenta", + "account.delete.confirm": "¿Realmente quieres eliminar tu cuenta? Tu sesión se cerrará inmediatamente. Tu cuenta no podrá ser recuperada. ", + + "activate": "Activate", + "add": "Agregar", + "alpha": "Alpha", + "author": "Autor", + "avatar": "Foto de perfil", + "back": "Regresar", + "cancel": "Cancelar", + "change": "Cambiar", + "close": "Cerrar", + "changes": "Changes", + "confirm": "De acuerdo", + "collapse": "Colapsar", + "collapse.all": "Colapsar todos", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Copiar", + "copy.all": "Copiar todo", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Crear", + "custom": "Custom", + + "date": "Fecha", + "date.select": "Selecciona una fecha", + + "day": "Día", + "days.fri": "Vie", + "days.mon": "Lun", + "days.sat": "S\u00e1b", + "days.sun": "Dom", + "days.thu": "Jue", + "days.tue": "Mar", + "days.wed": "Mi\u00e9", + + "debugging": "Depuración", + + "delete": "Eliminar", + "delete.all": "Eliminar todos", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No has seleccionado ningún archivo", + "dialog.pages.empty": "No has seleccionado ninguna página", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No has seleccionado ningún usuario", + + "dimensions": "Dimensiones", + "disable": "Disable", + "disabled": "Deshabilitado", + "discard": "Descartar", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Descargar", + "duplicate": "Duplicar", + + "edit": "Editar", + + "email": "Correo Electrónico", + "email.placeholder": "correo@ejemplo.com", + + "enter": "Enter", + "entries": "Entradas", + "entry": "Entrada", + + "environment": "Ambiente", + + "error": "Error", + "error.access.code": "Código inválido", + "error.access.login": "Ingreso inválido", + "error.access.panel": "No tienes permitido acceder al panel", + "error.access.view": "No tienes permiso para acceder a esta parte del panel", + + "error.avatar.create.fail": "No se pudo subir la foto de perfil", + "error.avatar.delete.fail": "No se pudo eliminar la foto de perfil", + "error.avatar.dimensions.invalid": "Por favor, mantén el ancho y la altura de la imagen de perfil por debajo de 3000 pixeles", + "error.avatar.mime.forbidden": "La foto de perfil debe de ser un archivo JPG o PNG", + + "error.blueprint.notFound": "El blueprint \"{name}\" no se pudo cargar.", + + "error.blocks.max.plural": "No debes añadir más de {max} bloques", + "error.blocks.max.singular": "No debes añadir más de un bloque", + "error.blocks.min.plural": "Debes añadir al menos {min} bloques ", + "error.blocks.min.singular": "Debes añadir al menos un bloque", + "error.blocks.validation": "Hay un error en el campo \"{field}\" del bloque {index} que utiliza el tipo de bloque \"{fieldset}\"", + + "error.cache.type.invalid": "Tipo de caché \"{tipo}\" no válido", + + "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": "El preajuste de email \"{name}\" no se pudo encontrar.", + + "error.field.converter.invalid": "Convertidor inválido \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Campo \"{ name }\": El tipo de campo \"{ type }\" no existe", + + "error.file.changeName.empty": "El nombre no debe estar vacío", + "error.file.changeName.permission": "No tienes permitido cambiar el nombre de \"{filename}\"", + "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": "Ya existe un archivo con el nombre \"{filename}\".", + "error.file.extension.forbidden": "La extensión \"{extension}\" no está permitida.", + "error.file.extension.invalid": "Extensión inválida: {extension}", + "error.file.extension.missing": "Falta la extensión para \"{filename}\".", + "error.file.maxheight": "La altura de la imagen no debe exceder {height} pixeles", + "error.file.maxsize": "El archivo es muy grande", + "error.file.maxwidth": "El ancho de la imagen no debe exceder {width} pixeles", + "error.file.mime.differs": "El archivo cargado debe ser del mismo tipo mime \"{mime}\".", + "error.file.mime.forbidden": "El tipo de medios \"{mime}\" no está permitido.", + "error.file.mime.invalid": "Tipo invalido de mime: {mime}", + "error.file.mime.missing": "No se puede detectar el tipo de medio para \"{filename}\".", + "error.file.minheight": "La altura de la imagen debe ser de al menos {height} pixeles", + "error.file.minsize": "El archivo es muy pequeño", + "error.file.minwidth": "El ancho de la imagen debe ser de al menos {width} pixeles", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "El nombre del archivo no debe estar vacío.", + "error.file.notFound": "El archivo \"{filename}\" no pudo ser encontrado.", + "error.file.orientation": "La orientación de la imagen debe ser \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "No está permitido subir archivos {type}.", + "error.file.type.invalid": "Tipo de archivo inválido: {type}", + "error.file.undefined": "El archivo no se puede encontrar", + + "error.form.incomplete": "Por favor, corrige todos los errores del formulario...", + "error.form.notSaved": "No se pudo guardar el formulario", + + "error.language.code": "Por favor introduce un código válido para el idioma", + "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": "El idioma ya existe", + "error.language.name": "Por favor introduce un nombre válido para el idioma", + "error.language.notFound": "No se pudo encontrar el idioma", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Hay un error en el campo \"{field}\" del bloque {blockIndex} que utiliza el tipo de bloque \"{fieldset}\" en el layout {layoutIndex}", + "error.layout.validation.settings": "Hay un error en los ajustes del layout {index}", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Por favor ingresa un correo electrónico valido", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "La licencia no pude ser verificada", + + "error.login.totp.confirm.invalid": "Código inválido", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "Hay un error en el campo \"{label}\":\n{message}", + + "error.offline": "El Panel se encuentra fuera de linea ", + + "error.page.changeSlug.permission": "No está permitido cambiar el apéndice de URL para \"{slug}\".", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "La página tiene errores y no puede ser publicada.", + "error.page.changeStatus.permission": "El estado de esta página no se puede cambiar.", + "error.page.changeStatus.toDraft.invalid": "La página \"{slug}\" no se puede convertir en un borrador", + "error.page.changeTemplate.invalid": "La plantilla para la página \"{slug}\" no se puede cambiar", + "error.page.changeTemplate.permission": "No está permitido cambiar la plantilla para \"{slug}\"", + "error.page.changeTitle.empty": "El título no debe estar vacío.", + "error.page.changeTitle.permission": "No tienes permiso para cambiar el título de \"{slug}\"", + "error.page.create.permission": "No tienes permiso para crear \"{slug}\"", + "error.page.delete": "La página \"{slug}\" no se puede eliminar", + "error.page.delete.confirm": "Por favor, introduce el título de la página para confirmar", + "error.page.delete.hasChildren": "La página tiene subpáginas y no se puede eliminar", + "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": "No tienes permiso para borrar \"{slug}\"", + "error.page.draft.duplicate": "Ya existe un borrador de página con el apéndice de URL \"{slug}\"", + "error.page.duplicate": "Ya existe una página con el apéndice de URL \"{slug}\"", + "error.page.duplicate.permission": "No tienes permitido duplicar \"{slug}\"", + "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.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}\"", + "error.page.notFound": "La página \"{slug}\" no se encuentra", + "error.page.num.invalid": "Por favor, introduce un número de posición válido. Los números no deben ser negativos.", + "error.page.slug.invalid": "Por favor, introduce un apéndice de URL válido", + "error.page.slug.maxlength": "La longitud del slug debe ser inferior a \"{length}\" caracteres", + "error.page.sort.permission": "La página \"{slug}\" no se puede ordenar", + "error.page.status.invalid": "Por favor, establece una estado de página válido", + "error.page.undefined": "La p\u00e1gina no fue encontrada", + "error.page.update.permission": "No tienes permiso para actualizar \"{slug}\"", + + "error.section.files.max.plural": "No debes agregar más de {max} archivos a la sección \"{section}\"", + "error.section.files.max.singular": "No debes agregar más de un archivo a la sección \"{section}\"", + "error.section.files.min.plural": "La sección \"{section}\" requiere al menos {min} archivos", + "error.section.files.min.singular": "La sección \"{section}\" requiere al menos un archivo", + + "error.section.pages.max.plural": "No debes agregar más de {max} páginas a la sección \"{section}\"", + "error.section.pages.max.singular": "No debes agregar más de una página a la sección \"{section}\"", + "error.section.pages.min.plural": "La sección \"{section}\" requiere al menos {min} páginas", + "error.section.pages.min.singular": "La sección \"{section}\" requiere al menos una página", + + "error.section.notLoaded": "La sección \"{name}\" no se pudo cargar", + "error.section.type.invalid": "La sección \"{type}\" no es valida", + + "error.site.changeTitle.empty": "El título no debe estar vacío.", + "error.site.changeTitle.permission": "No tienes permiso para cambiar el título del sitio", + "error.site.update.permission": "No tienes permiso de actualizar el sitio", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "La plantilla predeterminada no existe", + + "error.unexpected": "¡Se ha producido un error inesperado! Activa el modo de depuración para obtener más información: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "No tienes permiso para cambiar el email del usuario \"{name}\"", + "error.user.changeLanguage.permission": "No tienes permiso para cambiar el idioma del usuario \"{name}\"", + "error.user.changeName.permission": "No tienes permiso para cambiar el nombre del usuario \"{name}\"", + "error.user.changePassword.permission": "No tienes permiso para cambiar la contraseña del usuario \"{name}\"", + "error.user.changeRole.lastAdmin": "El rol del último administrador no puede ser cambiado", + "error.user.changeRole.permission": "No tienes permiso para cambiar el rol del usuario \"{name}\"", + "error.user.changeRole.toAdmin": "No tienes permitido promover a alguien al rol de admin", + "error.user.create.permission": "No tienes permiso de crear este usuario", + "error.user.delete": "El ususario no pudo ser eliminado", + "error.user.delete.lastAdmin": "Usted no puede borrar el \u00faltimo administrador", + "error.user.delete.lastUser": "El último usuario no puede ser borrado", + "error.user.delete.permission": "Usted no tiene permitido borrar este usuario", + "error.user.duplicate": "Ya existe un usuario con el email \"{email}\"", + "error.user.email.invalid": "Por favor ingresa un correo electrónico valido", + "error.user.language.invalid": "Por favor ingresa un idioma valido", + "error.user.notFound": "El usuario no pudo ser encontrado", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Por favor ingresa una contraseña valida. Las contraseñas deben tener al menos 8 caracteres de largo.", + "error.user.password.notSame": "Por favor confirma la contrase\u00f1a", + "error.user.password.undefined": "El usuario no tiene contraseña", + "error.user.password.wrong": "Contraseña incorrecta", + "error.user.role.invalid": "Por favor ingresa un rol valido", + "error.user.undefined": "El usuario no pudo ser encontrado", + "error.user.update.permission": "No tienes permiso para actualizar al usuario \"{name}\"", + + "error.validation.accepted": "Por favor, confirma", + "error.validation.alpha": "Por favor ingrese solo caracteres entre a-z", + "error.validation.alphanum": "Por favor ingrese solo caracteres entre a-z o números entre 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Por favor ingrese valores entre \"{min}\" y \"{max}\"", + "error.validation.boolean": "Por favor confirme o niegue", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Por favor ingrese valores que contengan \"{needle}\"", + "error.validation.date": "Por favor ingresa una fecha válida", + "error.validation.date.after": "Por favor introduce una fecha posterior a {date}", + "error.validation.date.before": "Por favor introduce una fecha anterior a {date}", + "error.validation.date.between": "Por favor introduce un número entre {min} y {max}", + "error.validation.denied": "Por favor niegue", + "error.validation.different": "EL valor no debe ser \"{other}\"", + "error.validation.email": "Por favor ingresa un correo electrónico valido", + "error.validation.endswith": "El valor no debe terminar con \"{end}\"", + "error.validation.filename": "Por favor ingresa un nombre de archivo válido", + "error.validation.in": "Por favor ingresa uno de los siguientes: ({in})", + "error.validation.integer": "Por favor ingresa un entero válido", + "error.validation.ip": "Por favor ingresa una dirección IP válida", + "error.validation.less": "Por favor ingresa un valor menor a {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "El valor no coincide con el patrón esperado", + "error.validation.max": "Por favor ingresa un valor menor o igual a {max}", + "error.validation.maxlength": "Por favor ingresa un valor mas corto. (max. {max} caracteres)", + "error.validation.maxwords": "Por favor ingresa no mas de {max} palabra(s)", + "error.validation.min": "Por favor ingresa un valor mayor o igual a {min}", + "error.validation.minlength": "Por favor ingresa un valor mas largo. (min. {min} caracteres)", + "error.validation.minwords": "Por favor ingresa al menos {min} palabra(s)", + "error.validation.more": "Por favor ingresa un valor mayor a {min}", + "error.validation.notcontains": "Por favor ingresa un valor que no contenga \"{needle}\"", + "error.validation.notin": "Por favor no ingreses ninguno de las siguientes: ({notIn})", + "error.validation.option": "Por favor selecciona una de las opciones válidas", + "error.validation.num": "Por favor ingresa un numero válido", + "error.validation.required": "Por favor ingresa algo", + "error.validation.same": "Por favor ingresa \"{other}\"", + "error.validation.size": "El tamaño del valor debe ser \"{size}\"", + "error.validation.startswith": "El valor debe comenzar con \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Por favor ingresa una hora válida", + "error.validation.time.after": "Por favor ingresa una fecha después de {time}", + "error.validation.time.before": "Por favor ingresa una fecha antes de {time}", + "error.validation.time.between": "Por favor ingresa un fecha entre {min} y {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Por favor ingresa un URL válido", + + "expand": "Expandir", + "expand.all": "Expandir todo", + + "field.invalid": "The field is invalid", + "field.required": "Este campo es requerido", + "field.blocks.changeType": "Cambiar tipo", + "field.blocks.code.name": "Código", + "field.blocks.code.language": "Idioma", + "field.blocks.code.placeholder": "Tu código...", + "field.blocks.delete.confirm": "¿Seguro que quieres eliminar este bloque?", + "field.blocks.delete.confirm.all": "¿Seguro que quieres eliminar todos los bloques?", + "field.blocks.delete.confirm.selected": "¿Seguro que quieres eliminar los bloques seleccionados?", + "field.blocks.empty": "No hay bloques aún", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Por favor selecciona un tipo de bloque...", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galería", + "field.blocks.gallery.images.empty": "No hay imágenes aún", + "field.blocks.gallery.images.label": "Imágenes", + "field.blocks.heading.level": "Nivel", + "field.blocks.heading.name": "Encabezado", + "field.blocks.heading.text": "Texto", + "field.blocks.heading.placeholder": "Encabezado...", + "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": "Texto alternativo", + "field.blocks.image.caption": "Leyenda", + "field.blocks.image.crop": "Cortar", + "field.blocks.image.link": "Enlace", + "field.blocks.image.location": "Ubicación", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Imágen", + "field.blocks.image.placeholder": "Selecciona una imagen", + "field.blocks.image.ratio": "Proporción", + "field.blocks.image.url": "URL de imágen", + "field.blocks.line.name": "Linea", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Texto", + "field.blocks.markdown.placeholder": "Markdown...", + "field.blocks.quote.name": "Cita", + "field.blocks.quote.text.label": "Texto", + "field.blocks.quote.text.placeholder": "Cita...", + "field.blocks.quote.citation.label": "Cita", + "field.blocks.quote.citation.placeholder": "Por ...", + "field.blocks.text.name": "Texto", + "field.blocks.text.placeholder": "Texto ...", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Leyenda", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Ubicación", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Introduce la URL de un vídeo", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "Vídeo-URL", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "¿Realmente quieres eliminar todas las entradas?", + "field.entries.empty": "Aún no existen entradas.", + + "field.files.empty": "Aún no ha seleccionado ningún archivo", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Eliminar layout", + "field.layout.delete.confirm": "¿Realmente quieres eliminar este layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Aún no hay filas", + "field.layout.select": "Seleccionar layout", + + "field.object.empty": "Aún no hay información", + + "field.pages.empty": "Aún no ha seleccionado ningúna pagina", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "\u00bfEn realidad desea borrar esta entrada?", + "field.structure.delete.confirm.all": "¿Realmente quieres eliminar todas las entradas?", + "field.structure.empty": "A\u00fan no existen entradas.", + + "field.users.empty": "Aún no ha seleccionado ningún usuario", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Archivo", + "file.blueprint": "Este archivo aún no tiene blueprint. Puedes definir la configuración en /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Cambiar plantilla", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "\u00bfEst\u00e1s seguro que deseas eliminar este archivo?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Cambiar posición", + + "files": "Archivos", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Aún no existen archivos", + + "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": "Ocultar", + "hour": "Hora", + "hue": "Hue", + "import": "Importar", + "info": "Info", + "insert": "Insertar", + "insert.after": "Insertar después", + "insert.before": "Insertar antes", + "install": "Instalar", + + "installation": "Instalación", + "installation.completed": "El panel ha sido instalado.", + "installation.disabled": "El instalador del panel está deshabilitado en servidores públicos por defecto. Ejecute el instalador en una máquina local o habilítelo con la opción panel.install.", + "installation.issues.accounts": "La carpeta /site/accounts no existe o no posee permisos de escritura.", + "installation.issues.content": "La carpeta /content no existe o no posee permisos de escritura.", + "installation.issues.curl": "Se requiere la extensión CURL.", + "installation.issues.headline": "El panel no puede ser instalado.", + "installation.issues.mbstring": "Se requiere la extensión MB String.", + "installation.issues.media": "La carpeta /media no existe o no posee permisos de escritura.", + "installation.issues.php": "Asegurese de estar usando PHP 8+", + "installation.issues.sessions": "La carpeta /site/sessions no existe o no posee permisos de escritura.", + + "language": "Idioma", + "language.code": "Código", + "language.convert": "Hacer por defecto", + "language.convert.confirm": "

Realmente deseas convertir {name} al idioma por defecto? Esta acción no se puede deshacer.

Si {name} tiene contenido sin traducir, no habrá vuelta atras y tu sitio puede quedar con partes sin contenido.

", + "language.create": "Añadir nuevo idioma", + "language.default": "Idioma por defecto", + "language.delete.confirm": "

", + "language.deleted": "El idioma ha sido borrado", + "language.direction": "Dirección de lectura", + "language.direction.ltr": "De Izquierda a derecha", + "language.direction.rtl": "De derecha a izquierda", + "language.locale": "Cadena de localización PHP", + "language.locale.warning": "Estas utilizando un configuración local. Por favor modifícalo en el archivo del lenguaje en /site/languages", + "language.name": "Nombre", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "El idioma a sido actualizado", + "language.variables": "Language variables", + "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", + + "languages": "Idiomas", + "languages.default": "Idioma por defecto", + "languages.empty": "Todavía no hay idiomas", + "languages.secondary": "Idiomas secundarios", + "languages.secondary.empty": "Todavía no hay idiomas secundarios", + + "license": "Licencia", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Comprar una licencia", + "license.code": "Código", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Por favor, ingresa tu código de licencia", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Gestiona tus licencias", + "license.purchased": "Purchased", + "license.success": "Gracias por apoyar a Kirby", + "license.unregistered.label": "No registrado", + + "link": "Enlace", + "link.text": "Texto de Enlace", + + "loading": "Cargando", + + "lock.unsaved": "Cambios sin guardar", + "lock.unsaved.empty": "No hay más cambios sin guardar", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Desbloquear", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Iniciar sesión", + "login.code.label.login": "Código de inicio de sesión", + "login.code.label.password-reset": "Código de restablecimiento de contraseña", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Si tu dirección de correo electrónico está registrada, el código solicitado fue enviado por correo electrónico.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hola {user.nameOrEmail},\n\nHas pedido, recientemente, un código de restablecimiento de contraseña para el Panel del sitio {site}.\nEl siguiente código de restablecimiento de contraseña será válido por {timeout} minutos:\n\n{code}\n\nSi no pediste un código de restablecimiento de contraseña, por favor ignora este correo o contacta a tu administrador si tienes dudas.\nPor seguridad, por favor NO reenvíes este correo.", + "login.email.login.subject": "Tu código de inicio de sesión", + "login.email.password-reset.body": "Hola {user.nameOrEmail},\n\nHas pedido, recientemente, un código de restablecimiento de contraseña para el Panel del sitio {site}.\nEl siguiente código de restablecimiento de contraseña será válido por {timeout} minutos:\n\n{code}\n\nSi no pediste un código de restablecimiento de contraseña, por favor ignora este correo o contacta a tu administrador si tienes dudas.\nPor seguridad, por favor NO reenvíes este correo.", + "login.email.password-reset.subject": "Tu código de restablecimiento de contraseña", + "login.remember": "Mantener mi sesión iniciada", + "login.reset": "Restablecer contraseña", + "login.toggleText.code.email": "Iniciar sesión por correo electrónico", + "login.toggleText.code.email-password": "Iniciar sesión con contraseña", + "login.toggleText.password-reset.email": "¿Olvidaste tu contraseña?", + "login.toggleText.password-reset.email-password": "← Volver al inicio de sesión", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Cerrar sesión", + + "merge": "Merge", + "menu": "Menú", + "meridiem": "AM/PM", + "mime": "Tipos de medios", + "minutes": "Minutos", + + "month": "Mes", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Diciembre", + "months.february": "Febrero", + "months.january": "Enero", + "months.july": "Julio", + "months.june": "Junio", + "months.march": "Marzo", + "months.may": "Mayo", + "months.november": "Noviembre", + "months.october": "Octubre", + "months.september": "Septiembre", + + "more": "Más", + "move": "Move", + "name": "Nombre", + "next": "Siguiente", + "night": "Night", + "no": "no", + "off": "Apagado", + "on": "Encendido", + "open": "Abrir", + "open.newWindow": "Abrir en una ventana nueva", + "option": "Option", + "options": "Opciones", + "options.none": "Sin opciones", + "options.all": "Show all {count} options", + + "orientation": "Orientación", + "orientation.landscape": "Paisaje", + "orientation.portrait": "Retrato", + "orientation.square": "Diapositiva", + + "page": "Página", + "page.blueprint": "Este archivo aún no tiene blueprint. Puedes definir la configuración en /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Cambiar URL", + "page.changeSlug.fromTitle": "Crear a partir del t\u00edtulo", + "page.changeStatus": "Cambiar estado", + "page.changeStatus.position": "Por favor selecciona una posición", + "page.changeStatus.select": "Selecciona un nuevo estado", + "page.changeTemplate": "Cambiar plantilla", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "¿Estás seguro que deseas eliminar {title}?", + "page.delete.confirm.subpages": "Esta página tiene subpáginas.
Todas las súbpaginas serán eliminadas también.", + "page.delete.confirm.title": "Introduce el título de la página para confirmar", + "page.duplicate.appendix": "Copiar", + "page.duplicate.files": "Copiar archivos", + "page.duplicate.pages": "Copiar páginas", + "page.move": "Move page", + "page.sort": "Cambiar posición", + "page.status": "Estado", + "page.status.draft": "Borrador", + "page.status.draft.description": "La página está en modo borrador y solo es visible para editores conectados o mediante enlace secreto.", + "page.status.listed": "Pública", + "page.status.listed.description": "La página es pública para cualquiera", + "page.status.unlisted": "No publicada", + "page.status.unlisted.description": "La página sólo es accesible vía URL", + + "pages": "Páginas", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "No hay páginas aún", + "pages.status.draft": "Borradores", + "pages.status.listed": "Publicado", + "pages.status.unlisted": "No publicado", + + "pagination.page": "Página", + + "password": "Contrase\u00f1a", + "paste": "Pegar", + "paste.after": "Pegar después", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Anterior", + "preview": "Previsualizar", + + "publish": "Publish", + "published": "Publicado", + + "remove": "Eliminar", + "rename": "Renombrar", + "renew": "Renew", + "replace": "Reemplazar", + "replace.with": "Replace with", + "retry": "Reintentar", + "revert": "Revertir", + "revert.confirm": "¿Realmente quieres eliminar todos los cambios sin guardar?", + + "role": "Rol", + "role.admin.description": "El administrador tiene todos los derechos", + "role.admin.title": "Administrador", + "role.all": "Todos", + "role.empty": "No hay usuarios con este rol", + "role.description.placeholder": "Sin descripción", + "role.nobody.description": "Este es un rol alternativo sin permisos", + "role.nobody.title": "Nadie", + + "save": "Guardar", + "saved": "Saved", + "search": "Buscar", + "searching": "Searching", + "search.min": "Introduce {min} caracteres para buscar", + "search.all": "Show all {count} results", + "search.results.none": "Sin resultados", + + "section.invalid": "The section is invalid", + "section.required": "Esta sección es requerida", + + "security": "Seguridad", + "select": "Seleccionar", + "server": "Servidor", + "settings": "Ajustes", + "show": "Mostrar", + "site.blueprint": "Este archivo aún no tiene blueprint. Puedes definir la configuración en /site/blueprints/site.yml", + "size": "Tamaño", + "slug": "Apéndice URL", + "sort": "Ordenar", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "Sin informes", + "status": "Estado", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "La carpeta content parece estar expuesta", + "system.issues.eol.kirby": "La versión de Kirby que tienes instalada ha llegado al final de su vida útil y no recibirá más actualizaciones de seguridad.", + "system.issues.eol.plugin": "Tu versión instalada del plugin { plugin } ha llegado al final de su vida útil y no recibirá más actualizaciones de seguridad.", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "La depuración debe estar desactivada en producción", + "system.issues.git": "La carpeta .git parece estar expuesta", + "system.issues.https": "Recomendamos HTTPS para todos tus sitios web", + "system.issues.kirby": "La carpeta kirby parece estar expuesta", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "La carpeta site parece estar expuesta", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Tu instalación podría estar afectada por la siguiente vulnerabilidad ({ severity } gravedad): { description }", + "system.issues.vulnerability.plugin": "Tu instalación podría estar afectada por la siguiente vulnerabilidad en el plugin { plugin } ({ severity } gravedad): { description }", + "system.updateStatus": "Estado de actualización", + "system.updateStatus.error": "No se ha podido comprobar si hay actualizaciones", + "system.updateStatus.not-vulnerable": "No hay vulnerabilidades conocidas", + "system.updateStatus.security-update": "Actualización gratuita de seguridad { version } disponible", + "system.updateStatus.security-upgrade": "Actualización { versión } con correcciones de seguridad disponibles", + "system.updateStatus.unreleased": "Versión no publicada", + "system.updateStatus.up-to-date": "Actualizado", + "system.updateStatus.update": "Actualización gratuita {version} disponible", + "system.updateStatus.upgrade": "Actualización {versión} disponible", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Plantilla", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Título", + "today": "Hoy", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Código", + "toolbar.button.bold": "Negrita", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Encabezados", + "toolbar.button.heading.1": "Encabezado 1", + "toolbar.button.heading.2": "Encabezado 2", + "toolbar.button.heading.3": "Encabezado 3", + "toolbar.button.heading.4": "Encabezado 4", + "toolbar.button.heading.5": "Encabezado 5", + "toolbar.button.heading.6": "Encabezado 6", + "toolbar.button.italic": "Texto en It\u00e1licas", + "toolbar.button.file": "Archivo", + "toolbar.button.file.select": "Selecciona un archivo", + "toolbar.button.file.upload": "Sube un archivo", + "toolbar.button.link": "Enlace", + "toolbar.button.paragraph": "Parágrafo", + "toolbar.button.strike": "Tachado", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Lista en orden", + "toolbar.button.underline": "Subrayado", + "toolbar.button.ul": "Lista de viñetas", + + "translation.author": "Equipo Kirby", + "translation.direction": "ltr", + "translation.name": "Español (América Latina)", + "translation.locale": "es_419", + + "type": "Type", + + "upload": "Subir", + "upload.error.cantMove": "El archivo subido no puede ser movido", + "upload.error.cantWrite": "Error al escribir el archivo en el disco", + "upload.error.default": "El archivo no pudo ser subido", + "upload.error.extension": "Subida de archivo detenida por la extensión", + "upload.error.formSize": "El archivo subido excede la directiva MAX_FILE_SIZE que fue especificada en el formulario", + "upload.error.iniPostSize": "El archivo subido excede la directiva post_max_size directive en php.ini", + "upload.error.iniSize": "El archivo subido excede la directiva upload_max_filesize en php.ini", + "upload.error.noFile": "Ningún archivo ha sido subido", + "upload.error.noFiles": "Ningún archivo ha sido subido", + "upload.error.partial": "El archivo ha sido subido solo parcialmente", + "upload.error.tmpDir": "No se encuentra la carpeta temporal", + "upload.errors": "Error", + "upload.progress": "Subiendo...", + + "url": "Url", + "url.placeholder": "https://ejemplo.com", + + "user": "Usuario", + "user.blueprint": "Puedes definir secciones y campos de formulario adicionales para este rol de usuario en /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Cambiar correo electrónico", + "user.changeLanguage": "Cambiar idioma", + "user.changeName": "Renombrar este usuario", + "user.changePassword": "Cambiar la contraseña", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Nueva contraseña", + "user.changePassword.new.confirm": "Confirma la nueva contraseña...", + "user.changeRole": "Cambiar rol", + "user.changeRole.select": "Selecciona un nuevo rol", + "user.create": "Agregar un nuevo usuario", + "user.delete": "Eliminar este usuario", + "user.delete.confirm": "¿Estás seguro que deseas eliminar
{email}?", + + "users": "Usuarios", + + "version": "Versión", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Versión actual", + "version.latest": "Última versión", + "versionInformation": "información sobre la versión", + + "view": "View", + "view.account": "Tu cuenta", + "view.installation": "Instalaci\u00f3n", + "view.languages": "Idiomas", + "view.resetPassword": "Restablecer contraseña", + "view.site": "Sitio", + "view.system": "Sistema", + "view.users": "Usuarios", + + "welcome": "Bienvenido", + "year": "Año", + "yes": "Sí" +} diff --git a/public/kirby/i18n/translations/es_ES.json b/public/kirby/i18n/translations/es_ES.json new file mode 100644 index 0000000..b1e521f --- /dev/null +++ b/public/kirby/i18n/translations/es_ES.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Cambiar nombre", + "account.delete": "Borrar cuenta", + "account.delete.confirm": "¿Realmente quieres eliminar tu cuenta? Tu sesión se cerrará inmediatamente. La cuenta no podrá ser recuperada.", + + "activate": "Activate", + "add": "Añadir", + "alpha": "Alpha", + "author": "Autor", + "avatar": "Foto de perfil", + "back": "Atrás", + "cancel": "Cancelar", + "change": "Cambiar", + "close": "Cerrar", + "changes": "Changes", + "confirm": "Confirmar", + "collapse": "Colapsar", + "collapse.all": "Colapsar todo", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Copiar", + "copy.all": "Copiar todo", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Crear", + "custom": "Custom", + + "date": "Fecha", + "date.select": "Selecciona una fecha", + + "day": "Día", + "days.fri": "Vi", + "days.mon": "Lu", + "days.sat": "Sá", + "days.sun": "Do", + "days.thu": "Ju", + "days.tue": "Ma", + "days.wed": "Mi", + + "debugging": "Depuración", + + "delete": "Eliminar", + "delete.all": "Eliminar todo", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No hay archivos para seleccionar", + "dialog.pages.empty": "No hay páginas para seleccionar", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No hay usuarios para seleccionar", + + "dimensions": "Dimensiones", + "disable": "Disable", + "disabled": "Desabilitado", + "discard": "Descartar", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Descargar", + "duplicate": "Duplicar", + + "edit": "Editar", + + "email": "Correo electrónico", + "email.placeholder": "correo@ejemplo.com", + + "enter": "Enter", + "entries": "Entradas", + "entry": "Entrada", + + "environment": "Entorno", + + "error": "Error", + "error.access.code": "Código inválido", + "error.access.login": "Inicio de sesión inválido", + "error.access.panel": "No tienes permiso para acceder al panel", + "error.access.view": "No tienes permiso para acceder a esta parte del panel", + + "error.avatar.create.fail": "No se pudo subir la foto de perfil.", + "error.avatar.delete.fail": "No se pudo borrar la foto de perfil", + "error.avatar.dimensions.invalid": "Por favor, mantén el ancho y la altura de la imagen de perfil por debajo de 3000 píxeles", + "error.avatar.mime.forbidden": "La imagen del perfil debe ser JPEG o PNG.", + + "error.blueprint.notFound": "El blueprint \"{name}\" no pudo ser cargado", + + "error.blocks.max.plural": "No debes añadir más de {max} bloques", + "error.blocks.max.singular": "No debes añadir más de un bloque", + "error.blocks.min.plural": "Debes añadir al menos {min} bloques ", + "error.blocks.min.singular": "Debes añadir al menos un bloque", + "error.blocks.validation": "Hay un error en el campo \"{field}\" del bloque {index} que utiliza el tipo de bloque \"{fieldset}\"", + + "error.cache.type.invalid": "Tipo de caché inválido \"{tipo}\"", + + "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": "El preset del correo \"{name}\" no puede ser encontrado", + + "error.field.converter.invalid": "Convertidor \"{converter}\" inválido", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Campo \"{ name }\": El tipo de campo \"{ type }\" no existe", + + "error.file.changeName.empty": "El nombre no debe estar vacío", + "error.file.changeName.permission": "No tienes permiso para cambiar el nombre de \"{filename}\"", + "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": "Ya existe un archivo con el nombre \"{filename}\"", + "error.file.extension.forbidden": "La extensión \"{extension}\" no está permitida", + "error.file.extension.invalid": "Extensión inválida: {extension}", + "error.file.extension.missing": "Falta la extensión para \"{filename}\"", + "error.file.maxheight": "La altura de la imagen no debe exceder {height} pixeles", + "error.file.maxsize": "El archivo es demasiado grande", + "error.file.maxwidth": "El ancho de la imagen no debe exceder {width} pixeles", + "error.file.mime.differs": "El archivo cargado debe ser del mismo tipo mime \"{mime}\"", + "error.file.mime.forbidden": "Los medios tipo \"{mime}\" no están permitidos", + "error.file.mime.invalid": "Tipo de mime inválido: {mime}", + "error.file.mime.missing": "El tipo de medio para \"{filename}\" no puede ser detectado", + "error.file.minheight": "La altura de la imagen debe ser de al menos {height} pixeles", + "error.file.minsize": "El archivo es demasiado pequeño", + "error.file.minwidth": "El ancho de la imagen debe ser de al menos {width} pixeles", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "El nombre de archivo no debe estar vacío", + "error.file.notFound": "El archivo \"{filename}\" no puede ser encontrado", + "error.file.orientation": "La orientación de la imagen debe ser \"{orientation}", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "No tienes permiso para subir archivos {type}", + "error.file.type.invalid": "Tipo de archivo inválido: {type}", + "error.file.undefined": "El archivo no puede ser encontrado", + + "error.form.incomplete": "Por favor, corrige todos los errores del formulario…", + "error.form.notSaved": "El formulario no pudo ser guardado", + + "error.language.code": "Por favor, introduce un código válido para el idioma", + "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": "El idioma ya existe", + "error.language.name": "Por favor, introduce un nombre válido para el idioma", + "error.language.notFound": "No se pudo encontrar el idioma", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Hay un error en el campo \"{field}\" del bloque {blockIndex} que utiliza el tipo de bloque \"{fieldset}\" en el layout {layoutIndex}", + "error.layout.validation.settings": "Hay un error en los ajustes del layout {index}", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Por favor, introduce un correo electrónico válido", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "La licencia no pudo ser verificada", + + "error.login.totp.confirm.invalid": "Código inválido", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "Hay un error en el campo \"{label}\":\n{message}", + + "error.offline": "El Panel se encuentra actualmente fuera de línea ", + + "error.page.changeSlug.permission": "No tienes permiso para cambiar el apéndice de URL para \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "La página tiene errores y no puede ser publicada.", + "error.page.changeStatus.permission": "El estado de esta página no se puede cambiar", + "error.page.changeStatus.toDraft.invalid": "La página \"{slug}\" no se puede convertir a borrador", + "error.page.changeTemplate.invalid": "La plantilla para la página \"{slug}\" no se puede cambiar", + "error.page.changeTemplate.permission": "No tienes permiso para cambiar la plantilla para \"{slug}\"", + "error.page.changeTitle.empty": "El título no debe estar vacío.", + "error.page.changeTitle.permission": "No tienes permiso para cambiar el título por \"{slug}\"", + "error.page.create.permission": "No tienes permiso para crear \"{slug}\"", + "error.page.delete": "La página \"{slug}\" no se puede eliminar", + "error.page.delete.confirm": "Por favor, introduce el título de la página para confirmar", + "error.page.delete.hasChildren": "La página tiene subpáginas y no se puede eliminar", + "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": "No tienes permiso para eliminar \"{slug}\"", + "error.page.draft.duplicate": "Un borrador de página con el apéndice de URL \"{slug}\" ya existe", + "error.page.duplicate": "Una página con el apéndice de URL \"{slug}\" ya existe", + "error.page.duplicate.permission": "No tienes permiso para duplicar \"{slug}\"", + "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.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}\"", + "error.page.notFound": "No se puede encontrar la página \"{slug}\"", + "error.page.num.invalid": "Por favor, introduce un número de ordenación válido. Los números no deben ser negativos.", + "error.page.slug.invalid": "Por favor, introduce un apéndice de URL válido", + "error.page.slug.maxlength": "La longitud del slug debe ser inferior a \"{length}\" caracteres", + "error.page.sort.permission": "No se puede encontrar la página \"{slug}\"", + "error.page.status.invalid": "Por favor, establece un estado de página válido", + "error.page.undefined": "No se puede encontrar la página", + "error.page.update.permission": "No tienes permiso para actualizar \"{slug}\"", + + "error.section.files.max.plural": "No debes agregar más de {max} archivos a la sección \"{section}\"", + "error.section.files.max.singular": "No debes agregar más de 1 archivo a la sección \"{section}\"", + "error.section.files.min.plural": "La sección \"{section}\" requiere al menos {min} archivos", + "error.section.files.min.singular": "La sección \"{section}\" requiere al menos un archivo", + + "error.section.pages.max.plural": "No debes agregar más de {max} páginas a la sección \"{section}\"", + "error.section.pages.max.singular": "No debes agregar más de una página a la sección \"{section}\"", + "error.section.pages.min.plural": "La sección \"{section}\" requiere al menos {min} páginas", + "error.section.pages.min.singular": "La sección \"{section}\" requiere al menos una página", + + "error.section.notLoaded": "La sección \"{name}\" no pudo ser cargada", + "error.section.type.invalid": "El sección tipo \"{tipo}\" no es válido", + + "error.site.changeTitle.empty": "El título no debe estar vacío.", + "error.site.changeTitle.permission": "No tienes permiso para cambiar el título del sitio", + "error.site.update.permission": "No tienes permiso para actualizar el sitio", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "La plantilla por defecto no existe", + + "error.unexpected": "¡Se ha producido un error inesperado! Activa el modo de depuración para obtener más información: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "No tienes permiso para cambiar el correo electrónico para el usuario \"{name}\"", + "error.user.changeLanguage.permission": "No tienes permiso para cambiar el idioma para el usuario \"{name}\"", + "error.user.changeName.permission": "No tienes permiso para cambiar el nombre del usuario \"{name}\"", + "error.user.changePassword.permission": "No tienes permiso para cambiar la contraseña del usuario \"{name}\"", + "error.user.changeRole.lastAdmin": "No se puede cambiar el rol del último administrador", + "error.user.changeRole.permission": "No tienes permiso para cambiar el rol del usuario \"{name}\"", + "error.user.changeRole.toAdmin": "No tienes permiso para promover a alguien al rol de admin", + "error.user.create.permission": "No tienes permiso para crear este usuario", + "error.user.delete": "No se puede eliminar el usuario \"{name}\"", + "error.user.delete.lastAdmin": "No se puede eliminar el último admin", + "error.user.delete.lastUser": "No se puede eliminar el último usuario ", + "error.user.delete.permission": "No tienes permiso para eliminar el usuario \"{name}\"", + "error.user.duplicate": "Un usuario con la dirección de correo electrónico \"{email}\" ya existe", + "error.user.email.invalid": "Por favor, introduce una dirección de correo electrónico válida", + "error.user.language.invalid": "Por favor, introduce un idioma válido", + "error.user.notFound": "No se puede encontrar el usuario \"{name}\"", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Por favor, introduce una contraseña válida. Las contraseñas deben tener al menos 8 caracteres de largo.", + "error.user.password.notSame": "Las contraseñas no coinciden", + "error.user.password.undefined": "El usuario no tiene contraseña", + "error.user.password.wrong": "Contraseña incorrecta", + "error.user.role.invalid": "Por favor, introduce un rol válido", + "error.user.undefined": "No se puede encontrar el usuario", + "error.user.update.permission": "No tienes permiso para actualizar el usuario \"{name}\"", + + "error.validation.accepted": "Por favor, confirma", + "error.validation.alpha": "Por favor, introduce solo caracteres entre a-z", + "error.validation.alphanum": "Por favor, introduce solo caracteres entre a-z o numerales 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Por favor, introduce un valor entre \"{min}\" y \"{max}\"", + "error.validation.boolean": "Por favor, confirma o rechaza", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Por favor, introduce un valor que contenga \"{needle}\"", + "error.validation.date": "Por favor, introduce una fecha válida", + "error.validation.date.after": "Por favor, introduce una fecha posterior a {date}", + "error.validation.date.before": "Por favor, introduce una fecha anterior a {date}", + "error.validation.date.between": "Por favor, introduce un número entre {min} y {max}", + "error.validation.denied": "Por favor, rechaza", + "error.validation.different": "El valor no debe ser \"{other}\"", + "error.validation.email": "Por favor, introduce un correo electrónico válido", + "error.validation.endswith": "El valor debe terminar con \"{end}\"", + "error.validation.filename": "Por favor, introduce un nombre de archivo válido", + "error.validation.in": "Por favor, introduce uno de los siguientes: ({in})", + "error.validation.integer": "Por favor, introduce un numero integro válido", + "error.validation.ip": "Por favor, introduce una dirección IP válida", + "error.validation.less": "Por favor, introduce un valor inferior a {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "El valor no coincide con el patrón esperado", + "error.validation.max": "Por favor, introduce un valor igual o inferior a {max}", + "error.validation.maxlength": "Por favor, introduce un valor más corto. (max. {max} caracteres)", + "error.validation.maxwords": "Por favor, introduce no más de {max} palabra(s)", + "error.validation.min": "Por favor, introduce un valor igual o mayor a {min}", + "error.validation.minlength": "Por favor, introduce un valor más largo. (min. {min} caracteres)", + "error.validation.minwords": "Por favor, introduce al menos {min} palabra(s)", + "error.validation.more": "Por favor, introduce un valor mayor a {min}", + "error.validation.notcontains": "Por favor, introduce un valor que no contenga \"{needle}\"", + "error.validation.notin": "Por favor, no introduzcas ninguno de los siguientes: ({notIn})", + "error.validation.option": "Por favor, selecciona una opción válida", + "error.validation.num": "Por favor, introduce un número valido", + "error.validation.required": "Por favor, introduce algo", + "error.validation.same": "Por favor, introduce \"{other}\"", + "error.validation.size": "El tamaño del valor debe ser \"{size}\"", + "error.validation.startswith": "El valor debe comenzar con \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Por favor, introduce una hora válida", + "error.validation.time.after": "Por favor, introduce una fecha después de {time}", + "error.validation.time.before": "Por favor, introduce una fecha antes de {time}", + "error.validation.time.between": "Por favor, introduce un fecha entre {min} y {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Por favor, introduce un URL válido", + + "expand": "Expandir", + "expand.all": "Expandir todo", + + "field.invalid": "The field is invalid", + "field.required": "Este campo es obligatorio", + "field.blocks.changeType": "Cambiar tipo", + "field.blocks.code.name": "Código", + "field.blocks.code.language": "Idioma", + "field.blocks.code.placeholder": "Tu código...", + "field.blocks.delete.confirm": "¿Realmente quieres eliminar este bloque?", + "field.blocks.delete.confirm.all": "¿Realmente quieres eliminar todos los bloques?", + "field.blocks.delete.confirm.selected": "¿Realmente quieres eliminar los bloques seleccionados?", + "field.blocks.empty": "Aún no hay bloques", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Por favor, selecciona un tipo de bloque...", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galería", + "field.blocks.gallery.images.empty": "Aún no hay imágenes", + "field.blocks.gallery.images.label": "Imágenes", + "field.blocks.heading.level": "Nivel", + "field.blocks.heading.name": "Encabezado", + "field.blocks.heading.text": "Texto", + "field.blocks.heading.placeholder": "Encabezado...", + "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": "Texto alternativo", + "field.blocks.image.caption": "Leyenda", + "field.blocks.image.crop": "Cortar", + "field.blocks.image.link": "Enlace", + "field.blocks.image.location": "Ubicación", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Imágen", + "field.blocks.image.placeholder": "Selecciona una imagen", + "field.blocks.image.ratio": "Proporción", + "field.blocks.image.url": "URL de imágen", + "field.blocks.line.name": "Linea", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Texto", + "field.blocks.markdown.placeholder": "Markdown...", + "field.blocks.quote.name": "Cita", + "field.blocks.quote.text.label": "Texto", + "field.blocks.quote.text.placeholder": "Cita...", + "field.blocks.quote.citation.label": "Cita", + "field.blocks.quote.citation.placeholder": "Por ...", + "field.blocks.text.name": "Texto", + "field.blocks.text.placeholder": "Texto ...", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Leyenda", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Ubicación", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Introduce la URL de un vídeo", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "Vídeo-URL", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "¿Realmente quieres eliminar todas las entradas?", + "field.entries.empty": "Aún no hay entradas", + + "field.files.empty": "Aún no hay archivos seleccionados", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Eliminar layout", + "field.layout.delete.confirm": "¿Realmente quieres eliminar este layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Aún no hay filas", + "field.layout.select": "Seleccionar layout", + + "field.object.empty": "Aún no hay información", + + "field.pages.empty": "Aún no hay páginas seleccionadas", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "¿Realmente quieres eliminar esta fila?", + "field.structure.delete.confirm.all": "¿Realmente quieres eliminar todas las entradas?", + "field.structure.empty": "Aún no hay entradas", + + "field.users.empty": "Aún no hay usuarios seleccionados", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Archivo", + "file.blueprint": "Este archivo aún no tiene blueprint. Puedes definir la configuración en /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Cambiar plantilla", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "¿Realmente quieres eliminar
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Cambiar posición", + + "files": "Archivos", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Aún no hay archivos", + + "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": "Ocultar", + "hour": "Hora", + "hue": "Hue", + "import": "Importar", + "info": "Info", + "insert": "Insertar", + "insert.after": "Insertar después", + "insert.before": "Insertar antes", + "install": "Instalar", + + "installation": "Instalación", + "installation.completed": "El panel ha sido instalado", + "installation.disabled": "El instalador del panel está deshabilitado en servidores públicos por defecto. Por favor, ejecuta el instalador en una máquina local o habilítalo con la opción panel.install.", + "installation.issues.accounts": "La carpeta /site/accounts no existe o no se puede escribir", + "installation.issues.content": "La carpeta /content no existe o no se puede escribir", + "installation.issues.curl": "La extensión CURL es requerida", + "installation.issues.headline": "No se puede instalar el panel", + "installation.issues.mbstring": "La extension MB String es requerida", + "installation.issues.media": "La carpeta /media no existe o no se puede escribir", + "installation.issues.php": "Asegurese de estar usando PHP 8+", + "installation.issues.sessions": "La carpeta /site/sessions no existe o no se puede escribir", + + "language": "Idioma", + "language.code": "Código", + "language.convert": "Hacer por defecto", + "language.convert.confirm": "

¿Realmente quieres convertir {name} al idioma por defecto? Esto no se puede deshacer.

Si {name} tiene contenido sin traducir, ya no habrá un respaldo válido y algunas partes de tu sitio podrían estar vacías.

", + "language.create": "Añadir un nuevo idioma", + "language.default": "Idioma predeterminado", + "language.delete.confirm": "¿Realmente quieres eliminar el idioma {name} incluyendo todas las traducciones? ¡Esto no se puede deshacer!", + "language.deleted": "El idioma ha sido eliminado", + "language.direction": "Leyendo dirección", + "language.direction.ltr": "De izquierda a derecha", + "language.direction.rtl": "De derecha a izquierda", + "language.locale": "PHP locale string", + "language.locale.warning": "Estás utilizando una configuración local. Por favor, modifícalo en el archivo del lenguaje en /site/languages", + "language.name": "Nombre", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "El idioma ha sido actualizado", + "language.variables": "Language variables", + "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", + + "languages": "Idiomas", + "languages.default": "Idioma predeterminado", + "languages.empty": "Aún no hay idiomas", + "languages.secondary": "Idiomas secundarios", + "languages.secondary.empty": "Aún no hay idiomas secundarios", + + "license": "Licencia", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Comprar una licencia", + "license.code": "Código", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Por favor, introduce tu código de licencia", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Gestiona licencias", + "license.purchased": "Purchased", + "license.success": "Gracias por apoyar a Kirby", + "license.unregistered.label": "No registrado", + + "link": "Enlace", + "link.text": "Texto del enlace", + + "loading": "Cargando", + + "lock.unsaved": "Cambios sin guardar", + "lock.unsaved.empty": "No hay más cambios sin guardar", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Desbloquear", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Iniciar sesión", + "login.code.label.login": "Código de inicio de sesión", + "login.code.label.password-reset": "Código de restablecimiento de contraseña", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Si tu dirección de correo electrónico está registrada, el código solicitado fue enviado por correo electrónico.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hola {user.nameOrEmail},\n\nHas pedido, recientemente, un código de restablecimiento de contraseña para el Panel del sitio {site}.\nEl siguiente código de restablecimiento de contraseña será válido por {timeout} minutos:\n\n{code}\n\nSi no pediste un código de restablecimiento de contraseña, por favor ignora este correo o contacta a tu administrador si tienes dudas.\nPor seguridad, por favor NO reenvíes este correo.", + "login.email.login.subject": "Tu código de inicio de sesión", + "login.email.password-reset.body": "Hola {user.nameOrEmail},\n\nHas pedido, recientemente, un código de restablecimiento de contraseña para el Panel del sitio {site}.\nEl siguiente código de restablecimiento de contraseña será válido por {timeout} minutos:\n\n{code}\n\nSi no pediste un código de restablecimiento de contraseña, por favor ignora este correo o contacta a tu administrador si tienes dudas.\nPor seguridad, por favor NO reenvíes este correo.", + "login.email.password-reset.subject": "Tu código de restablecimiento de contraseña", + "login.remember": "Mantener sesión iniciada", + "login.reset": "Restablecer contraseña", + "login.toggleText.code.email": "Iniciar sesión por correo electrónico", + "login.toggleText.code.email-password": "Iniciar sesión con contraseña", + "login.toggleText.password-reset.email": "¿Olvidaste tu contraseña?", + "login.toggleText.password-reset.email-password": "← Volver al inicio de sesión", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Cerrar sesión", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipos de medios", + "minutes": "Minutos", + + "month": "Mes", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Diciembre", + "months.february": "Febrero", + "months.january": "Enero", + "months.july": "Julio", + "months.june": "Junio", + "months.march": "Marzo", + "months.may": "Mayo", + "months.november": "Noviembre", + "months.october": "Octubre", + "months.september": "Septiembre", + + "more": "Más", + "move": "Move", + "name": "Nombre", + "next": "Siguiente", + "night": "Night", + "no": "no", + "off": "Apagado", + "on": "Encendido", + "open": "Abrir", + "open.newWindow": "Abrir en una ventana nueva", + "option": "Option", + "options": "Opciones", + "options.none": "Sin opciones", + "options.all": "Show all {count} options", + + "orientation": "Orientación", + "orientation.landscape": "Paisaje", + "orientation.portrait": "Retrato", + "orientation.square": "Cuadrado", + + "page": "Página", + "page.blueprint": "Este archivo aún no tiene blueprint. Puedes definir la configuración en /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Cambiar URL", + "page.changeSlug.fromTitle": "Crear en base al título", + "page.changeStatus": "Cambiar estado", + "page.changeStatus.position": "Por favor, selecciona una posición", + "page.changeStatus.select": "Selecciona un nuevo estado", + "page.changeTemplate": "Cambiar plantilla", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "¿Realmente quieres eliminar {title}?", + "page.delete.confirm.subpages": "Esta página tiene subpáginas.
Todas las subpáginas también serán eliminadas.", + "page.delete.confirm.title": "Introduce el título de la página para confirmar", + "page.duplicate.appendix": "Copiar", + "page.duplicate.files": "Copiar archivos", + "page.duplicate.pages": "Copiar páginas", + "page.move": "Move page", + "page.sort": "Cambiar posición", + "page.status": "Estado", + "page.status.draft": "Borrador", + "page.status.draft.description": "La página está en modo borrador y solo es visible para editores conectados o mediante enlace secreto.", + "page.status.listed": "Pública", + "page.status.listed.description": "La página es pública para cualquiera", + "page.status.unlisted": "Sin publicar", + "page.status.unlisted.description": "La página solo es accesible vía URL", + + "pages": "Paginas", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Aún no hay páginas", + "pages.status.draft": "Borradores", + "pages.status.listed": "Publicadas", + "pages.status.unlisted": "Sin publicar", + + "pagination.page": "Página", + + "password": "Contraseña", + "paste": "Pegar", + "paste.after": "Pegar después", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Anterior", + "preview": "Previsualizar", + + "publish": "Publish", + "published": "Publicadas", + + "remove": "Eliminar", + "rename": "Renombrar", + "renew": "Renew", + "replace": "Remplazar", + "replace.with": "Replace with", + "retry": "Inténtalo de nuevo", + "revert": "Revertir", + "revert.confirm": "¿Realmente quieres eliminar todos los cambios sin guardar?", + + "role": "Rol", + "role.admin.description": "El administrador tiene todos los derechos", + "role.admin.title": "Administrador", + "role.all": "Todo", + "role.empty": "No hay usuarios con este rol", + "role.description.placeholder": "Sin descripción", + "role.nobody.description": "Este es un rol alternativo sin permisos", + "role.nobody.title": "Nadie", + + "save": "Guardar", + "saved": "Saved", + "search": "Buscar", + "searching": "Searching", + "search.min": "Introduce {min} caracteres para buscar", + "search.all": "Show all {count} results", + "search.results.none": "Sin resultados", + + "section.invalid": "The section is invalid", + "section.required": "Esta sección es obligatoria", + + "security": "Seguridad", + "select": "Seleccionar", + "server": "Servidor", + "settings": "Ajustes", + "show": "Mostrar", + "site.blueprint": "Este archivo aún no tiene blueprint. Puedes definir la configuración en /site/blueprints/site.yml", + "size": "Tamaño", + "slug": "Apéndice de URL", + "sort": "Ordenar", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "Sin informes", + "status": "Estado", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "La carpeta content parece estar expuesta", + "system.issues.eol.kirby": "La versión de Kirby que tienes instalada ha llegado al final de su vida útil y no recibirá más actualizaciones de seguridad.", + "system.issues.eol.plugin": "La versión del plugin { plugin } que tienes instalada ha llegado al final de su vida útil y no recibirá más actualizaciones de seguridad.", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "La depuración debe estar desactivada en producción", + "system.issues.git": "La carpeta .git parece estar expuesta", + "system.issues.https": "Recomendamos HTTPS para todos tus sitios web", + "system.issues.kirby": "La carpeta kirby parece estar expuesta", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "La carpeta site parece estar expuesta", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Tu instalación podría estar afectada por la siguiente vulnerabilidad ({ severity } gravedad): { description }", + "system.issues.vulnerability.plugin": "Tu instalación podría estar afectada por la siguiente vulnerabilidad en el plugin { plugin } ({ severity } gravedad): { description }", + "system.updateStatus": "Estado de actualización", + "system.updateStatus.error": "No se pudo comprobar si hay actualizaciones", + "system.updateStatus.not-vulnerable": "Sin vulnerabilidades conocidas", + "system.updateStatus.security-update": "Actualización gratuita de seguridad { version } disponible", + "system.updateStatus.security-upgrade": "Actualización { versión } con correcciones de seguridad disponibles", + "system.updateStatus.unreleased": "Versión no publicada", + "system.updateStatus.up-to-date": "Actualizado", + "system.updateStatus.update": "Actualización gratuita {version} disponible", + "system.updateStatus.upgrade": "Actualización {versión} disponible", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Plantilla", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Título", + "today": "Hoy", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Código", + "toolbar.button.bold": "Negrita", + "toolbar.button.email": "Correo electrónico", + "toolbar.button.headings": "Encabezados", + "toolbar.button.heading.1": "Encabezado 1", + "toolbar.button.heading.2": "Encabezado 2", + "toolbar.button.heading.3": "Encabezado 3", + "toolbar.button.heading.4": "Encabezado 4", + "toolbar.button.heading.5": "Encabezado 5", + "toolbar.button.heading.6": "Encabezado 6", + "toolbar.button.italic": "Italica", + "toolbar.button.file": "Archivo", + "toolbar.button.file.select": "Seleccionar un archivo", + "toolbar.button.file.upload": "Subir un archivo", + "toolbar.button.link": "Enlace", + "toolbar.button.paragraph": "Parágrafo", + "toolbar.button.strike": "Tachado", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Lista ordenada", + "toolbar.button.underline": "Subrayado", + "toolbar.button.ul": "Lista de viñetas", + + "translation.author": "Turqueso", + "translation.direction": "ltr", + "translation.name": "Español", + "translation.locale": "es_ES", + + "type": "Type", + + "upload": "Subir", + "upload.error.cantMove": "El archivo subido no pudo ser movido", + "upload.error.cantWrite": "Error al escribir el archivo en el disco", + "upload.error.default": "El archivo no pudo ser subido", + "upload.error.extension": "Subida de archivo detenida por la extensión", + "upload.error.formSize": "El archivo subido excede la directiva MAX_FILE_SIZE que fue especificada en el formulario", + "upload.error.iniPostSize": "El archivo subido excede la directiva post_max_size directive en php.ini", + "upload.error.iniSize": "El archivo subido excede la directiva upload_max_filesize en php.ini", + "upload.error.noFile": "No se ha subido ningún archivo", + "upload.error.noFiles": "No se ha subido ningún archivo", + "upload.error.partial": "El archivo ha sido subido solo parcialmente", + "upload.error.tmpDir": "No se encuentra la carpeta temporal", + "upload.errors": "Error", + "upload.progress": "Cargando…", + + "url": "Url", + "url.placeholder": "https://ejemplo.com", + + "user": "Usuario", + "user.blueprint": "Puedes definir secciones y campos de formulario adicionales para este rol de usuario en /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Cambiar correo electrónico", + "user.changeLanguage": "Cambiar idioma", + "user.changeName": "Renombrar a este usuario", + "user.changePassword": "Cambiar contraseña", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Nueva contraseña", + "user.changePassword.new.confirm": "Confirmar nueva contraseña…", + "user.changeRole": "Cambiar rol", + "user.changeRole.select": "Seleccionar un nuevo rol", + "user.create": "Añadir un nuevo usuario", + "user.delete": "Eliminar este usuario", + "user.delete.confirm": "¿Realmente quieres eliminar
{email}?", + + "users": "Usuarios", + + "version": "Versión", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Versión actual", + "version.latest": "Última versión", + "versionInformation": "Información sobre la versión", + + "view": "View", + "view.account": "Tu cuenta", + "view.installation": "Instalación", + "view.languages": "Idiomas", + "view.resetPassword": "Restablecer contraseña", + "view.site": "Sitio", + "view.system": "Sistema", + "view.users": "Usuarios", + + "welcome": "Bienvenido(a)", + "year": "Año", + "yes": "Sí" +} diff --git a/public/kirby/i18n/translations/fa.json b/public/kirby/i18n/translations/fa.json new file mode 100644 index 0000000..89c45f8 --- /dev/null +++ b/public/kirby/i18n/translations/fa.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Change your name", + "account.delete": "Delete your account", + "account.delete.confirm": "Do you really want to delete your account? You will be logged out immediately. Your account cannot be recovered.", + + "activate": "Activate", + "add": "\u0627\u0641\u0632\u0648\u062f\u0646", + "alpha": "Alpha", + "author": "Author", + "avatar": "\u062a\u0635\u0648\u06cc\u0631 \u067e\u0631\u0648\u0641\u0627\u06cc\u0644", + "back": "بازگشت", + "cancel": "\u0627\u0646\u0635\u0631\u0627\u0641", + "change": "\u0627\u0635\u0644\u0627\u062d", + "close": "\u0628\u0633\u062a\u0646", + "changes": "Changes", + "confirm": "تایید", + "collapse": "Collapse", + "collapse.all": "Collapse All", + "color": "Color", + "coordinates": "Coordinates", + "copy": "کپی", + "copy.all": "Copy all", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "ایجاد", + "custom": "Custom", + + "date": "تاریخ", + "date.select": "یک تاریخ را انتخاب کنید", + + "day": "روز", + "days.fri": "\u062c\u0645\u0639\u0647", + "days.mon": "\u062f\u0648\u0634\u0646\u0628\u0647", + "days.sat": "\u0634\u0646\u0628\u0647", + "days.sun": "\u06cc\u06a9\u0634\u0646\u0628\u0647", + "days.thu": "\u067e\u0646\u062c\u0634\u0646\u0628\u0647", + "days.tue": "\u0633\u0647 \u0634\u0646\u0628\u0647", + "days.wed": "\u0686\u0647\u0627\u0631\u0634\u0646\u0628\u0647", + + "debugging": "Debugging", + + "delete": "\u062d\u0630\u0641", + "delete.all": "Delete all", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No files to select", + "dialog.pages.empty": "No pages to select", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "No users to select", + + "dimensions": "ابعاد", + "disable": "Disable", + "disabled": "Disabled", + "discard": "\u0627\u0646\u0635\u0631\u0627\u0641", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Download", + "duplicate": "Duplicate", + + "edit": "\u0648\u06cc\u0631\u0627\u06cc\u0634", + + "email": "\u067e\u0633\u062a \u0627\u0644\u06a9\u062a\u0631\u0648\u0646\u06cc\u06a9", + "email.placeholder": "mail@example.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Environment", + + "error": "Error", + "error.access.code": "Invalid code", + "error.access.login": "اطلاعات ورودی نامعتبر است", + "error.access.panel": "شما اجازه دسترسی به پانل را ندارید", + "error.access.view": "You are not allowed to access this part of the panel", + + "error.avatar.create.fail": "بارگزاری تصویر پروفایل موفق نبود", + "error.avatar.delete.fail": "\u062a\u0635\u0648\u06cc\u0631 \u067e\u0631\u0648\u0641\u0627\u06cc\u0644 \u0631\u0627 \u0646\u0645\u06cc\u062a\u0648\u0627\u0646 \u062d\u0630\u0641 \u06a9\u0631\u062f", + "error.avatar.dimensions.invalid": "لطفا طول و عرض تصویر پروفایل را زیر 3000 پیکسل انتخاب کنید", + "error.avatar.mime.forbidden": "تصویر پروفایل باید از نوع JPEG یا PNG باشد", + + "error.blueprint.notFound": "بلوپرینت با نام «{name}» قابل بارگذاری نیست", + + "error.blocks.max.plural": "You must not add more than {max} blocks", + "error.blocks.max.singular": "You must not add more than one block", + "error.blocks.min.plural": "You must add at least {min} blocks", + "error.blocks.min.singular": "You must add at least one block", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "قالب ایمیل «{name}» پیدا نشد", + + "error.field.converter.invalid": "مبدل «{converter}» نامعتبر است", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "The name must not be empty", + "error.file.changeName.permission": "شما اجازه تنغییر نام فایل «{filename}» را ندارید", + "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": "فایلی هم نام با «{filename}» هم اکنون موجود است", + "error.file.extension.forbidden": "پسوند فایل «{extension}» غیرمجاز است", + "error.file.extension.invalid": "Invalid extension: {extension}", + "error.file.extension.missing": "\u0634\u0645\u0627 \u0646\u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc\u062f \u0641\u0627\u06cc\u0644\u200c\u0647\u0627\u06cc \u0628\u062f\u0648\u0646 \u067e\u0633\u0648\u0646\u062f \u0631\u0627 \u0622\u067e\u0644\u0648\u062f \u06a9\u0646\u06cc\u062f", + "error.file.maxheight": "The height of the image must not exceed {height} pixels", + "error.file.maxsize": "The file is too large", + "error.file.maxwidth": "The width of the image must not exceed {width} pixels", + "error.file.mime.differs": "فایل آپلود شده باید از همان نوع باشد «{mime}»", + "error.file.mime.forbidden": "فرمت فایل «{mime}» غیرمجاز است", + "error.file.mime.invalid": "Invalid mime type: {mime}", + "error.file.mime.missing": "فرمت فایل «{filename}» قابل شناسایی نیست", + "error.file.minheight": "The height of the image must be at least {height} pixels", + "error.file.minsize": "The file is too small", + "error.file.minwidth": "The width of the image must be at least {width} pixels", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "نام فایل اجباری است", + "error.file.notFound": "فایل «{filename}» پیدا نشد.", + "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": "شما اجازه بارگذاری فایلهای «{type}» را ندارید", + "error.file.type.invalid": "Invalid file type: {type}", + "error.file.undefined": "\u0641\u0627\u06cc\u0644 \u0645\u0648\u0631\u062f \u0646\u0638\u0631 \u067e\u06cc\u062f\u0627 \u0646\u0634\u062f.", + + "error.form.incomplete": "لطفا کلیه خطاهای فرم را برطرف کنید", + "error.form.notSaved": "امکان دخیره فرم وجود ندارد", + + "error.language.code": "Please enter a valid code for the language", + "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": "The language already exists", + "error.language.name": "Please enter a valid name for the language", + "error.language.notFound": "The language could not be found", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "There's an error in layout {index} settings", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "لطفا ایمیل صحیحی وارد کنید", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "The license could not be verified", + + "error.login.totp.confirm.invalid": "Invalid code", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "The Panel is currently offline", + + "error.page.changeSlug.permission": "شما امکان تغییر پسوند Url صفحه «{slug}» را ندارید", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{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": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.", + "error.page.delete.permission": "شما اجازه حذف «{slug}» را ندارید", + "error.page.draft.duplicate": "صفحه پیش‌نویسی با پسوند Url مشابه «{slug}» هم اکنون موجود است", + "error.page.duplicate": "صفحه‌ای با آدرس Url مشابه «{slug}» هم اکنون موجود است", + "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"", + "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.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}\"", + "error.page.notFound": "صفحه مورد نظر با آدرس «{slug}» پیدا نشد.", + "error.page.num.invalid": "لطفا شماره ترتیب را بدرستی وارد نمایید. اعداد نباید منفی باشند.", + "error.page.slug.invalid": "Please enter a valid URL appendix", + "error.page.slug.maxlength": "Slug length must be less than \"{length}\" characters", + "error.page.sort.permission": "امکان مرتب‌سازی «{slug}» نیست", + "error.page.status.invalid": "لطفا وضعیت صحیحی برای صفحه انتخاب کنید", + "error.page.undefined": "صفحه مورد نظر پیدا نشد", + "error.page.update.permission": "شما اجازه بروزرسانی «{slug}» را ندارید", + + "error.section.files.max.plural": "نباید بیش از {max} فایل به بخش «{section}» اضافه کنید", + "error.section.files.max.singular": "نباید بیش از یک فایل به بخش «{section}» اضافه کنید", + "error.section.files.min.plural": "The \"{section}\" section requires at least {min} files", + "error.section.files.min.singular": "The \"{section}\" section requires at least one file", + + "error.section.pages.max.plural": "نباید بیش از {max} صفحه به بخش «{section}» اضافه کنید", + "error.section.pages.max.singular": "نباید بیش از یک صفحه به بخش «{section}» اضافه کنید", + "error.section.pages.min.plural": "The \"{section}\" section requires at least {min} pages", + "error.section.pages.min.singular": "The \"{section}\" section requires at least one page", + + "error.section.notLoaded": "بخش «{name}» قابل بارکذاری نیست", + "error.section.type.invalid": "نوع بخش «{type}» غیرمجاز است", + + "error.site.changeTitle.empty": "عنوان اجباری است", + "error.site.changeTitle.permission": "شما اجازه تغییر عنوان سایت را ندارید", + "error.site.update.permission": "شما اجازه بروزرسانی سایت را ندارید", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "قالب پیش فرض موجود نیست", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: 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": "You are not allowed to promote someone to the admin role", + "error.user.create.permission": "شما اجازه ایجاد این کاربر را ندارید", + "error.user.delete": "کاربر «{name}» نمی تواند حذف شود", + "error.user.delete.lastAdmin": "\u062d\u0630\u0641 \u0622\u062e\u0631\u06cc\u0646 \u0645\u062f\u06cc\u0631 \u0633\u06cc\u0633\u062a\u0645 \u0645\u0645\u06a9\u0646 \u0646\u06cc\u0633\u062a", + "error.user.delete.lastUser": "حذف آخرین کاربر ممکن نیست", + "error.user.delete.permission": "شما اجازه حذف کاربر «{name}» را ندارید", + "error.user.duplicate": "کاربری با ایمیل «{email}» هم اکنون موجود است", + "error.user.email.invalid": "لطفا یک ایمیل معتبر وارد کنید", + "error.user.language.invalid": "لطفا زبان معتبری انتخاب کنید", + "error.user.notFound": "کاربر «{name}» پیدا نشد", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "لطفا گذرواژه صحیحی با حداقل طول 8 حرف وارد کنید. ", + "error.user.password.notSame": "\u0644\u0637\u0641\u0627 \u062a\u06a9\u0631\u0627\u0631 \u06af\u0630\u0631\u0648\u0627\u0698\u0647 \u0631\u0627 \u0648\u0627\u0631\u062f \u0646\u0645\u0627\u06cc\u06cc\u062f", + "error.user.password.undefined": "کاربر فاقد گذرواژه است", + "error.user.password.wrong": "Wrong password", + "error.user.role.invalid": "لطفا نقش صحیحی وارد نمایید", + "error.user.undefined": "کاربر مورد نظر پیدا نشد", + "error.user.update.permission": "شما اجازه بروزرسانی کاربر «{name}» را ندارید", + + "error.validation.accepted": "لطفا تایید کنید", + "error.validation.alpha": "لطفا تنها از بین حروف a-z انتخاب کنید", + "error.validation.alphanum": "لطفا تنها از بین حروف a-z و اعداد 0-9 انتخاب کنید", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "لطفا مقداری مابین «{min}» و «{max}» وارد کنید", + "error.validation.boolean": "لطفا تایید یا رد کنید", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "لطفا مقداری شامل «{needle}» وارد کنید", + "error.validation.date": "لطفا تاریخ معتبری وارد کنید", + "error.validation.date.after": "Please enter a date after {date}", + "error.validation.date.before": "Please enter a date before {date}", + "error.validation.date.between": "Please enter a date between {min} and {max}", + "error.validation.denied": "لطفا رد کنید", + "error.validation.different": "مقدار نباید مساوی «{other}» باشد", + "error.validation.email": "لطفا ایمیل صحیحی وارد کنید", + "error.validation.endswith": "مقدار باید با «{end}» ختم شود", + "error.validation.filename": "لطفا نام فایل صحیحی وارد کنید", + "error.validation.in": "لطفا یکی از مقادیر روبرو را وارد کنید: ({in})", + "error.validation.integer": "لطفا عدد صحیحی وارد کنید", + "error.validation.ip": "لطفا IP آدرس صحیحی وارد کنید", + "error.validation.less": "لطفا مقداری کمتر از {max} وارد کنید", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "مقدار وارد شده با الگوی مورد نظر همخوانی ندارد", + "error.validation.max": "لطفا مقداری کوچکتر یا مساوی {min} وارد کنید", + "error.validation.maxlength": "لطفا عبارت کوتاه‌تری وارد کنید. (حداکثر {max} حرف)", + "error.validation.maxwords": "لطفا بیش از {max} کلمه وارد نکنید", + "error.validation.min": "لطفا مقداری بزرگتر یا مساوی با {min} وارد کنید", + "error.validation.minlength": "لطفا عبارتی طولانی‌تری وارد کنید. (حداقل {min} حرف)", + "error.validation.minwords": "لطفا حداقل {min} کلمه وارد کنید", + "error.validation.more": "لطفا مقداری بیش از {min} وارد کنید", + "error.validation.notcontains": "لطفا مقداری فاقد «{needle}» وارد کنید", + "error.validation.notin": "لطفا از مقادیر روبرو استفاده نکنید: ({notin})", + "error.validation.option": "لطفا گزینه معتبری انتخاب کنید", + "error.validation.num": "لطفا عدد صحیحی وارد کنید", + "error.validation.required": "لطفا مقداری وارد کنید", + "error.validation.same": "لطفا مقدار «{other}» را وارد کنید", + "error.validation.size": "اندازه ورودی باید معادل «{size}» باشد", + "error.validation.startswith": "مقدار باید با «{start}» شروع شود", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "لطفا زمان معتبری وارد کنید", + "error.validation.time.after": "Please enter a time after {time}", + "error.validation.time.before": "Please enter a time before {time}", + "error.validation.time.between": "Please enter a time between {min} and {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "لطفا آدرس URL صحیح وارد کنید", + + "expand": "Expand", + "expand.all": "Expand All", + + "field.invalid": "The field is invalid", + "field.required": "The field is required", + "field.blocks.changeType": "Change type", + "field.blocks.code.name": "کد", + "field.blocks.code.language": "زبان", + "field.blocks.code.placeholder": "Your code …", + "field.blocks.delete.confirm": "Do you really want to delete this block?", + "field.blocks.delete.confirm.all": "Do you really want to delete all blocks?", + "field.blocks.delete.confirm.selected": "Do you really want to delete the selected blocks?", + "field.blocks.empty": "No blocks yet", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Please select a block type …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Gallery", + "field.blocks.gallery.images.empty": "No images yet", + "field.blocks.gallery.images.label": "Images", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Heading", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Heading …", + "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": "Alternative text", + "field.blocks.image.caption": "Caption", + "field.blocks.image.crop": "Crop", + "field.blocks.image.link": "پیوند", + "field.blocks.image.location": "Location", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "تصویر", + "field.blocks.image.placeholder": "Select an image", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Image URL", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "List", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Quote", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Quote …", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "by …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Caption", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Location", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Enter a video URL", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "موردی وجود ندارد.", + + "field.files.empty": "فایلی انتخاب نشده است", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Delete layout", + "field.layout.delete.confirm": "Do you really want to delete this layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "No rows yet", + "field.layout.select": "Select a layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "صفحه‌ای انتخاب نشده است", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "\u0645\u062f\u062e\u0644 \u062c\u0627\u0631\u06cc \u062d\u0630\u0641 \u0634\u0648\u062f\u061f", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "\u0645\u0648\u0631\u062f\u06cc \u0648\u062c\u0648\u062f \u0646\u062f\u0627\u0631\u062f.", + + "field.users.empty": "کاربری انتخاب نشده است", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "File", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "تغییر قالب", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "آیا واقعا می خواهید این فایل را حذف کنید؟
{filename}", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Change position", + + "files": "فایل‌ها", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "فایلی موجود نیست", + + "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": "ساعت", + "hue": "Hue", + "import": "Import", + "info": "Info", + "insert": "\u062f\u0631\u062c", + "insert.after": "Insert after", + "insert.before": "Insert before", + "install": "نصب", + + "installation": "نصب و راه اندازی", + "installation.completed": "پنل کاربری نصب شد", + "installation.disabled": "نصب کننده پانل کاربری بصورت پیش‌فرض در سرورهای عمومی غیرفعال است. لطفا نصب را روی یک ماشین محلی اجرا کنید یا آن را با استفاده از panel.install فعال کنید.", + "installation.issues.accounts": "پوشه /site/accounts موجود نیست یا قابل نوشتن نیست.", + "installation.issues.content": "پوشه /content موجود نیست یا قابل نوشتن نیست", + "installation.issues.curl": "افزونه CURL مورد نیاز است", + "installation.issues.headline": "نصب پانل کاربری ممکن نیست", + "installation.issues.mbstring": "افزونه MB String مورد نیاز است", + "installation.issues.media": "پوشه /media موجود نیست یا قابل نوشتن نیست", + "installation.issues.php": "لطفا از پی‌اچ‌پی 8 یا بالاتر استفاده کنید", + "installation.issues.sessions": "پوشه /site/sessions وجود ندارد یا قابل نوشتن نیست", + + "language": "\u0632\u0628\u0627\u0646", + "language.code": "کد", + "language.convert": "پیش‌فرض شود", + "language.convert.confirm": "

آیا واقعا میخواهید {name} را به زبان پیشفرض تبدیل کنید؟ این عمل برگشت ناپذیر است.

اگر {name} دارای محتوای غیر ترجمه شده باشد، جایگزین معتبر دیگری نخواهد بود و ممکن است بخش‌هایی از سایت شما خالی باشد.

", + "language.create": "افزودن زبان جدید", + "language.default": "زبان پیش‌فرض", + "language.delete.confirm": "آیا واقعا میخواهید زبان {name} را به همراه تمام ترجمه‌ها حذف کنید؟ این عمل قابل بازگشت نخواهد بود!", + "language.deleted": "زبان مورد نظر حذف شد", + "language.direction": "rtl", + "language.direction.ltr": "چپ به راست", + "language.direction.rtl": "راست به چپ", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "پارسی", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "زبان به روز شد", + "language.variables": "Language variables", + "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", + + "languages": "زبان‌ها", + "languages.default": "زبان پیش‌فرض", + "languages.empty": "هنوز هیچ زبانی موجود نیست", + "languages.secondary": "زبان‌های ثانویه", + "languages.secondary.empty": "هنوز هیچ زبان ثانویه‌ای موجود نیست", + + "license": "\u0645\u062c\u0648\u0632", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "خرید مجوز", + "license.code": "کد", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "لطفا کد مجوز خود را وارد کنید", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "با تشکر از شما برای حمایت از کربی", + "license.unregistered.label": "Unregistered", + + "link": "\u067e\u06cc\u0648\u0646\u062f", + "link.text": "\u0645\u062a\u0646 \u067e\u06cc\u0648\u0646\u062f", + + "loading": "بارگزاری", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more 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}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "ورود", + "login.code.label.login": "Login code", + "login.code.label.password-reset": "Password reset code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "If your email address is registered, the requested code was sent via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Your login code", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Your password reset code", + "login.remember": "مرا به خاطر بسپار", + "login.reset": "Reset password", + "login.toggleText.code.email": "Login via email", + "login.toggleText.code.email-password": "Login with password", + "login.toggleText.password-reset.email": "Forgot your password?", + "login.toggleText.password-reset.email-password": "← Back to login", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "خروج", + + "merge": "Merge", + "menu": "منو", + "meridiem": "ق.ظ/ب.ظ", + "mime": "نوع رسانه", + "minutes": "دقیقه", + + "month": "ماه", + "months.april": "\u0622\u0648\u0631\u06cc\u0644", + "months.august": "\u0627\u0648\u062a", + "months.december": "\u062f\u0633\u0627\u0645\u0628\u0631", + "months.february": "فوریه", + "months.january": "\u0698\u0627\u0646\u0648\u06cc\u0647", + "months.july": "\u0698\u0648\u0626\u06cc\u0647", + "months.june": "\u0698\u0648\u0626\u0646", + "months.march": "\u0645\u0627\u0631\u0633", + "months.may": "\u0645\u06cc", + "months.november": "\u0646\u0648\u0627\u0645\u0628\u0631", + "months.october": "\u0627\u06a9\u062a\u0628\u0631", + "months.september": "\u0633\u067e\u062a\u0627\u0645\u0628\u0631", + + "more": "بیشتر", + "move": "Move", + "name": "نام", + "next": "بعدی", + "night": "Night", + "no": "no", + "off": "off", + "on": "on", + "open": "بازکردن", + "open.newWindow": "Open in new window", + "option": "Option", + "options": "گزینه‌ها", + "options.none": "No options", + "options.all": "Show all {count} options", + + "orientation": "جهت", + "orientation.landscape": "افقی", + "orientation.portrait": "عمودی", + "orientation.square": "مربع", + + "page": "صفحه", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "تغییر Url صفحه", + "page.changeSlug.fromTitle": "\u0627\u06cc\u062c\u0627\u062f \u0627\u0632 \u0631\u0648\u06cc \u0639\u0646\u0648\u0627\u0646", + "page.changeStatus": "تغییر وضعیت", + "page.changeStatus.position": "لطفا یک موقعیت را انتخاب کنید", + "page.changeStatus.select": "یک وضعیت جدید را انتخاب کنید", + "page.changeTemplate": "تغییر قالب", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "صفحه {title} حذف شود؟", + "page.delete.confirm.subpages": "این صفحه دارای زیرصفحه است.
تمام زیر صفحات نیز حذف خواهد شد.", + "page.delete.confirm.title": "جهت ادامه عنوان صفحه را وارد کنید", + "page.duplicate.appendix": "کپی", + "page.duplicate.files": "Copy files", + "page.duplicate.pages": "Copy pages", + "page.move": "Move page", + "page.sort": "Change position", + "page.status": "وضعیت", + "page.status.draft": "پیش‌نویس", + "page.status.draft.description": "The page is in draft mode and only visible for logged in editors or via secret link", + "page.status.listed": "عمومی", + "page.status.listed.description": "این صفحه برای عموم قابل مشاهده است", + "page.status.unlisted": "فهرست نشده", + "page.status.unlisted.description": "این صفحه فقط از طریق URL قابل دسترسی است", + + "pages": "صفحات", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "هنوز هیچ صفحه‌ای موجود نیست", + "pages.status.draft": "پیش‌نویس‌ها", + "pages.status.listed": "منتشر شده", + "pages.status.unlisted": "فهرست نشده", + + "pagination.page": "صفحه", + + "password": "\u06af\u0630\u0631\u0648\u0627\u0698\u0647", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "پیکسل", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "قبلی", + "preview": "Preview", + + "publish": "Publish", + "published": "منتشر شده", + + "remove": "حذف", + "rename": "تغییر نام", + "renew": "Renew", + "replace": "\u062c\u0627\u06cc\u06af\u0632\u06cc\u0646\u06cc", + "replace.with": "Replace with", + "retry": "\u062a\u0644\u0627\u0634 \u0645\u062c\u062f\u062f", + "revert": "بازگرداندن تغییرات", + "revert.confirm": "Do you really want to delete all unsaved changes?", + + "role": "\u0646\u0642\u0634", + "role.admin.description": "The admin has all rights", + "role.admin.title": "Admin", + "role.all": "همه", + "role.empty": "هیچ کاربری با این نقش وجود ندارد", + "role.description.placeholder": "فاقد شرح", + "role.nobody.description": "This is a fallback role without any permissions", + "role.nobody.title": "Nobody", + + "save": "\u0630\u062e\u06cc\u0631\u0647", + "saved": "Saved", + "search": "جستجو", + "searching": "Searching", + "search.min": "Enter {min} characters to search", + "search.all": "Show all {count} results", + "search.results.none": "No results", + + "section.invalid": "The section is invalid", + "section.required": "The section is required", + + "security": "Security", + "select": "انتخاب", + "server": "Server", + "settings": "تنظیمات", + "show": "Show", + "site.blueprint": "The site has no blueprint yet. You can define the setup in /site/blueprints/site.yml", + "size": "اندازه", + "slug": "پسوند Url", + "sort": "ترتیب", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "وضعیت", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "\u0642\u0627\u0644\u0628 \u0635\u0641\u062d\u0647", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "عنوان", + "today": "امروز", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "کد", + "toolbar.button.bold": "\u0645\u062a\u0646 \u0628\u0627 \u062d\u0631\u0648\u0641 \u062f\u0631\u0634\u062a", + "toolbar.button.email": "\u067e\u0633\u062a \u0627\u0644\u06a9\u062a\u0631\u0648\u0646\u06cc\u06a9", + "toolbar.button.headings": "عنوان‌ها", + "toolbar.button.heading.1": "عنوان 1", + "toolbar.button.heading.2": "عنوان 2", + "toolbar.button.heading.3": "عنوان 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "\u0645\u062a\u0646 \u0627\u0631\u06cc\u0628", + "toolbar.button.file": "فایل", + "toolbar.button.file.select": "Select a file", + "toolbar.button.file.upload": "Upload a file", + "toolbar.button.link": "\u067e\u06cc\u0648\u0646\u062f", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Strike-through", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "لیست مرتب", + "toolbar.button.underline": "Underline", + "toolbar.button.ul": "لیست معمولی", + + "translation.author": "تیم کربی", + "translation.direction": "rtl", + "translation.name": "انگلیسی", + "translation.locale": "fa_IR", + + "type": "Type", + + "upload": "بارگذاری", + "upload.error.cantMove": "The uploaded file could not be moved", + "upload.error.cantWrite": "Failed to write file to disk", + "upload.error.default": "The file could not be uploaded", + "upload.error.extension": "File upload stopped by extension", + "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form", + "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini", + "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini", + "upload.error.noFile": "No file was uploaded", + "upload.error.noFiles": "No files were uploaded", + "upload.error.partial": "The uploaded file was only partially uploaded", + "upload.error.tmpDir": "Missing a temporary folder", + "upload.errors": "خطا", + "upload.progress": "در حال بارگذاری...", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "کاربر", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "تغییر ایمیل", + "user.changeLanguage": "تغییر زبان", + "user.changeName": "تغییر نام این کاربر", + "user.changePassword": "تغییر گذرواژه", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "گذرواژه جدید", + "user.changePassword.new.confirm": "تایید گذرواژه جدید...", + "user.changeRole": "تغییر نقش", + "user.changeRole.select": "یک نقش جدید را انتخاب کنید", + "user.create": "افزودن کاربر جدید", + "user.delete": "حذف کاربر جاری", + "user.delete.confirm": "آیا واقعا میخواهید {email} را حذف کنید؟", + + "users": "کاربران", + + "version": "\u0646\u0633\u062e\u0647 \u0646\u0631\u0645 \u0627\u0641\u0632\u0627\u0631", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "حساب کاربری شما", + "view.installation": "\u0646\u0635\u0628 \u0648 \u0631\u0627\u0647 \u0627\u0646\u062f\u0627\u0632\u06cc", + "view.languages": "زبان‌ها", + "view.resetPassword": "Reset password", + "view.site": "سایت", + "view.system": "System", + "view.users": "\u06a9\u0627\u0631\u0628\u0631\u0627\u0646", + + "welcome": "خوش آمدید", + "year": "سال", + "yes": "yes" +} diff --git a/public/kirby/i18n/translations/fi.json b/public/kirby/i18n/translations/fi.json new file mode 100644 index 0000000..78a9cfc --- /dev/null +++ b/public/kirby/i18n/translations/fi.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Muuta nimesi", + "account.delete": "Poista tilisi", + "account.delete.confirm": "Haluatko varmasti poistaa tilisi? Sinut kirjataan ulos välittömästi, eikä tiliäsi voi palauttaa.", + + "activate": "Activate", + "add": "Lis\u00e4\u00e4", + "alpha": "Alpha", + "author": "Tekijä", + "avatar": "Profiilikuva", + "back": "Takaisin", + "cancel": "Peruuta", + "change": "Muuta", + "close": "Sulje", + "changes": "Changes", + "confirm": "Ok", + "collapse": "Pienennä", + "collapse.all": "Pienennä kaikki", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Kopioi", + "copy.all": "Kopioi kaikki", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Luo", + "custom": "Custom", + + "date": "Päivämäärä", + "date.select": "Valitse päivämäärä", + + "day": "Päivä", + "days.fri": "Pe", + "days.mon": "Ma", + "days.sat": "La", + "days.sun": "Su", + "days.thu": "To", + "days.tue": "Ti", + "days.wed": "Ke", + + "debugging": "Virheenkäsittelytila", + + "delete": "Poista", + "delete.all": "Poista kaikki", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "Ei valittavissa olevia tiedostoja", + "dialog.pages.empty": "Ei valittavissa olevia sivuja", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Ei valittavissa olevia käyttäjiä", + + "dimensions": "Mitat", + "disable": "Disable", + "disabled": "Pois käytöstä", + "discard": "Hylkää", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Lataa", + "duplicate": "Kahdenna", + + "edit": "Muokkaa", + + "email": "S\u00e4hk\u00f6posti", + "email.placeholder": "nimi@osoite.fi", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Ympäristö", + + "error": "Error", + "error.access.code": "Väärä koodi", + "error.access.login": "Kirjautumistiedot eivät kelpaa", + "error.access.panel": "Sinulla ei ole oikeutta käyttää paneelia", + "error.access.view": "Sinulla ei ole oikeutta käyttää tätä osaa paneelista", + + "error.avatar.create.fail": "Profiilikuvaa ei voitu lähettää", + "error.avatar.delete.fail": "Profiilikuvaa ei voitu poistaa", + "error.avatar.dimensions.invalid": "Profiilikuvan leveys ja korkeus voivat olla enintään 3000 pikseliä", + "error.avatar.mime.forbidden": "Profiilikuvan täytyy olla joko JPEG- tai PNG-formaatissa", + + "error.blueprint.notFound": "Suunnitelmaa \"{name}\" ei voitu ladata", + + "error.blocks.max.plural": "Voit lisätä enintään {max} lohkoa", + "error.blocks.max.singular": "Voit lisätä enintään yhden lohkon", + "error.blocks.min.plural": "Lisää vähintään {min} lohkoa", + "error.blocks.min.singular": "Lisää vähintään yksi lohko", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "Nimellä \"{name}\" ja kyseisellä verkkotunnuksella ei löydy sähköpostiosoitetta", + + "error.field.converter.invalid": "Muunnin \"{converter}\" ei kelpaa", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "Nimi ei voi olla tyhjä", + "error.file.changeName.permission": "Sinulla ei ole oikeutta muuttaa tiedoston \"{filename}\" nimeä", + "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": "Tiedosto nimeltä \"{filename}\" on jo olemassa", + "error.file.extension.forbidden": "Tiedostopääte \"{extension}\" ei ole sallittu", + "error.file.extension.invalid": "Pääte {extension} ei kelpaa", + "error.file.extension.missing": "Tiedoston \"{filename}\" tiedostopääte puuttuu", + "error.file.maxheight": "Kuvan korkeus ei voi ylittää {height} pikseliä", + "error.file.maxsize": "Tiedosto on liian suuri", + "error.file.maxwidth": "Kuvan leveys ei voi ylittää {width} pikseliä", + "error.file.mime.differs": "Lähetetyllä tiedostolla täytyy olla sama mime-tyyppi \"{mime}\"", + "error.file.mime.forbidden": "Median tyyppi \"{mime}\" ei ole sallittu", + "error.file.mime.invalid": "Mime-tyyppi {mime} ei kelpaa", + "error.file.mime.missing": "Tiedoston \"{filename}\" mediatyyppiä ei voida tunnistaa", + "error.file.minheight": "Kuvan korkeus täytyy olla vähintään {height} pikseliä", + "error.file.minsize": "Tiedosto on liian pieni", + "error.file.minwidth": "Kuvan leveys täytyy olla vähintään {width} pikseliä", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Tiedostonimi ei voi olla tyhjä", + "error.file.notFound": "Tiedostoa \"{filename}\" ei löytynyt", + "error.file.orientation": "Kuvan suuntaus täytyy olla \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Sinulla ei ole oikeutta lähettää tiedostoja joiden tyyppi on {type}", + "error.file.type.invalid": "Tiedostotyyppi {type} ei kelpaa", + "error.file.undefined": "Tiedostoa ei l\u00f6ytynyt", + + "error.form.incomplete": "Korjaa kaikki lomakkeen virheet…", + "error.form.notSaved": "Lomaketta ei voitu tallentaa", + + "error.language.code": "Anna kielen lyhenne", + "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": "Kieli on jo olemassa", + "error.language.name": "Anna kielen nimi", + "error.language.notFound": "Kieltä ei löytynyt", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "Virhe asetelman {index} asetuksissa", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Anna sähköpostiosoite", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "Lisenssiä ei voitu vahvistaa", + + "error.login.totp.confirm.invalid": "Väärä koodi", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "Paneeli on offline-tilassa", + + "error.page.changeSlug.permission": "Sinulla ei ole oikeutta muuttaa URL-liitettä sivulle \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "Sivulla on virheitä eikä sitä voitu julkaista", + "error.page.changeStatus.permission": "Tämän sivun tilaa ei voi muuttaa", + "error.page.changeStatus.toDraft.invalid": "Sivua \"{slug}\" ei voi muuttaa luonnokseksi", + "error.page.changeTemplate.invalid": "Sivun \"{slug}\" pohjaa ei voi muuttaa", + "error.page.changeTemplate.permission": "Sinulla ei ole oikeutta muuttaa sivun \"{slug}\" sivupohjaa", + "error.page.changeTitle.empty": "Nimi ei voi olla tyhjä", + "error.page.changeTitle.permission": "Sinulla ei ole oikeutta muuttaa sivun \"{slug}\" nimeä", + "error.page.create.permission": "Sinulla ei ole oikeutta luoda sivua \"{slug}\"", + "error.page.delete": "Sivua \"{slug}\" ei voi poistaa", + "error.page.delete.confirm": "Anna vahvistuksena sivun nimi", + "error.page.delete.hasChildren": "Sivu sisältää alasivuja eikä sitä voida poistaa", + "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": "Sinulla ei ole oikeutta poistaa sivua \"{slug}\"", + "error.page.draft.duplicate": "Sivuluonnos URL-liitteellä \"{slug}\" on jo olemassa", + "error.page.duplicate": "Sivu URL-liitteellä \"{slug}\" on jo olemassa", + "error.page.duplicate.permission": "Sinulla ei ole oikeutta kahdentaa sivua \"{slug}\"", + "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.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}\"", + "error.page.notFound": "Sivua \"{slug}\" ei löytynyt", + "error.page.num.invalid": "Anna kelpaava järjestysnumero. Numero ei voi olla negatiivinen.", + "error.page.slug.invalid": "Anna kelpaava URL-liite", + "error.page.slug.maxlength": "URL-liite täytyy olla vähemmän kuin \"{length}\" merkkiä pitkä", + "error.page.sort.permission": "Sivua \"{slug}\" ei voi järjestellä", + "error.page.status.invalid": "Aseta kelvollinen sivun tila", + "error.page.undefined": "Sivua ei l\u00f6ytynyt", + "error.page.update.permission": "Sinulla ei ole oikeutta päivittää sivua \"{slug}\"", + + "error.section.files.max.plural": "Et voi lisätä enemmän kuin {max} tiedostoa osioon \"{section}\"", + "error.section.files.max.singular": "Et voi lisätä enempää kuin yhden tiedoston osioon \"{section}\"", + "error.section.files.min.plural": "Osio \"{section}\" vaatii ainakin {min} tiedostoa", + "error.section.files.min.singular": "Osio \"{section}\" vaatii ainakin yhden sivun", + + "error.section.pages.max.plural": "Et voi lisätä enemmän kuin {max} sivua osioon \"{section}\"", + "error.section.pages.max.singular": "Et voi lisätä enempää kuin yhden sivun osioon \"{section}\"", + "error.section.pages.min.plural": "Osio \"{section}\" vaatii ainakin {min} sivua", + "error.section.pages.min.singular": "Osio \"{section}\" vaatii ainakin yhden sivun", + + "error.section.notLoaded": "Osiota \"{name}\" ei voitu ladata", + "error.section.type.invalid": "Osion tyyppi \"{type}\" ei ole kelvollinen", + + "error.site.changeTitle.empty": "Nimi ei voi olla tyhjä", + "error.site.changeTitle.permission": "Sinulla ei ole oikeutta päivittää sivuston nimeä", + "error.site.update.permission": "Sinulla ei ole oikeutta päivittää sivuston tietoja", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Oletussivupohjaa ei ole määritetty", + + "error.unexpected": "Pahus, määrittelemätön virhe! Laita virheenkäsittelytila päälle saadaksesi lisätietoja: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Sinulla ei ole oikeutta vaihtaa käyttäjän \"{name}\" sähköpostiosoitetta", + "error.user.changeLanguage.permission": "Sinulla ei ole oikeutta vaihtaa käyttäjän \"{name}\" kieltä", + "error.user.changeName.permission": "Sinulla ei ole oikeutta vaihtaa käyttäjän \"{name}\" nimeä", + "error.user.changePassword.permission": "Sinulla ei ole oikeutta vaihtaa käyttäjän \"{name}\" salasanaa", + "error.user.changeRole.lastAdmin": "Ainoan pääkäyttäjän roolia ei voi muuttaa", + "error.user.changeRole.permission": "Sinulla ei ole oikeutta vaihtaa käyttäjän \"{name}\" käyttäjätasoa", + "error.user.changeRole.toAdmin": "Sinulla ei ole oikeutta vaihtaa käyttäjätasoa pääkäyttäjäksi", + "error.user.create.permission": "Sinulla ei ole oikeutta luoda tätä käyttäjää", + "error.user.delete": "Käyttäjää \"{name}\" ei voi poistaa", + "error.user.delete.lastAdmin": "Ainoaa pääkäyttäjää ei voi poistaa", + "error.user.delete.lastUser": "Ainoaa käyttäjää ei voi poistaa", + "error.user.delete.permission": "Sinulla ei ole oikeutta poistaa käyttäjää \"{name}\"", + "error.user.duplicate": "Käyttäjä, jonka sähköpostiosoite on \"{name}\", on jo olemassa", + "error.user.email.invalid": "Anna kelpaava sähköpostiosoite", + "error.user.language.invalid": "Anna kelpaava kieli", + "error.user.notFound": "K\u00e4ytt\u00e4j\u00e4\u00e4 ei l\u00f6ytynyt", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Anna kelpaava salasana. Salasanan täytyy olla ainakin 8 merkkiä pitkä.", + "error.user.password.notSame": "Salasanat eivät täsmää", + "error.user.password.undefined": "Käyttäjällä ei ole salasanaa", + "error.user.password.wrong": "Väärä salasana", + "error.user.role.invalid": "Anna kelpaava käyttäjätaso", + "error.user.undefined": "Käyttäjää ei löytynyt", + "error.user.update.permission": "Sinulla ei ole oikeutta päivittää käyttäjää \"{name}\"", + + "error.validation.accepted": "Ole hyvä ja vahvista", + "error.validation.alpha": "Anna vain merkkejä väliltä a-z", + "error.validation.alphanum": "Anna vain merkkejä väliltä a-z tai/ja numeroita väliltä 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Anna arvo väliltä \"{min}\" ja \"{max}\"", + "error.validation.boolean": "Vahvista tai peruuta", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Anna arvo joka sisältää \"{needle}\"", + "error.validation.date": "Anna kelpaava päivämäärä", + "error.validation.date.after": "Anna päivämäärä {date} jälkeen", + "error.validation.date.before": "Anna päivämäärä ennen {date}", + "error.validation.date.between": "Anna päivämäärä väliltä {min} ja {max}", + "error.validation.denied": "Ole hyvä ja peruuta", + "error.validation.different": "Arvo ei voi olla \"{other}\"", + "error.validation.email": "Anna kelpaava sähköpostiosoite", + "error.validation.endswith": "Arvon loppuosa täytyy olla \"{end}\"", + "error.validation.filename": "Anna kelpaava tiedostonimi", + "error.validation.in": "Anna joku seuraavista: ({in})", + "error.validation.integer": "Anna kelpaava kokonaisluku", + "error.validation.ip": "Anna kelpaava IP-osoite", + "error.validation.less": "Anna arvo joka on pienempi kuin {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Arvo ei vastaa vaadittua kaavaa", + "error.validation.max": "Anna arvo joka on enintään {max}", + "error.validation.maxlength": "Anna lyhyempi arvo. (enintään {max} merkkiä)", + "error.validation.maxwords": "Anna korkeintaan {max} sana(a)", + "error.validation.min": "Anna arvo joka on vähintään {min}", + "error.validation.minlength": "Anna pidempi arvo. (vähintään {min} merkkiä)", + "error.validation.minwords": "Anna vähintään {min} sana(a)", + "error.validation.more": "Anna suurempi arvo kuin {min}", + "error.validation.notcontains": "Anna arvo joka ei sisällä \"{needle}\"", + "error.validation.notin": "Arvo ei voi sisältää mitään seuraavista: ({notIn})", + "error.validation.option": "Valitse kelpaava vaihtoehto", + "error.validation.num": "Anna kelpaava numero", + "error.validation.required": "Arvo ei voi olla tyhjä", + "error.validation.same": "Anna \"{other}\"", + "error.validation.size": "Arvon koko täytyy olla \"{size}\"", + "error.validation.startswith": "Arvon alkuosa täytyy olla \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Anna kelpaava aika", + "error.validation.time.after": "Anna myöhempi aika kuin {time}", + "error.validation.time.before": "Anna aiempi aika kuin {time}", + "error.validation.time.between": "Anna aika väliltä {min} ja {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Anna kelpaava URL", + + "expand": "Laajenna", + "expand.all": "Laajenna kaikki", + + "field.invalid": "The field is invalid", + "field.required": "Kenttä on pakollinen", + "field.blocks.changeType": "Vaihda tyyppiä", + "field.blocks.code.name": "Koodi", + "field.blocks.code.language": "Kieli", + "field.blocks.code.placeholder": "Koodisi …", + "field.blocks.delete.confirm": "Haluatko varmasti poistaa tämän lohkon?", + "field.blocks.delete.confirm.all": "Haluatko varmasti poistaa kaikki lohkot?", + "field.blocks.delete.confirm.selected": "Haluatko varmasti poistaa valitut lohkot?", + "field.blocks.empty": "Ei lohkoja", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Valitse lohkon tyyppi …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galleria", + "field.blocks.gallery.images.empty": "Ei kuvia", + "field.blocks.gallery.images.label": "Kuvat", + "field.blocks.heading.level": "Taso", + "field.blocks.heading.name": "Otsikko", + "field.blocks.heading.text": "Teksti", + "field.blocks.heading.placeholder": "Otsikko …", + "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": "Vaihtoehtoinen teksti", + "field.blocks.image.caption": "Kuvateksti", + "field.blocks.image.crop": "Rajaa", + "field.blocks.image.link": "Linkki", + "field.blocks.image.location": "Sijainti", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Kuva", + "field.blocks.image.placeholder": "Valitse kuva", + "field.blocks.image.ratio": "Kuvasuhde", + "field.blocks.image.url": "Kuvan URL", + "field.blocks.line.name": "Rivi", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Teksti", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Lainaus", + "field.blocks.quote.text.label": "Teksti", + "field.blocks.quote.text.placeholder": "Lainaus …", + "field.blocks.quote.citation.label": "Sitaatti", + "field.blocks.quote.citation.placeholder": "Lähde …", + "field.blocks.text.name": "Teksti", + "field.blocks.text.placeholder": "Teksti …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Videon teksti", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Sijainti", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Anna videon URL", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "Videon 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": "Rivejä ei ole vielä lisätty", + + "field.files.empty": "Tiedostoja ei ole vielä valittu", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Poista asettelu", + "field.layout.delete.confirm": "Halutako varmasti poistaa tämän asettelun?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Ei rivejä", + "field.layout.select": "Valitse asettelu", + + "field.object.empty": "Ei vielä tietoja", + + "field.pages.empty": " Sivuja ei ole vielä valittu", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Haluatko varmasti poistaa tämän rivin?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Rivejä ei ole vielä lisätty", + + "field.users.empty": "Käyttäjiä ei ole vielä valittu", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "File", + "file.blueprint": "Tällä tiedostolla ei ole vielä suunnitelmaa. Voit määrittää suunnitelman tiedostoon /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Vaihda sivupohja", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Haluatko varmasti poistaa tiedoston
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Muuta järjestyspaikkaa", + + "files": "Tiedostot", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Tiedostoja ei ole vielä lisätty", + + "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": "Piilota", + "hour": "Tunti", + "hue": "Hue", + "import": "Tuo", + "info": "Tietoja", + "insert": "Lis\u00e4\u00e4", + "insert.after": "Lisää eteen", + "insert.before": "Lisää jälkeen", + "install": "Asenna", + + "installation": "Asennus", + "installation.completed": "Paneeli on asennettu", + "installation.disabled": "Paneelin asennus on oletuksena poissa käytöstä julkisilla palvelimilla. Aja asennus paikallisella koneella, tai ota paneeli käyttöön panel.install-optiolla.", + "installation.issues.accounts": "/site/accounts -kansio ei ole olemassa tai siihen ei voi kirjoittaa", + "installation.issues.content": "/content -kansio ei ole olemassa tai siihen ei voi kirjoittaa", + "installation.issues.curl": "CURL-laajennos on pakollinen", + "installation.issues.headline": "Paneelia ei voida asentaa", + "installation.issues.mbstring": "MB String-laajennos on pakollinen", + "installation.issues.media": "/media -kansio ei ole olemassa tai siihen ei voi kirjoittaa", + "installation.issues.php": "Varmista että PHP 8+ on käytössä", + "installation.issues.sessions": "/site/sessions -kansio ei ole olemassa tai siihen ei voi kirjoittaa", + + "language": "Kieli", + "language.code": "Tunniste", + "language.convert": "Muuta oletukseksi", + "language.convert.confirm": "

Haluatko varmasti muuttaa kielen {name} oletuskieleksi? Tätä muutosta ei voi peruuttaa.

Jos{name} sisältää kääntämättömiä kohtia, varakäännöstä ei enää ole näille kohdille ja sivustosi saattaa olla osittain tyhjä.

", + "language.create": "Lisää uusi kieli", + "language.default": "Oletuskieli", + "language.delete.confirm": "Haluatko varmasti poistaa kielen {name}, mukaanlukien kaikki käännökset? Tätä toimintoa ei voi peruuttaa!", + "language.deleted": "Kieli on poistettu", + "language.direction": "Lukusuunta", + "language.direction.ltr": "Vasemmalta oikealle", + "language.direction.rtl": "Oikealta vasemmalle", + "language.locale": "PHP-aluemäärityksen tunniste", + "language.locale.warning": "Käytät mukautettua aluemääritystä. Muokkaa sitä kielitiedostossa /site/languages", + "language.name": "Nimi", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Kieli on päivitetty", + "language.variables": "Language variables", + "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", + + "languages": "Kielet", + "languages.default": "Oletuskieli", + "languages.empty": "Kieliä ei ole vielä määritetty", + "languages.secondary": "Toissijaiset kielet", + "languages.secondary.empty": "Toissijaisia kieliä ei ole vielä määritetty", + + "license": "Lisenssi", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Osta lisenssi", + "license.code": "Tunniste", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Anna lisenssiavain", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Hallinnoi lisenssejäsi", + "license.purchased": "Purchased", + "license.success": "Kiitos kun tuet Kirbyä", + "license.unregistered.label": "Rekisteröimätön", + + "link": "Linkki", + "link.text": "Linkin teksti", + + "loading": "Ladataan", + + "lock.unsaved": "Tallentamattomia muutoksia", + "lock.unsaved.empty": "Ei enempää tallentamattomia muutoksia ", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Vapauta", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Kirjaudu", + "login.code.label.login": "Kirjautumiskoodi", + "login.code.label.password-reset": "Salasanan asetuskoodi", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Jos sähköpostiosoitteesi on rekisteröity, tilaamasi koodi lähetetään tähän osoitteeseen.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Kirjautumiskoodisi", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Salasanan asetuskoodisi", + "login.remember": "Pidä minut kirjautuneena", + "login.reset": "Aseta salasana", + "login.toggleText.code.email": "Kirjaudu sähköpostiosoitteella", + "login.toggleText.code.email-password": "Kirjaudu salasanalla", + "login.toggleText.password-reset.email": "Unohditko salasanasi?", + "login.toggleText.password-reset.email-password": "← Takaisin kirjautumiseen", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Kirjaudu ulos", + + "merge": "Merge", + "menu": "Valikko", + "meridiem": "am/pm", + "mime": "Median tyyppi", + "minutes": "Minuutit", + + "month": "Kuukausi", + "months.april": "Huhtikuu", + "months.august": "Elokuu", + "months.december": "Joulukuu", + "months.february": "Helmikuu", + "months.january": "Tammikuu", + "months.july": "Hein\u00e4kuu", + "months.june": "Kes\u00e4kuu", + "months.march": "Maaliskuu", + "months.may": "Toukokuu", + "months.november": "Marraskuu", + "months.october": "Lokakuu", + "months.september": "Syyskuu", + + "more": "Lisää", + "move": "Move", + "name": "Nimi", + "next": "Seuraava", + "night": "Night", + "no": "ei", + "off": "Pois käytöstä", + "on": "Käytössä", + "open": "Avaa", + "open.newWindow": "Avaa uudessa ikkunassa", + "option": "Option", + "options": "Asetukset", + "options.none": "Ei valintoja", + "options.all": "Show all {count} options", + + "orientation": "Suunta", + "orientation.landscape": "Vaakasuuntainen", + "orientation.portrait": "Pystysuuntainen", + "orientation.square": "Neliskulmainen", + + "page": "Page", + "page.blueprint": "Tällä sivulla ei ole vielä suunnitelmaa. Voit määrittää suunnitelman tiedostoon /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Vaihda URL-osoite", + "page.changeSlug.fromTitle": "Luo nimen perusteella", + "page.changeStatus": "Muuta tilaa", + "page.changeStatus.position": "Valitse järjestyspaikka", + "page.changeStatus.select": "Valitse uusi tila", + "page.changeTemplate": "Vaihda sivupohja", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Haluatko varmasti poistaa sivun {title}?", + "page.delete.confirm.subpages": "Tällä sivulla on alasivuja.
Myös kaikki alasivut poistetaan.", + "page.delete.confirm.title": "Anna vahvistuksena sivun nimi", + "page.duplicate.appendix": "Kopioi", + "page.duplicate.files": "Kopioi tiedostot", + "page.duplicate.pages": "Kopioi sivut", + "page.move": "Move page", + "page.sort": "Muuta järjestyspaikkaa", + "page.status": "Tila", + "page.status.draft": "Luonnos", + "page.status.draft.description": "Sivu on luonnostilassa ja näkyvissä vain kirjautuneille editoijille tai yksityisen linkin kautta", + "page.status.listed": "Julkinen", + "page.status.listed.description": "Sivu on julkinen kaikille", + "page.status.unlisted": "Listaamaton", + "page.status.unlisted.description": "Sivulle pääsee vain URL:n kautta", + + "pages": "Sivut", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Sivuja ei ole vielä lisätty", + "pages.status.draft": "Luonnokset", + "pages.status.listed": "Julkaistut", + "pages.status.unlisted": "Listaamaton", + + "pagination.page": "Sivu", + + "password": "Salasana", + "paste": "Liitä", + "paste.after": "Liitä jälkeen", + "paste.success": "{count} pasted!", + "pixel": "Pikseli", + "plugin": "Liitännäinen", + "plugins": "Liitännäiset", + "prev": "Edellinen", + "preview": "Esikatselu", + + "publish": "Publish", + "published": "Julkaistut", + + "remove": "Poista", + "rename": "Nimeä uudelleen", + "renew": "Renew", + "replace": "Korvaa", + "replace.with": "Replace with", + "retry": "Yrit\u00e4 uudelleen", + "revert": "Palauta", + "revert.confirm": "Haluatko varmasti poistaa kaikki tallentamattomat muutokset?", + + "role": "K\u00e4ytt\u00e4j\u00e4taso", + "role.admin.description": "Pääkäyttäjällä on kaikki oikeudet", + "role.admin.title": "Pääkäyttäjä", + "role.all": "Kaikki", + "role.empty": "Tällä käyttäjätasolla ei ole yhtään käyttäjää", + "role.description.placeholder": "Ei kuvausta", + "role.nobody.description": "Tämä on vararooli, jolla ei ole mitään oikeuksia", + "role.nobody.title": "Tuntematon", + + "save": "Tallenna", + "saved": "Saved", + "search": "Haku", + "searching": "Searching", + "search.min": "Anna vähintään {min} merkkiä hakua varten", + "search.all": "Show all {count} results", + "search.results.none": "Ei tuloksia", + + "section.invalid": "The section is invalid", + "section.required": "Osio on pakollinen", + + "security": "Tietoturva", + "select": "Valitse", + "server": "Palvelin", + "settings": "Asetukset", + "show": "Näytä", + "site.blueprint": "Tällä sivustolla ei ole vielä suunnitelmaa. Voit määrittää suunnitelman tiedostoon /site/blueprints/site.yml", + "size": "Koko", + "slug": "URL-tunniste", + "sort": "Järjestele", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "Ei raportteja", + "status": "Tila", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "Content-kansio näyttäisi olevan julkinen", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Virheenkäsittelytila pitää poistaa käytöstä tuotantoympäristössä", + "system.issues.git": ".git-kansio näyttäisi olevan julkinen", + "system.issues.https": "Suosittelemme HTTPS:n käyttöä kaikilla sivustoillasi", + "system.issues.kirby": "Kirby-kansio näyttäisi olevan julkinen", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "Site-kansio näyttäisi olevan julkinen", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Asennuksesi voi olla altis seuraaville haavoittuvuuksille ({ severity } vakavuus): { description }", + "system.issues.vulnerability.plugin": "Asennuksesi käyttämä liitännäinen { plugin } voi olla altis haavoittuvuudelle ({ severity } vakavuus): { description }", + "system.updateStatus": "Päivitysten tilanne", + "system.updateStatus.error": "Päivityksiä ei voitu tarkistaa", + "system.updateStatus.not-vulnerable": "Ei tunnettuja haavoittuvuuksia", + "system.updateStatus.security-update": "Ilmainen tietoturvapäivitys { version } saatavilla", + "system.updateStatus.security-upgrade": "Tietoturvakorjauksia sisältävä päivitys { version } saatavilla", + "system.updateStatus.unreleased": "Julkaisematon versio", + "system.updateStatus.up-to-date": "Ajan tasalla", + "system.updateStatus.update": "Ilmainen päivitys { version } saatavilla", + "system.updateStatus.upgrade": "Päivitys { version } saatavilla", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Sivupohja", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Nimi", + "today": "Tänään", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Koodi", + "toolbar.button.bold": "Lihavointi", + "toolbar.button.email": "S\u00e4hk\u00f6posti", + "toolbar.button.headings": "Otsikot", + "toolbar.button.heading.1": "Otsikko 1", + "toolbar.button.heading.2": "Otsikko 2", + "toolbar.button.heading.3": "Otsikko 3", + "toolbar.button.heading.4": "Otsikko 4", + "toolbar.button.heading.5": "Otsikko 5", + "toolbar.button.heading.6": "Otsikko 6", + "toolbar.button.italic": "Kursivointi", + "toolbar.button.file": "Tiedosto", + "toolbar.button.file.select": "Valitse tiedosto", + "toolbar.button.file.upload": "Lähetä tiedosto", + "toolbar.button.link": "Linkki", + "toolbar.button.paragraph": "Kappale", + "toolbar.button.strike": "Yliviivaus", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Järjestetty lista", + "toolbar.button.underline": "Alaviiva", + "toolbar.button.ul": "Järjestämätön lista", + + "translation.author": "Kirby-tiimi", + "translation.direction": "ltr", + "translation.name": "Suomi", + "translation.locale": "fi_FI", + + "type": "Type", + + "upload": "Lähetä", + "upload.error.cantMove": "Lähetettyä tiedostoa ei voitu siirtää", + "upload.error.cantWrite": "Tiedoston kirjoitus levylle epäonnistui", + "upload.error.default": "Tiedostoa ei voitu lähettää", + "upload.error.extension": "Tiedostoa ei lähetetty tiedostopäätteen takia", + "upload.error.formSize": "Lähetetyn tiedoston koko ylittää lomakkeen sallitun ylärajan MAX_FILE_SIZE", + "upload.error.iniPostSize": "Lähetetyn tiedoston koko ylittää sallitun ylärajan post_max_size asetustiedostossa php.ini", + "upload.error.iniSize": "Lähetetyn tiedoston koko ylittää sallitun ylärajan upload_max_filesize asetustiedostossa php.ini", + "upload.error.noFile": "Tiedostoa ei lähetetty", + "upload.error.noFiles": "Tiedostoja ei lähetetty", + "upload.error.partial": "Tiedoston lähetys onnistui vain osittain", + "upload.error.tmpDir": "Väliaikainen hakemisto puuttuu", + "upload.errors": "Virhe", + "upload.progress": "Lähetetään...", + + "url": "Url", + "url.placeholder": "https://esimerkki.fi", + + "user": "Käyttäjä", + "user.blueprint": "Voit määrittää lisää osioita ja lomakekenttiä tälle käyttäjälle suunnitelmassa /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Muuta sähköpostiosoite", + "user.changeLanguage": "Vaihda kieli", + "user.changeName": "Nimeä uudelleen", + "user.changePassword": "Vaihda salasana", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Uusi salasana", + "user.changePassword.new.confirm": "Vahvista uusi salasana...", + "user.changeRole": "Muuta käyttäjätasoa", + "user.changeRole.select": "Valitse uusi käyttäjätaso", + "user.create": "Lisää uusi käyttäjä", + "user.delete": "Poista tämä käyttäjä", + "user.delete.confirm": "Haluatko varmsti poistaa käyttäjän
{email}?", + + "users": "Käyttäjät", + + "version": "Versio", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Nykyinen versio ", + "version.latest": "Uusin versio ", + "versionInformation": "Version tiedot", + + "view": "View", + "view.account": "Oma käyttäjätili", + "view.installation": "Asennus", + "view.languages": "Kielet", + "view.resetPassword": "Aseta salasana", + "view.site": "Sivusto", + "view.system": "Järjestelmä", + "view.users": "K\u00e4ytt\u00e4j\u00e4t", + + "welcome": "Tervetuloa", + "year": "Vuosi", + "yes": "kyllä" +} diff --git a/public/kirby/i18n/translations/fr.json b/public/kirby/i18n/translations/fr.json new file mode 100644 index 0000000..a512c59 --- /dev/null +++ b/public/kirby/i18n/translations/fr.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Modifier votre nom", + "account.delete": "Supprimer votre compte", + "account.delete.confirm": "Voulez-vous vraiment supprimer votre compte ? Vous serez déconnecté immédiatement. Votre compte ne pourra pas être récupéré.", + + "activate": "Activer", + "add": "Ajouter", + "alpha": "Alpha", + "author": "Auteur", + "avatar": "Image de profil", + "back": "Retour", + "cancel": "Annuler", + "change": "Changer", + "close": "Fermer", + "changes": "Modifications", + "confirm": "Ok", + "collapse": "Replier", + "collapse.all": "Tout replier", + "color": "Couleur", + "coordinates": "Coordonnées", + "copy": "Copier", + "copy.all": "Tout copier", + "copy.success": "Copié", + "copy.success.multiple": "Copié : {count}", + "copy.url": "Copier l’URL", + "create": "Créer", + "custom": "Personnalisé", + + "date": "Date", + "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.", + + "debugging": "Débogage", + + "delete": "Supprimer", + "delete.all": "Tout supprimer", + + "dialog.fields.empty": "Ce dialogue ne comporte aucun champ", + "dialog.files.empty": "Aucun fichier à sélectionner", + "dialog.pages.empty": "Aucune page à sélectionner", + "dialog.text.empty": "Ce dialogue ne définit aucun texte", + "dialog.users.empty": "Aucun utilisateur à sélectionner", + + "dimensions": "Dimensions", + "disable": "Désactiver", + "disabled": "Désactivé", + "discard": "Supprimer", + + "drawer.fields.empty": "Ce tiroir ne comporte aucun champ", + + "domain": "Domaine", + "download": "Télécharger", + "duplicate": "Dupliquer", + + "edit": "Éditer", + + "email": "Courriel", + "email.placeholder": "mail@example.com", + + "enter": "Entrer", + "entries": "Entrées", + "entry": "Entrée", + + "environment": "Environnement", + + "error": "Erreur", + "error.access.code": "Code incorrect", + "error.access.login": "Identifiant incorrect", + "error.access.panel": "Vous n’êtes pas autorisé à accéder au Panel", + "error.access.view": "Vous n’êtes pas autorisé à accéder à cette section du Panel", + + "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.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.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} »", + + "error.cache.type.invalid": "Type de cache invalide « {type} »", + + "error.content.lock.delete": "Cette version est verrouillée et ne peut être supprimée", + "error.content.lock.move": "Cette version de la source est verrouillée et ne peut être supprimée", + "error.content.lock.publish": "Cette version est déjà publiée", + "error.content.lock.replace": "Cette version est verrouillée et ne peut être remplacée", + "error.content.lock.update": "Cette version est verrouillée et ne peut être mise à jour", + + "error.entries.max.plural": "Vous ne devez pas ajouter plus de {max} entrées", + "error.entries.max.singular": "Vous ne devez pas ajouter plus d’une entrée", + "error.entries.min.plural": "Vous devez ajouter au moins {min} entrées", + "error.entries.min.singular": "Vous devez ajouter au moins une entrée", + "error.entries.supports": "Le champ de type « {type} » n’est pas pris en charge par le champ entrées", + "error.entries.validation": "Il y a une erreur dans le champ « {field} » de la rangée {index}", + + "error.email.preset.notFound": "La configuration de courriel « {name} » n’a pu être trouvé ", + + "error.field.converter.invalid": "Convertisseur « {converter} » invalide", + "error.field.link.options": "Options invalides : {options}", + "error.field.type.missing": "Champ « { name } » : Le type de champ « { type } » n’existe pas", + + "error.file.changeName.empty": "Le nom ne peut être vide", + "error.file.changeName.permission": "Vous n’êtes pas autorisé à modifier le nom de « {filename} »", + "error.file.changeTemplate.invalid": "Le modèle du fichier « {id} » ne peut être modifié en « {template} » (valide : « {blueprints} »)", + "error.file.changeTemplate.permission": "Vous n’êtes pas autorisé à changer le modèle du fichier « {id} »", + + "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 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.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} »", + "error.file.mime.forbidden": "Le type de média « {mime} » n’est pas autorisé", + "error.file.mime.invalid": "Type de média invalide : {mime}", + "error.file.mime.missing": "Le type de média de « {filename} » n’a pu être détecté", + "error.file.minheight": "La hauteur de l’image doit être au moins {height} pixels", + "error.file.minsize": "Le fichier n’est pas assez volumineux", + "error.file.minwidth": "La largeur de l’image doit être au moins {width} pixels", + "error.file.name.unique": "Le nom de fichier doit être unique", + "error.file.name.missing": "Veuillez entrer un titre", + "error.file.notFound": "Le fichier « {filename} » n’a pu être trouvé", + "error.file.orientation": "L’orientation de l'image doit être « {orientation} »", + "error.file.sort.permission": "Vous n’êtes pas autorisé à modifier le tri de « {filename} »", + "error.file.type.forbidden": "Vous n’êtes pas autorisé à transférer des fichiers {type}", + "error.file.type.invalid": "Type de fichier invalide : {type}", + "error.file.undefined": "Le fichier n’a pu être trouvé", + + "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 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 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", + + "error.layout.validation.block": "Il y a une erreur sur le champ « {field} » du bloc {blockIndex} utilisant le type de bloc « {fieldset} » dans le layout {layoutIndex}.", + "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 valide", + "error.license.format": "Veuillez saisir un numéro de licence valide", + "error.license.verification": "La licence n’a pu être vérifiée", + + "error.login.totp.confirm.invalid": "Code invalide", + "error.login.totp.confirm.missing": "Veuillez saisir le code actuel", + + "error.object.validation": "Il y a une erreur dans le champ « {label} » :\n{message}", + + "error.offline": "Le Panel est actuellement hors ligne", + + "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 ê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é", + "error.page.changeTemplate.permission": "Vous n’êtes pas autorisé à changer le modèle de « {slug} »", + "error.page.changeTitle.empty": "Le titre ne peut être vide", + "error.page.changeTitle.permission": "Vous n’êtes pas autorisé à modifier le titre de « {slug} »", + "error.page.create.permission": "Vous n’êtes pas autorisé à créer « {slug} »", + "error.page.delete": "La page « {slug} » ne peut être supprimée", + "error.page.delete.confirm": "Veuillez saisir le titre de la page pour confirmer", + "error.page.delete.hasChildren": "La page comporte des sous-pages et ne peut pas être supprimée", + "error.page.delete.multiple": "Toutes les pages n’ont pu être supprimées. Essayez avec chaque page restante individuellement pour voir quelle erreur empêche sa suppression.", + "error.page.delete.permission": "Vous n’êtes pas autorisé à supprimer « {slug} »", + "error.page.draft.duplicate": "Un brouillon avec l’identifiant d’URL « {slug} » existe déjà", + "error.page.duplicate": "Une page avec l’identifiant d’URL « {slug} » existe déjà", + "error.page.duplicate.permission": "Vous n’êtes pas autorisé à dupliquer « {slug} »", + "error.page.move.ancestor": "La page ne peut être déplacée à l’intérieur d’elle-même", + "error.page.move.directory": "Le répertoire de la page ne peut être déplacé", + "error.page.move.duplicate": "Une sous-page possédant l’identifiant d’URL « {slug} » existe déjà", + "error.page.move.noSections": "La page « {parent} » ne peut être parente d'autres pages car elle ne comporte pas de section de pages dans son blueprint", + "error.page.move.notFound": "La page déplacée n’a pu être trouvée", + "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 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 valide", + "error.page.undefined": "La page n’a pu être trouvée", + "error.page.update.permission": "Vous n’êtes pas autorisé à modifier « {slug} »", + + "error.section.files.max.plural": "Vous ne pouvez ajouter plus de {max} fichier(s) à la section « {section} »", + "error.section.files.max.singular": "Vous ne pouvez ajouter plus d’un fichier à la section « {section} »", + "error.section.files.min.plural": "La section « {section} » requiert au moins {min} fichiers", + "error.section.files.min.singular": "La section « {section} » requiert au moins un fichier", + + "error.section.pages.max.plural": "Vous ne pouvez ajouter plus de {max} pages à la section « {section} »", + "error.section.pages.max.singular": "Vous ne pouvez ajouter plus d’une page à la section « {section} »", + "error.section.pages.min.plural": "La section « {section} » requiert au moins {min} pages", + "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 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", + "error.site.update.permission": "Vous n’êtes pas autorisé à modifier le contenu global du site", + + "error.structure.validation": "Il y a une erreur dans le champ « {field} » de la rangée {index}", + + "error.template.default.notFound": "Le modèle par défaut n’existe pas", + + "error.unexpected": "Une erreur inattendue est survenue ! Activez le mode de débogage pour plus d’informations : https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Vous n’êtes pas autorisé à modifier le courriel de l’utilisateur « {name} »", + "error.user.changeLanguage.permission": "Vous n’êtes pas autorisé à changer la langue de l’utilisateur « {name} »", + "error.user.changeName.permission": "Vous n’êtes pas autorisé à modifier le nom de l’utilisateur « {name} »", + "error.user.changePassword.permission": "Vous n’êtes pas autorisé à changer le mot de passe de l’utilisateur « {name} »", + "error.user.changeRole.lastAdmin": "Le rôle du dernier administrateur ne peut être modifié", + "error.user.changeRole.permission": "Vous n’êtes pas autorisé à changer le rôle de l’utilisateur « {name} »", + "error.user.changeRole.toAdmin": "Vous n’êtes pas autorisé à attribuer le rôle d’administrateur aux utilisateurs", + "error.user.create.permission": "Vous n’êtes pas autorisé à créer cet utilisateur", + "error.user.delete": "L’utilisateur « {name} » ne peut être supprimé", + "error.user.delete.lastAdmin": "Le dernier administrateur ne peut être supprimé", + "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 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 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 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 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 valide", + "error.validation.endswith": "La valeur doit se terminer par « {end} »", + "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 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", + "error.validation.max": "Veuillez saisir une valeur inférieure ou égale à {max}", + "error.validation.maxlength": "Veuillez saisir une valeur plus courte (max. {max} caractères)", + "error.validation.maxwords": "Veuillez ne pas saisir plus de {max} mot(s)", + "error.validation.min": "Veuillez saisir une valeur supérieure ou égale à {min}", + "error.validation.minlength": "Veuillez saisir une valeur plus longue (min. {min} caractères)", + "error.validation.minwords": "Veuillez saisir au moins {min} mot(s)", + "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 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 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 valide", + + "expand": "Déplier", + "expand.all": "Tout déplier", + + "field.invalid": "Le champ est invalide", + "field.required": "Le champ est obligatoire", + "field.blocks.changeType": "Changer le type", + "field.blocks.code.name": "Code", + "field.blocks.code.language": "Langue", + "field.blocks.code.placeholder": "Votre code…", + "field.blocks.delete.confirm": "Voulez-vous vraiment supprimer ce bloc ?", + "field.blocks.delete.confirm.all": "Voulez-vous vraiment supprimer tous les blocs ?", + "field.blocks.delete.confirm.selected": "Voulez-vous vraiment supprimer les blocs sélectionnés ?", + "field.blocks.empty": "Pas encore de blocs", + "field.blocks.fieldsets.empty": "Pas encore d‘ensembles de champs", + "field.blocks.fieldsets.label": "Veuillez sélectionner un type de bloc…", + "field.blocks.fieldsets.paste": "Pressez {{ shortcut }} pour importer des dispositions ou blocs depuis votre presse-papier Seuls ceux autorisés dans le champ actuel seront insérés.", + "field.blocks.gallery.name": "Galerie", + "field.blocks.gallery.images.empty": "Pas encore d’images", + "field.blocks.gallery.images.label": "Images", + "field.blocks.heading.level": "Niveau", + "field.blocks.heading.name": "Titre", + "field.blocks.heading.text": "Texte", + "field.blocks.heading.placeholder": "Titre…", + "field.blocks.figure.back.plain": "Brut", + "field.blocks.figure.back.pattern.light": "Motif (clair)", + "field.blocks.figure.back.pattern.dark": "Motif (sombre)", + "field.blocks.image.alt": "Texte alternatif", + "field.blocks.image.caption": "Légende", + "field.blocks.image.crop": "Recadrer", + "field.blocks.image.link": "Lien", + "field.blocks.image.location": "Emplacement", + "field.blocks.image.location.internal": "Ce site web", + "field.blocks.image.location.external": "Source externe", + "field.blocks.image.name": "Image", + "field.blocks.image.placeholder": "Sélectionnez une image", + "field.blocks.image.ratio": "Proportions", + "field.blocks.image.url": "URL de l’image", + "field.blocks.line.name": "Ligne", + "field.blocks.list.name": "Liste", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Texte", + "field.blocks.markdown.placeholder": "Markdown…", + "field.blocks.quote.name": "Citation", + "field.blocks.quote.text.label": "Texte", + "field.blocks.quote.text.placeholder": "Citation…", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "par…", + "field.blocks.text.name": "Texte", + "field.blocks.text.placeholder": "Texte…", + "field.blocks.video.autoplay": "Lecture automatique", + "field.blocks.video.caption": "Légende", + "field.blocks.video.controls": "Contrôles", + "field.blocks.video.location": "Emplacement", + "field.blocks.video.loop": "Boucle", + "field.blocks.video.muted": "Muet", + "field.blocks.video.name": "Vidéo", + "field.blocks.video.placeholder": "Saisissez l’URL d’une vidéo", + "field.blocks.video.poster": "Vignette", + "field.blocks.video.preload": "Préchargement", + "field.blocks.video.url.label": "URL de la vidéo", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Voulez-vous vraiment supprimer toutes les entrées ?", + "field.entries.empty": "Pas encore d’entrée", + + "field.files.empty": "Pas encore de fichier sélectionné", + "field.files.empty.single": "Pas encore de fichier sélectionné", + + "field.layout.change": "Changer de disposition", + "field.layout.delete": "Supprimer cette disposition", + "field.layout.delete.confirm": "Voulez-vous vraiment supprimer cette disposition ?", + "field.layout.delete.confirm.all": "Voulez-vous vraiment supprimer toutes les dispositions ?", + "field.layout.empty": "Pas encore de rangées", + "field.layout.select": "Choisir une disposition", + + "field.object.empty": "Pas encore d‘information", + + "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 ?", + "field.structure.delete.confirm.all": "Voulez-vous vraiment supprimer toutes les entrées ?", + "field.structure.empty": "Pas encore d’entrée", + + "field.users.empty": "Pas encore d’utilisateur sélectionné", + "field.users.empty.single": "Pas encore d’utilisateur sélectionné", + + "fields.empty": "Pas encore de champs", + + "file": "Fichier", + "file.blueprint": "Ce fichier n’a pas encore de blueprint. Vous pouvez en définir les paramètres dans /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Changer de modèle", + "file.changeTemplate.notice": "Modifier le modèle du fichier supprimera le contenu des champs dont le type ne correspond pas. Si le nouveau modèle définit certaines règles, par exemple les dimensions des images, celles-ci seront également appliquées de manière irréversible. Utilisez avec précaution.", + "file.delete.confirm": "Voulez-vous vraiment supprimer
{filename} ?", + "file.focus.placeholder": "Définir le point focal", + "file.focus.reset": "Supprimer le point focal", + "file.focus.title": "Point focal", + "file.sort": "Modifier la position", + + "files": "Fichiers", + "files.delete.confirm.selected": "Voulez-vous vraiment supprimer le fichier sélectionné ? Cette action ne peut être annulée.", + "files.empty": "Pas encore de fichier", + + "filter": "Filtrer", + + "form.discard": "Annuler les modifications", + "form.discard.confirm": "Voulez-vous vraiment annuler toutes les modifications ?", + "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", + + "hide": "Masquer", + "hour": "Heure", + "hue": "Teinte", + "import": "Importer", + "info": "Info", + "insert": "Insérer", + "insert.after": "Insérer après", + "insert.before": "Insérer avant", + "install": "Installer", + + "installation": "Installation", + "installation.completed": "Le Panel a été installé", + "installation.disabled": "L’installation du Panel est désactivée par défaut sur les serveurs publics. Veuillez lancer l’installation sur un serveur local, ou activez-la avec l’option panel.install.", + "installation.issues.accounts": "Le dossier /site/accounts n’existe pas ou n’est pas accessible en écriture", + "installation.issues.content": "Le dossier /content n’existe pas ou n’est pas accessible en écriture", + "installation.issues.curl": "L’extension CURL est requise", + "installation.issues.headline": "Le Panel ne peut être installé", + "installation.issues.mbstring": "L’extension MB String est requise", + "installation.issues.media": "Le dossier /media n’existe pas ou n’est pas accessible en écriture", + "installation.issues.php": "Veuillez utiliser PHP 8+", + "installation.issues.sessions": "Le dossier /site/sessions n’existe pas ou n’est pas accessible en écriture", + + "language": "Langue", + "language.code": "Code", + "language.convert": "Choisir comme langue par défaut", + "language.convert.confirm": "

Souhaitez-vous vraiment convertir {name} vers la langue par défaut ? Cette action ne peut pas être annulée.

Si {name} a un contenu non traduit, il n’y aura plus de solution de secours possible et certaines parties de votre site pourraient être vides.

", + "language.create": "Ajouter une nouvelle langue", + "language.default": "Langue par défaut", + "language.delete.confirm": "Voulez-vous vraiment supprimer la langue {name}, ainsi que toutes ses traductions ? Cette action ne peut être annulée !", + "language.deleted": "La langue a été supprimée", + "language.direction": "Sens de lecture", + "language.direction.ltr": "De gauche à droite", + "language.direction.rtl": "De droite à gauche", + "language.locale": "Locales PHP", + "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", + "language.updated": "La langue a été mise à jour", + "language.variables": "Variables de langue", + "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", + + "languages": "Langages", + "languages.default": "Langue par défaut", + "languages.empty": "Il n’y a pas encore de langues", + "languages.secondary": "Langues secondaires", + "languages.secondary.empty": "Il n’y a pas encore de langues secondaires", + + "license": "Licence", + "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.activated": "Activée", + "license.buy": "Acheter une licence", + "license.code": "Code", + "license.code.help": "Vous avez reçu votre code de licence par courriel après l‘achat. Veuillez le copier et le coller ici.", + "license.code.label": "Veuillez saisir votre numéro de licence", + "license.status.active.info": "Inclut les nouvelles versions majeures jusqu’au {date}", + "license.status.active.label": "Licence valide", + "license.status.demo.info": "Ceci est une installation de démonstration", + "license.status.demo.label": "Démonstration", + "license.status.inactive.info": "Renouveler la licence pour mettre à jour vers les nouvelles versions majeures", + "license.status.inactive.label": "Pas de nouvelles versions majeures", + "license.status.legacy.bubble": "Prêt à renouveler votre licence ?", + "license.status.legacy.info": "Votre licence ne couvre pas cette version", + "license.status.legacy.label": "Veuillez renouveler votre licence", + "license.status.missing.bubble": "Prêt à lancer votre site ?", + "license.status.missing.info": "Pas de licence valide", + "license.status.missing.label": "Veuillez activer votre licence", + "license.status.unknown.info": "Le statut de la licence est inconnu", + "license.status.unknown.label": "Inconnu", + "license.manage": "Gérer vos licences", + "license.purchased": "Achetée", + "license.success": "Merci pour votre soutien à Kirby", + "license.unregistered.label": "Non enregistré", + + "link": "Lien", + "link.text": "Texte du lien", + + "loading": "Chargement", + + "lock.unsaved": "Modifications non enregistrées", + "lock.unsaved.empty": "Il n’y a pas de modifications non enregistrées", + "lock.unsaved.files": "Fichiers non enregistrés", + "lock.unsaved.pages": "Pages non enregistrées", + "lock.unsaved.users": "Comptes non enregistrés", + "lock.isLocked": "Modifications non enregistrées par {email}", + "lock.unlock": "Déverrouiller", + "lock.unlock.submit": "Déverrouiller et écraser les modifications non enregistrées par {email}", + "lock.isUnlocked": "A été déverrouillé par un autre utilisateur", + + "login": "Connexion", + "login.code.label.login": "Code de connexion", + "login.code.label.password-reset": "Code de réinitialisation du mot de passe", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Si votre adresse de courriel est enregistrée, le code demandé vous sera envoyé par courriel.", + "login.code.text.totp": "Veuillez saisir le code à usage unique de votre application d‘authentification", + "login.email.login.body": "Bonjour {user.nameOrEmail},\n\nVous avez récemment demandé un code de connexion pour le Panel de {site}.\nLe code de connexion suivant sera valable pendant {timeout} minutes :\n\n{code}\n\nSi vous n’avez pas demandé de code de connexion, veuillez ignorer cet email ou contacter votre administrateur si vous avez des questions.\nPar sécurité, merci de ne PAS faire suivre cet email.", + "login.email.login.subject": "Votre code de connexion", + "login.email.password-reset.body": "Bonjour {user.nameOrEmail},\n\nVous avez récemment demandé un code de réinitialisation de mot de passe pour le Panel de {site}.\nLe code de réinitialisation de mot de passe suivant sera valable pendant {timeout} minutes :\n\n{code}\n\nSi vous n’avez pas demandé de code de réinitialisation de mot de passe, veuillez ignorer cet email ou contacter votre administrateur si vous avez des questions.\nPar sécurité, merci de ne PAS faire suivre ce courriel.", + "login.email.password-reset.subject": "Votre code de réinitialisation du mot de passe", + "login.remember": "Rester connecté", + "login.reset": "Réinitialiser", + "login.toggleText.code.email": "Se connecter par courriel", + "login.toggleText.code.email-password": "Se connecter avec un mot de passe", + "login.toggleText.password-reset.email": "Mot de passe oublié ?", + "login.toggleText.password-reset.email-password": "← Retour à la connexion", + "login.totp.enable.option": "Configurer les codes à usage unique", + "login.totp.enable.intro": "Les applications d’authentification peuvent générer des codes à usage unique qui sont utilisés comme second facteur lors de la connexion à votre compte.", + "login.totp.enable.qr.label": "1. Scannez ce QR code", + "login.totp.enable.qr.help": "Impossible de scanner ? Ajoutez la clé de configuration {secret} manuellement à votre application d’authentification..", + "login.totp.enable.confirm.headline": "2. Confirmez avec le code généré", + "login.totp.enable.confirm.text": "Votre application génère un nouveau code à usage unique toutes les 30 secondes. Saisissez le code actuel pour terminer la configuration :", + "login.totp.enable.confirm.label": "Code actuel", + "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.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", + + "logout": "Déconnexion", + + "merge": "Fusionner", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Type de médias", + "minutes": "Minutes", + + "month": "Mois", + "months.april": "Avril", + "months.august": "Août", + "months.december": "Décembre", + "months.february": "Février", + "months.january": "Janvier", + "months.july": "Juillet", + "months.june": "Juin", + "months.march": "Mars", + "months.may": "Mai", + "months.november": "Novembre", + "months.october": "Octobre", + "months.september": "Septembre", + + "more": "Plus", + "move": "Déplacer", + "name": "Nom", + "next": "Suivant", + "night": "Nuit", + "no": "non", + "off": "off", + "on": "on", + "open": "Ouvrir", + "open.newWindow": "Ouvrir dans une nouvelle fenêtre", + "option": "Option", + "options": "Options", + "options.none": "Pas d’options", + "options.all": "Afficher toutes les options de {count}", + + "orientation": "Orientation", + "orientation.landscape": "Paysage", + "orientation.portrait": "Portrait", + "orientation.square": "Carré", + + "page": "Page", + "page.blueprint": "Cette page n’a pas encore de blueprint. Vous pouvez en définir les paramètres dans /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Modifier l’URL", + "page.changeSlug.fromTitle": "Créer à partir du titre", + "page.changeStatus": "Changer le statut", + "page.changeStatus.position": "Veuillez sélectionner une position", + "page.changeStatus.select": "Sélectionner un nouveau statut", + "page.changeTemplate": "Changer de modèle", + "page.changeTemplate.notice": "Modifier le modèle de la page supprimera le contenu des champs dont le type ne correspond pas. Utilisez avec précaution.", + "page.create": "Créer en tant que {status}", + "page.delete.confirm": "Voulez-vous vraiment supprimer {title} ?", + "page.delete.confirm.subpages": "Cette page contient des sous-pages.
Toutes les sous-pages seront également supprimées.", + "page.delete.confirm.title": "Veuillez saisir le titre de la page pour confirmer", + "page.duplicate.appendix": "Copier", + "page.duplicate.files": "Copier les fichiers", + "page.duplicate.pages": "Copier les pages", + "page.move": "Déplacer la page", + "page.sort": "Modifier la position", + "page.status": "Statut", + "page.status.draft": "Brouillon", + "page.status.draft.description": "La page est accessible uniquement pour les éditeurs connectés ou via un lien secret", + "page.status.listed": "Public", + "page.status.listed.description": "La page est accessible par tout le monde", + "page.status.unlisted": "Non listé", + "page.status.unlisted.description": "La page est accessible uniquement par son URL", + + "pages": "Pages", + "pages.delete.confirm.selected": "Voulez-vous vraiment supprimer la page sélectionnée ? Cette action ne peut être annulée.", + "pages.empty": "Pas encore de pages", + "pages.status.draft": "Brouillons", + "pages.status.listed": "Publié", + "pages.status.unlisted": "Non listé", + + "pagination.page": "Page", + + "password": "Mot de passe", + "paste": "Coller", + "paste.after": "Coller après", + "paste.success": "Copié : {count}", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Précédent", + "preview": "Prévisualiser", + + "publish": "Publier", + "published": "Publié", + + "remove": "Supprimer", + "rename": "Renommer", + "renew": "Renouveler", + "replace": "Remplacer", + "replace.with": "Remplacer par", + "retry": "Essayer à nouveau", + "revert": "Revenir", + "revert.confirm": "Voulez-vous vraiment supprimer toutes les modifications non enregistrées ?", + + "role": "Rôle", + "role.admin.description": "L’administrateur dispose de tous les droits", + "role.admin.title": "Administrateur", + "role.all": "Tous", + "role.empty": "Il n’y a aucun utilisateur avec ce rôle", + "role.description.placeholder": "Pas de description", + "role.nobody.description": "Ceci est un rôle de secours sans aucune permission.", + "role.nobody.title": "Personne", + + "save": "Enregistrer", + "saved": "Enregistré", + "search": "Rechercher", + "searching": "Recherche en cours", + "search.min": "Saisissez {min} caractères pour rechercher", + "search.all": "Afficher tous les résultats de {count}", + "search.results.none": "Pas de résultats", + + "section.invalid": "La section est invalide", + "section.required": "Cette section est obligatoire", + + "security": "Sécurité", + "select": "Sélectionner", + "server": "Serveur", + "settings": "Paramètres", + "show": "Afficher", + "site.blueprint": "Ce site n’a pas encore de blueprint. Vous pouvez en définir les paramètres dans /site/blueprints/site.yml", + "size": "Poids", + "slug": "Identifiant de l’URL", + "sort": "Trier", + "sort.drag": "Déplacer pour réordonner…", + "split": "Diviser", + + "stats.empty": "Aucun rapport", + "status": "Statut", + + "system.info.copy": "Copier les informations", + "system.info.copied": "Informations système copiées", + "system.issues.content": "Le dossier content semble exposé", + "system.issues.eol.kirby": "La version de Kirby installée a atteint la fin de son cycle de vie et ne recevra plus de mises à jour de sécurité", + "system.issues.eol.plugin": "La version du plugin { plugin } installée a atteint la fin de son cycle de vie et ne recevra plus de mises à jour de sécurité", + "system.issues.eol.php": "Votre version de PHP installée { release } a atteint la fin de son cycle de vie et ne recevra plus de mises à jour de sécurité", + "system.issues.debug": "Le débogage doit être désactivé en production", + "system.issues.git": "Le dossier .git semble exposé", + "system.issues.https": "Nous recommandons HTTPS pour tous vos sites", + "system.issues.kirby": "Le dossier kirby semble exposé", + "system.issues.local": "Le site fonctionne localement avec des contrôles de sécurité allégés.", + "system.issues.site": "Le dossier site semble exposé", + "system.issues.vue.compiler": "Le compileur de templates de Vue est activé", + "system.issues.vulnerability.kirby": "Votre installation pourrait être affectée par la vulnérabilité suivante ({ severity } gravité) : { description }", + "system.issues.vulnerability.plugin": "Votre installation pourrait être affectée par la vulnérabilité suivante du plugin { plugin } ({ severity } gravité) : { description }", + "system.updateStatus": "Statut des mises à jour", + "system.updateStatus.error": "Les mises à jour n’ont pu être vérifiées", + "system.updateStatus.not-vulnerable": "Aucune vulnérabilité connue", + "system.updateStatus.security-update": "Mise à jour gratuite { version } disponible", + "system.updateStatus.security-upgrade": "Mise à jour { version } avec correctifs de sécurité disponible", + "system.updateStatus.unreleased": "Version non diffusée", + "system.updateStatus.up-to-date": "À jour", + "system.updateStatus.update": "Mise à jour gratuite { version } disponible", + "system.updateStatus.upgrade": "Mise à jour { version } disponible", + + "tel": "Téléphone", + "tel.placeholder": "+3312345678", + "template": "Modèle", + + "theme": "Thème", + "theme.light": "Clair", + "theme.dark": "Sombre", + "theme.automatic": "Suivre le réglage système", + + "title": "Titre", + "today": "Aujourd’hui", + + "toolbar.button.clear": "Supprimer la mise en forme", + "toolbar.button.code": "Code", + "toolbar.button.bold": "Gras", + "toolbar.button.email": "Courriel", + "toolbar.button.headings": "Titres", + "toolbar.button.heading.1": "Titre 1", + "toolbar.button.heading.2": "Titre 2", + "toolbar.button.heading.3": "Titre 3", + "toolbar.button.heading.4": "Titre 4", + "toolbar.button.heading.5": "Titre 5", + "toolbar.button.heading.6": "Titre 6", + "toolbar.button.italic": "Italique", + "toolbar.button.file": "Fichier", + "toolbar.button.file.select": "Sélectionner un fichier", + "toolbar.button.file.upload": "Transférer un fichier", + "toolbar.button.link": "Lien", + "toolbar.button.paragraph": "Paragraphe", + "toolbar.button.strike": "Barré", + "toolbar.button.sub": "Indice", + "toolbar.button.sup": "Exposant", + "toolbar.button.ol": "Liste ordonnée", + "toolbar.button.underline": "Souligné", + "toolbar.button.ul": "Liste non-ordonnée", + + "translation.author": "Kirby Team", + "translation.direction": "ltr", + "translation.name": "Français", + "translation.locale": "fr_FR", + + "type": "Type", + + "upload": "Transférer", + "upload.error.cantMove": "Le fichier transféré n’a pu être déplacé", + "upload.error.cantWrite": "Le fichier n’a pu être écrit sur le disque", + "upload.error.default": "Le fichier n’a pu être transféré", + "upload.error.extension": "Le transfert de fichier a été stoppé par une extension", + "upload.error.formSize": "Le fichier transféré excède la directive MAX_FILE_SIZE spécifiée dans le formulaire", + "upload.error.iniPostSize": "Le fichier transféré excède la directive post_max_size spécifiée dans php.ini", + "upload.error.iniSize": "Le fichier transféré excède la directive upload_max_filesize spécifiée dans php.ini", + "upload.error.noFile": "Aucun fichier n’a été transféré", + "upload.error.noFiles": "Aucun fichier n’a été transféré", + "upload.error.partial": "Le fichier n’a été que partiellement transféré", + "upload.error.tmpDir": "Un dossier temporaire est manquant", + "upload.errors": "Erreur", + "upload.progress": "Transfert en cours…", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Utilisateur", + "user.blueprint": "Vous pouvez définir de nouvelles sections et champs de formulaires pour ce rôle d’utilisateur dans /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Modifier le courriel", + "user.changeLanguage": "Modifier la langue", + "user.changeName": "Renommer cet utilisateur", + "user.changePassword": "Modifier le mot de passe", + "user.changePassword.current": "Votre mot de passe actuel", + "user.changePassword.new": "Nouveau mot de passe", + "user.changePassword.new.confirm": "Confirmer le nouveau mot de passe…", + "user.changeRole": "Modifier le rôle", + "user.changeRole.select": "Sélectionner un nouveau rôle", + "user.create": "Ajouter un nouvel utilisateur", + "user.delete": "Supprimer cet utilisateur", + "user.delete.confirm": "Voulez-vous vraiment supprimer
{email} ?", + + "users": "Utilisateurs", + + "version": "Version", + "version.changes": "Version modifiée", + "version.compare": "Comparer les versions", + "version.current": "Version actuelle", + "version.latest": "Dernière version", + "versionInformation": "Informations de version", + + "view": "Visualiser", + "view.account": "Votre compte", + "view.installation": "Installation", + "view.languages": "Langues", + "view.resetPassword": "Réinitialiser le mot de passe", + "view.site": "Site", + "view.system": "Système", + "view.users": "Utilisateurs", + + "welcome": "Bienvenue", + "year": "Année", + "yes": "oui" +} diff --git a/public/kirby/i18n/translations/hu.json b/public/kirby/i18n/translations/hu.json new file mode 100644 index 0000000..442122e --- /dev/null +++ b/public/kirby/i18n/translations/hu.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Név megváltoztatása", + "account.delete": "Fiók törlése", + "account.delete.confirm": "Tényleg törölni szeretnéd a fiókodat? Azonnal kijelentkeztetünk és ez a folyamat visszavonhatatlan.", + + "activate": "Activate", + "add": "Hozz\u00e1ad", + "alpha": "Alpha", + "author": "Szerző", + "avatar": "Profilkép", + "back": "Vissza", + "cancel": "M\u00e9gsem", + "change": "M\u00f3dos\u00edt\u00e1s", + "close": "Bez\u00e1r", + "changes": "Changes", + "confirm": "Mentés", + "collapse": "Bezárás", + "collapse.all": "Összes bezárása", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Másol", + "copy.all": "Összes másolása", + "copy.success": "Copied", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Létrehoz", + "custom": "Custom", + + "date": "Dátum", + "date.select": "Dátum kiválasztása", + + "day": "Nap", + "days.fri": "p\u00e9", + "days.mon": "h\u00e9", + "days.sat": "szo", + "days.sun": "va", + "days.thu": "cs\u00fc", + "days.tue": "ke", + "days.wed": "sze", + + "debugging": "Hibakeresés", + + "delete": "T\u00f6rl\u00e9s", + "delete.all": "Összes törlése", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "Nincsenek fájlok kiválasztva", + "dialog.pages.empty": "Nincsenek oldalak kiválasztva", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Nincsenek felhasználók kiválasztva", + + "dimensions": "Méretek", + "disable": "Disable", + "disabled": "Inaktív", + "discard": "Visszavon\u00e1s", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Letöltés", + "duplicate": "Másolat", + + "edit": "Aloldal szerkeszt\u00e9se", + + "email": "Email", + "email.placeholder": "mail@pelda.hu", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Környezet", + + "error": "Error", + "error.access.code": "Érvénytelen kód", + "error.access.login": "Érvénytelen bejelentkezés", + "error.access.panel": "Nincs jogosultságod megnyitni a panelt", + "error.access.view": "Nincs hozzáférésed a panel ezen részéhez", + + "error.avatar.create.fail": "A profilkép feltöltése nem sikerült", + "error.avatar.delete.fail": "A profilkép nem törölhető", + "error.avatar.dimensions.invalid": "A profilkép maximális szélessége és magassága 3000 pixel lehet", + "error.avatar.mime.forbidden": "A profilkép formátuma csak JPEG vagy PNG lehet", + + "error.blueprint.notFound": "A \"{name}\" oldalsablon nem tölthető be", + + "error.blocks.max.plural": "Legfeljebb {max} blokk adható hozzá", + "error.blocks.max.singular": "Csak egyetlen blokk adható hozzá", + "error.blocks.min.plural": "Legalább {min} blokkot hozzá kell adnod", + "error.blocks.min.singular": "Legalább egy blokkot hozzá kell adnod", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "A \"{name}\" email-beállítás nem található", + + "error.field.converter.invalid": "Érvénytelen konverter: \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "A név nem lehet üres", + "error.file.changeName.permission": "Nincs jogosultságod megváltoztatni a \"{filename}\" fájl nevét", + "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": "Már létezik \"{filename}\" nevű fájl", + "error.file.extension.forbidden": "Tiltott kiterjeszt\u00e9s\u0171 f\u00e1jl", + "error.file.extension.invalid": "Érvénytelen kiterjesztés: {extension}", + "error.file.extension.missing": "Kiterjeszt\u00e9s n\u00e9lk\u00fcli f\u00e1jl nem t\u00f6lthet\u0151 fel", + "error.file.maxheight": "A kép nem lehet magasabb {height} pixelnél", + "error.file.maxsize": "A fájl túl nagy", + "error.file.maxwidth": "A kép nem lehet szélesebb {width} pixelnél", + "error.file.mime.differs": "A feltöltött fájlnak azonos \"{mime}\" típusúnak kell lennie", + "error.file.mime.forbidden": "A \"{mime}\" típusú médiafájlok nem engedélyezettek", + "error.file.mime.invalid": "Érvénytelen mime-típus: {mime}", + "error.file.mime.missing": "A \"{filename}\" fájl típusa nem állapítható meg", + "error.file.minheight": "A képnek legalább {height} pixel magasnak kell lennie", + "error.file.minsize": "A fájl túl kicsi", + "error.file.minwidth": "A képnek legalább {width} pixel szélesnek kell lennie", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "A fálj neve nem lehet üres", + "error.file.notFound": "A \"{filename}\" fájl nem található", + "error.file.orientation": "A képnek \"{orientation}\" tájolásúnak kell lennie", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Nem tölthetsz fel \"{type}\" típusú fájlokat", + "error.file.type.invalid": "Érvénytelen fájltípus: {type}", + "error.file.undefined": "A f\u00e1jl nem tal\u00e1lhat\u00f3", + + "error.form.incomplete": "Kérlek javítsd ki az összes hibát az űrlapon", + "error.form.notSaved": "Az űrlap nem menthető", + + "error.language.code": "Kérlek, add meg a nyelv érvényes kódját", + "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": "A nyelv már létezik", + "error.language.name": "Kérlek, add meg a nyelv érvényes nevét", + "error.language.notFound": "A nyelv nem található", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "Hibát találtunk a(z) {index} elrendezés beállításaiban", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Kérlek adj meg egy valós email-címet", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "A licensz nem ellenőrizhető", + + "error.login.totp.confirm.invalid": "Érvénytelen kód", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "A Panel jelenleg nem elérhető", + + "error.page.changeSlug.permission": "Nem változtathatod meg az URL-előtagot: \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "Az oldal hibákat tartalmaz és nem publikálható", + "error.page.changeStatus.permission": "Az oldal státusza nem változtatható meg", + "error.page.changeStatus.toDraft.invalid": "A(z) \"{slug}\" oldalt nem lehet piszkozattá alakítani", + "error.page.changeTemplate.invalid": "A \"{slug}\" oldal sablonját nem lehet megváltoztatni", + "error.page.changeTemplate.permission": "Nincs jogosultságod megváltoztatni a sablont ehhez: \"{slug}\"", + "error.page.changeTitle.empty": "A cím nem lehet üres", + "error.page.changeTitle.permission": "Nincs jogosultságod megváltoztatni a címet: \"{slug}\"", + "error.page.create.permission": "Nincs jogosultságod az oldal létrehozásához: \"{slug}\"", + "error.page.delete": "A(z) \"{slug}\" oldal nem törölhető", + "error.page.delete.confirm": "Megerősítéshez add meg az oldal címét", + "error.page.delete.hasChildren": "Az oldalnak vannak aloldalai és nem törölhető", + "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": "Nincs jogosultságod a(z) \"{slug}\" oldal törléséhez", + "error.page.draft.duplicate": "Van már egy másik oldal ezzel az URL-lel: \"{slug}\"", + "error.page.duplicate": "Van már egy másik oldal ezzel az URL-lel: \"{slug}\"", + "error.page.duplicate.permission": "Nincs engedélyed a(z) \"{slug}\" másolat keszítéséhez", + "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.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}\"", + "error.page.notFound": "Az oldal nem tal\u00e1lhat\u00f3", + "error.page.num.invalid": "Kérlek megfelelő oldalszámozást adj meg. Negatív szám itt nem használható.", + "error.page.slug.invalid": "Kérlek érvényes URL-kiterjesztést adj meg", + "error.page.slug.maxlength": "Az URL maximum \"{length}\" karakter hosszúságú lehet", + "error.page.sort.permission": "A(z) \"{slug}\" oldal nem illeszthető a sorrendbe", + "error.page.status.invalid": "Kérlek add meg a megfelelő oldalstátuszt", + "error.page.undefined": "Az oldal nem tal\u00e1lhat\u00f3", + "error.page.update.permission": "Nincs jogosultságod a(z) \"{slug}\" oldal frissítéséhez", + + "error.section.files.max.plural": "Maximum {max} fájlt adhatsz hozzá a(z) \"{section}\" szekcióhoz", + "error.section.files.max.singular": "Nem adhatsz hozzá egynél több fájlt a(z) \"{section}\" szekcióhoz", + "error.section.files.min.plural": "A \"{section}\" szakasz legalább {min} fájlt igényel", + "error.section.files.min.singular": "A \"{section}\" szakasz legalább egy fájlt igényel", + + "error.section.pages.max.plural": "Maximum {max} oldalt adhatsz hozzá a(z) \"{section}\" szekcióhoz", + "error.section.pages.max.singular": "Nem adhatsz hozzá egynél több oldalt a(z) \"{section}\" szekcióhoz", + "error.section.pages.min.plural": "A \"{section}\" szakasz legalább {min} oldalt igényel", + "error.section.pages.min.singular": "A \"{section}\" szakasz legalább egy oldalt igényel", + + "error.section.notLoaded": "A(z) \"{name}\" szekció nem tölthető be", + "error.section.type.invalid": "A szekció típusa (\"{type}\") nem megfelelő", + + "error.site.changeTitle.empty": "A cím nem lehet üres", + "error.site.changeTitle.permission": "Nincs jogosultságod megváltoztatni az honlap címét", + "error.site.update.permission": "Nincs jogosultságod frissíteni a honlapot", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Az alapértelmezett sablon nem létezik", + + "error.unexpected": "Váratlan hiba történt! További információért engedélyezd a hibakeresés módot: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Nincs jogosultságod megváltoztatni \"{name}\" felhasználó email-címét", + "error.user.changeLanguage.permission": "Nincs jogosultságod megváltoztatni \"{name}\" felhasználó nyelvi beállításait", + "error.user.changeName.permission": "Nincs jogosultságod megváltoztatni \"{name}\" felhasználó nevét", + "error.user.changePassword.permission": "Nincs jogosultságod megváltoztatni \"{name}\" felhasználó jelszavát", + "error.user.changeRole.lastAdmin": "Az egyedüli adminisztrátor szerepkörét nem lehet megváltoztatni", + "error.user.changeRole.permission": "Nincs jogosultságod megváltoztatni \"{name}\" felhasználó szerepkörét", + "error.user.changeRole.toAdmin": "Nincs jogosultságod előléptetni a felhasználót adminisztrátorrá", + "error.user.create.permission": "Nincs jogosultságod létrehozni ezt a felhasználót", + "error.user.delete": "A felhaszn\u00e1l\u00f3 nem t\u00f6r\u00f6lhet\u0151", + "error.user.delete.lastAdmin": "Nem t\u00f6r\u00f6lheted az egyetlen adminisztr\u00e1tort", + "error.user.delete.lastUser": "Nem törölheted az egyetlen felhasználót", + "error.user.delete.permission": "Nincs jogosults\u00e1god t\u00f6r\u00f6lni ezt a felhaszn\u00e1l\u00f3t", + "error.user.duplicate": "Már létezik felhasználó \"{email}\" email-címmel", + "error.user.email.invalid": "Kérlek adj meg egy valós email-címet", + "error.user.language.invalid": "Kérlek add meg a megfelelő nyelvi beállítást", + "error.user.notFound": "A felhaszn\u00e1l\u00f3 nem tal\u00e1lhat\u00f3", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Kérlek adj meg egy megfelelő jelszót. A jelszónak legalább 8 karakter hosszúságúnak kell lennie.", + "error.user.password.notSame": "K\u00e9rlek er\u0151s\u00edtsd meg a jelsz\u00f3t", + "error.user.password.undefined": "A felhasználónak nincs jelszó megadva", + "error.user.password.wrong": "Hibás jelszó", + "error.user.role.invalid": "Kérlek adj meg egy megfelelő szerepkört", + "error.user.undefined": "A felhasználó nem található", + "error.user.update.permission": "Nincs jogosultságod frissíteni \"{name}\" felhasználó adatait", + + "error.validation.accepted": "Kérlek erősítsd meg", + "error.validation.alpha": "Kérlek csak kis betűket használj (a-z)", + "error.validation.alphanum": "Kérlek csak kis betűket és számjegyeket használj (a-z, 0-9)", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Kérlek egy \"{min}\" és \"{max}\" közötti értéket adj meg", + "error.validation.boolean": "Kérlek erősítsd meg vagy vesd el", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Kérlek olyan értéket adj meg, amely tartalmazza ezt: \"{needle}\"", + "error.validation.date": "Kérlek megfelelő dátumot adj meg", + "error.validation.date.after": "Kérlek olyan dátumot adj meg, amely későbbi ennél: {date}", + "error.validation.date.before": "Kérlek olyan dátumot adj meg, amely korábbi ennél: {date}", + "error.validation.date.between": "Kérlek {min} és {max} közötti dátumot adj meg", + "error.validation.denied": "Kérlek vesd el", + "error.validation.different": "Az érték nem lehet \"{other}\"", + "error.validation.email": "Kérlek adj meg egy valós email-címet", + "error.validation.endswith": "Az értéknek erre kell végződnie: \"{end}\"", + "error.validation.filename": "Kérlek megfelelő fájlnevet adj meg", + "error.validation.in": "Kérlek adj meg egyet az alábbiak közül: ({in})", + "error.validation.integer": "Kérlek valós számot adj meg", + "error.validation.ip": "Kérlek megfelelő IP-címet adj meg", + "error.validation.less": "A megadott érték kevesebb legyen, mint {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "A megadott érték nem felel meg az elvárt struktúrának", + "error.validation.max": "A megadott érték egyenlő vagy kevesebb legyen, mint {max}", + "error.validation.maxlength": "Kérlek rövidebb értéket adj meg (legfeljebb {max} karakter)", + "error.validation.maxwords": "Kérlek ide legfeljebb {max} szót írj", + "error.validation.min": "A megadott érték egyenlő vagy nagyobb legyen, mint {min}", + "error.validation.minlength": "Kérlek hosszabb értéket adj meg (legalább {min} karakter)", + "error.validation.minwords": "Kérlek ide legalább {min} szót írj", + "error.validation.more": "A megadott érték legyen nagyobb, mint {min} ", + "error.validation.notcontains": "Kérlek olyan értéket adj meg, amely nem tartalmazza ezt: \"{needle}\" ", + "error.validation.notin": "Kérlek egyiket se használd az alábbiak közül: ({notIn})", + "error.validation.option": "Kérlek válassz egy megfelelő opciót", + "error.validation.num": "Kérlek adj meg egy megfelelő számot", + "error.validation.required": "Kérlek írj be valamit", + "error.validation.same": "Kérlek írd be: \"{other}\"", + "error.validation.size": "Az értéknek az alábbi méretűnek kell lennie: \"{size}\"", + "error.validation.startswith": "Az értéknek ezzel kell kezdődnie: \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Kérlek megfelelő időt adj meg", + "error.validation.time.after": "Kérlek olyan időpontot adj meg, amely későbbi ennél: {time}", + "error.validation.time.before": "Kérlek olyan időpontot adj meg, amely korábbi ennél: {time}", + "error.validation.time.between": "Kérlek {min} és {max} közötti időpontot adj meg", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Kérlek megfelelő URL-t adj meg", + + "expand": "Kinyitás", + "expand.all": "Összes kinyitása", + + "field.invalid": "The field is invalid", + "field.required": "Kötelező mező", + "field.blocks.changeType": "Típus megváltoztatása", + "field.blocks.code.name": "Kód", + "field.blocks.code.language": "Nyelv", + "field.blocks.code.placeholder": "A megjelenítendő kód …", + "field.blocks.delete.confirm": "Tényleg törölni szeretnéd ezt a blokkot?", + "field.blocks.delete.confirm.all": "Tényleg minden blokkot törölni szeretnél?", + "field.blocks.delete.confirm.selected": "Tényleg törölni szeretnéd a kijelölt blokkokat?", + "field.blocks.empty": "Még nincsenek blokkok", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Kérlek válassz blokktípust …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galéria", + "field.blocks.gallery.images.empty": "Még nincsenek képek", + "field.blocks.gallery.images.label": "Képek", + "field.blocks.heading.level": "Szint", + "field.blocks.heading.name": "Címsor", + "field.blocks.heading.text": "Szöveg", + "field.blocks.heading.placeholder": "Címsor …", + "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": "Alternatív szöveg", + "field.blocks.image.caption": "Képaláírás", + "field.blocks.image.crop": "Körülvágás", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "A kép helye", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Kép", + "field.blocks.image.placeholder": "Kép kiválasztása", + "field.blocks.image.ratio": "Képarány", + "field.blocks.image.url": "Kép URL-je", + "field.blocks.line.name": "Vonal", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Szöveg", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Idézet", + "field.blocks.quote.text.label": "Szöveg", + "field.blocks.quote.text.placeholder": "Idézet szövege …", + "field.blocks.quote.citation.label": "Idézet szerzője", + "field.blocks.quote.citation.placeholder": "Szerző …", + "field.blocks.text.name": "Szöveg", + "field.blocks.text.placeholder": "Szöveg …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Képaláírás", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "A kép helye", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Videó", + "field.blocks.video.placeholder": "Videó URL-jének megadása", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "Videó 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": "Nincs még bejegyzés", + + "field.files.empty": "Nincs fálj kiválasztva", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Elrendezés törlése", + "field.layout.delete.confirm": "Tényleg törölni szeretnéd ezt az elrendezést?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Még nincsenek sorok", + "field.layout.select": "Válassz elrendezést", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Nincs oldal kiválasztva", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Biztos t\u00f6r\u00f6lni szeretn\u00e9d ezt a bejegyz\u00e9st?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Nincs m\u00e9g bejegyz\u00e9s", + + "field.users.empty": "Nincs felhasználó kiválasztva", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Fájl", + "file.blueprint": "Ehhez a fájlhoz még nem tartozik oldalsablon. Itt hozhatod létre: /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Sablon módosítása", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Biztos törölni akarod ezt a fájlt:
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Sorrend megváltoztatása", + + "files": "Fájlok", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Még nincsenek fájlok", + + "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": "Elrejtés", + "hour": "Óra", + "hue": "Hue", + "import": "Importálás", + "info": "Info", + "insert": "Beilleszt", + "insert.after": "Beszúrás mögé", + "insert.before": "Beszúrás elé", + "install": "Telepítés", + + "installation": "Telepítés", + "installation.completed": "A panel sikeresen telepítve", + "installation.disabled": "A panel telepítője alapértelmezés szerint le van tiltva a nyilvános szervereken. Kérlek, futtassd a telepítőt egy helyi gépen vagy engedélyezze a panel.install opcióval.", + "installation.issues.accounts": "A /site/accounts mappa nem létezik, vagy nem írható", + "installation.issues.content": "A /content mappa nem létezik vagy nem írható", + "installation.issues.curl": "A CURL bővítmény engedélyezése szükséges", + "installation.issues.headline": "A panel telepítése sikertelen", + "installation.issues.mbstring": "Az MB String bővítmény engedélyezése szükséges", + "installation.issues.media": "A /media mappa nem létezik vagy nem írható", + "installation.issues.php": "Bizonyosodj meg róla, hogy az általad használt PHP-verzió PHP 8+", + "installation.issues.sessions": "A /site/sessions könyvtár nem létezik vagy nem írható", + + "language": "Nyelv", + "language.code": "Kód", + "language.convert": "Alapértelmezettnek jelölés", + "language.convert.confirm": "

Tényleg az alaőértelmezett nyelvre szeretnéd konvertálni ezt: {name}? Ez a művelet nem vonható vissza.

Ha{name} olyat is tartalmaz, amelynek nincs megfelelő fordítása, a honlapod egyes részei az új alapértelmezett nyelv hiányosságai miatt üresek maradhatnak.

", + "language.create": "Új nyelv hozzáadása", + "language.default": "Alapértelmezett nyelv", + "language.delete.confirm": "Tényleg törölni szeretnéd a(z) {name} nyelvet, annak minden fordításával együtt? Ez a művelet nem vonható vissza!", + "language.deleted": "A nyelv törölve lett", + "language.direction": "Olvasási irány", + "language.direction.ltr": "Balról jobbra", + "language.direction.rtl": "Jobbról balra", + "language.locale": "PHP locale sztring", + "language.locale.warning": "Egyedi nyelvi készletet használsz. Kérlek módosítsd a nyelvhez tartozó fájlt az alábbi mappában: /site/languages", + "language.name": "Név", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "A nyelv frissítve lett", + "language.variables": "Language variables", + "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", + + "languages": "Nyelvek", + "languages.default": "Alapértelmezett nyelv", + "languages.empty": "Nincsnek még nyelvek", + "languages.secondary": "Másodlagos nyelvek", + "languages.secondary.empty": "Nincsnek még másodlagos nyelvek", + + "license": "Kirby licenc", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Licenc vásárlása", + "license.code": "Kód", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Kérlek írd be a licenc-kódot", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Köszönjük, hogy támogatod a Kirby-t", + "license.unregistered.label": "Unregistered", + + "link": "Link", + "link.text": "Link szövege", + + "loading": "Betöltés", + + "lock.unsaved": "Nem mentett változások", + "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": "Kinyit", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Bejelentkezés", + "login.code.label.login": "Bejelentkezéshez szükséges kód", + "login.code.label.password-reset": "Jelszóvisszaállításhoz szükséges kód", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Amennyiben az email-címed létezik a rendszerben, a kódot oda küldjük el.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Helló {user.nameOrEmail},\n\nNemrégiben bejelentkezési kódot igényeltél a(z) {site} Paneljéhez.\nAz alábbi kód {timeout} percig lesz érvényes:\n\n{code}\n\nHa nem te igényelted a kódot, kérlek hagyd figyelmen kívül ezt az emailt, kérdések esetén pedig vedd fel a kapcsolatot az oldal Adminisztrátorával.\nBiztonsági okokból kérjük NE továbbítsd ezt az emailt.", + "login.email.login.subject": "Bejelentkezési kódod", + "login.email.password-reset.body": "Helló {user.nameOrEmail},\n\nNemrégiben jelszóvisszaállítási kódot igényeltél a(z) {site} Paneljéhez.\nAz alábbi jelszóvisszaállítási kód {timeout} percig lesz érvényes:\n\n{code}\n\nHa nem te igényelted a jelszóvisszaállítási kódot, kérlek hagyd figyelmen kívül ezt az emailt, kérdések esetén pedig vedd fel a kapcsolatot az oldal Adminisztrátorával.\nBiztonsági okokból kérjük NE továbbítsd ezt az emailt.", + "login.email.password-reset.subject": "Jelszóvisszaállítási kódod", + "login.remember": "Maradjak bejelentkezve", + "login.reset": "Jelszó visszaállítása", + "login.toggleText.code.email": "Bejelentkezés emaillel", + "login.toggleText.code.email-password": "Bejelentkezés jelszóval", + "login.toggleText.password-reset.email": "Elfelejtetted a jelszavad?", + "login.toggleText.password-reset.email-password": "← Vissza a bejelentkezéshez", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Kijelentkezés", + + "merge": "Merge", + "menu": "Menü", + "meridiem": "DE/DU", + "mime": "Média-típus", + "minutes": "Perc", + + "month": "Hónap", + "months.april": "\u00e1prilis", + "months.august": "augusztus", + "months.december": "december", + "months.february": "február", + "months.january": "janu\u00e1r", + "months.july": "j\u00falius", + "months.june": "j\u00fanius", + "months.march": "m\u00e1rcius", + "months.may": "m\u00e1jus", + "months.november": "november", + "months.october": "okt\u00f3ber", + "months.september": "szeptember", + + "more": "Több", + "move": "Move", + "name": "Név", + "next": "Következő", + "night": "Night", + "no": "nem", + "off": "ki", + "on": "be", + "open": "Megnyitás", + "open.newWindow": "Megnyitás új ablakban", + "option": "Option", + "options": "Beállítások", + "options.none": "Nincsnek beállítások", + "options.all": "Show all {count} options", + + "orientation": "Tájolás", + "orientation.landscape": "Fekvő", + "orientation.portrait": "Álló", + "orientation.square": "Négyzetes", + + "page": "Oldal", + "page.blueprint": "Ehhez az oldalhoz még nem tartozik oldalsablon. Itt hozhatod létre: /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "URL v\u00e1ltoztat\u00e1sa", + "page.changeSlug.fromTitle": "L\u00e9trehoz\u00e1s c\u00edmb\u0151l", + "page.changeStatus": "Állapot módosítása", + "page.changeStatus.position": "Kérlek válaszd ki a pozíciót", + "page.changeStatus.select": "Új állapot kiválasztása", + "page.changeTemplate": "Sablon módosítása", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Biztos vagy benne, hogy törlöd az alábbi oldalt: {title}?", + "page.delete.confirm.subpages": "Ehhez az oldalhoz aloldalak tartoznak.
Az oldal törlésekor a hozzá tartozó aloldalak is törlődnek.", + "page.delete.confirm.title": "Megerősítéshez add meg az oldal címét", + "page.duplicate.appendix": "Másol", + "page.duplicate.files": "Fájlok másolása", + "page.duplicate.pages": "Oldalak másolása", + "page.move": "Move page", + "page.sort": "Sorrend megváltoztatása", + "page.status": "Állapot", + "page.status.draft": "Piszkozat", + "page.status.draft.description": "Ez az oldal jelenleg piszkozat és csak bejelentkezett szerkesztők számára, vagy egy titkos linken keresztül érhető el", + "page.status.listed": "Publikus", + "page.status.listed.description": "Az oldal mindenki számára elérhető", + "page.status.unlisted": "Nem listázott", + "page.status.unlisted.description": "Az oldal csak URL-en keresztül érhető el", + + "pages": "Oldalak", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Nincs még bejegyzés", + "pages.status.draft": "Piszkozatok", + "pages.status.listed": "Publikálva", + "pages.status.unlisted": "Nem listázott", + + "pagination.page": "Oldal", + + "password": "Jelsz\u00f3", + "paste": "Beillesztés", + "paste.after": "Beillesztés utána", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Pluginek", + "prev": "Előző", + "preview": "Előnézet", + + "publish": "Publish", + "published": "Publikálva", + + "remove": "Eltávolítás", + "rename": "Átnevezés", + "renew": "Renew", + "replace": "Cser\u00e9l", + "replace.with": "Replace with", + "retry": "Próbáld újra", + "revert": "Visszavon\u00e1s", + "revert.confirm": "Tényleg törölni szeretnél minden nem mentett változtatást?", + + "role": "Szerepkör", + "role.admin.description": "Az adminisztrátornak minden joga van", + "role.admin.title": "Admin", + "role.all": "Összes", + "role.empty": "Nincsenek felhasználók ilyen szerepkörrel", + "role.description.placeholder": "Nincs leírás", + "role.nobody.description": "Ez a visszatérő szabály a nem rendelkező jogosultsághoz", + "role.nobody.title": "Senki", + + "save": "Ment\u00e9s", + "saved": "Saved", + "search": "Keresés", + "searching": "Searching", + "search.min": "A kereséshez írj be minimum {min} karaktert", + "search.all": "Show all {count} results", + "search.results.none": "Nincs találat", + + "section.invalid": "The section is invalid", + "section.required": "Ez a szakasz kötelező", + + "security": "Security", + "select": "Kiválasztás", + "server": "Szerver", + "settings": "Beállítások", + "show": "Mutat", + "site.blueprint": "Ehhez a weblaphoz még nem tartozik oldalsablon. Itt hozhatod létre: /site/blueprints/site.yml", + "size": "Méret", + "slug": "URL n\u00e9v", + "sort": "Rendezés", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Állapot", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Sablon", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Cím", + "today": "Ma", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Kód", + "toolbar.button.bold": "F\u00e9lk\u00f6v\u00e9r sz\u00f6veg", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Címsor", + "toolbar.button.heading.1": "Címsor 1", + "toolbar.button.heading.2": "Címsor 2", + "toolbar.button.heading.3": "Címsor 3", + "toolbar.button.heading.4": "Címsor 4", + "toolbar.button.heading.5": "Címsor 5", + "toolbar.button.heading.6": "Címsor 6", + "toolbar.button.italic": "Dőlt szöveg", + "toolbar.button.file": "Fájl", + "toolbar.button.file.select": "Válassz egy fájlt", + "toolbar.button.file.upload": "Fájl feltöltése", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Bekezdés", + "toolbar.button.strike": "Áthúzott szöveg", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Rendezett lista", + "toolbar.button.underline": "Aláhúzott szöveg", + "toolbar.button.ul": "Rendezetlen lista", + + "translation.author": "A Kirby csapata", + "translation.direction": "ltr", + "translation.name": "Magyar", + "translation.locale": "hu_HU", + + "type": "Type", + + "upload": "Feltöltés", + "upload.error.cantMove": "A feltöltött fájlt nem sikerült áthelyezni", + "upload.error.cantWrite": "Hiba a fájl lemezre írása közben", + "upload.error.default": "A fájlt nem sikerült feltölteni", + "upload.error.extension": "A fájlfeltöltés egy kiterjesztés miatt megszakadt", + "upload.error.formSize": "A feltöltendő fájl mérete nagyobb, mint az űrlap MAX_FILE_SIZE szabályában beállított érték", + "upload.error.iniPostSize": "A feltöltendő fájl mérete nagyobb, mint a php.ini post_max_size szabályában beállított érték", + "upload.error.iniSize": "A feltöltendő fájl mérete nagyobb, mint a php.ini upload_max_filesize szabályában beállított érték", + "upload.error.noFile": "Nem lett fájl feltöltve", + "upload.error.noFiles": "Nem lettek fájlok feltöltve", + "upload.error.partial": "A fájl feltöltése csak részben sikerült", + "upload.error.tmpDir": "Hiányzik egy átmeneti mappa", + "upload.errors": "Hiba", + "upload.progress": "Feltöltés...", + + "url": "Url", + "url.placeholder": "https://pelda.hu", + + "user": "Felhasználó", + "user.blueprint": "További szakaszokat és mezőket adhatsz meg ehhez a felhasználói szerepkörhöz itt: /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Email módosítása", + "user.changeLanguage": "Nyelv módosítása", + "user.changeName": "Felhasználó átnevezése", + "user.changePassword": "Jelszó módosítása", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Új jelszó", + "user.changePassword.new.confirm": "Az új jelszó megerősítése", + "user.changeRole": "Szerepkör módosítása", + "user.changeRole.select": "Új szerepkör kiválasztása", + "user.create": "Új felhasználó hozzáadása", + "user.delete": "Felhasználó törlése", + "user.delete.confirm": "Biztos törlöd ezt a felhasználót:
{email}?", + + "users": "Felhasználók", + + "version": "Kirby verzi\u00f3", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "Fi\u00f3kod", + "view.installation": "Telep\u00edt\u00e9s", + "view.languages": "Nyelvek", + "view.resetPassword": "Jelszó visszaállítása", + "view.site": "Weboldal", + "view.system": "Rendszer", + "view.users": "Felhaszn\u00e1l\u00f3k", + + "welcome": "Üdvözlünk", + "year": "Év", + "yes": "igen" +} diff --git a/public/kirby/i18n/translations/id.json b/public/kirby/i18n/translations/id.json new file mode 100644 index 0000000..a26de0e --- /dev/null +++ b/public/kirby/i18n/translations/id.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Ubah nama Anda", + "account.delete": "Hapus akun Anda", + "account.delete.confirm": "Anda yakin menghapus akun? Anda akan dikeluarkan segera. Akun Anda tidak dapat dipulihkan.", + + "activate": "Activate", + "add": "Tambah", + "alpha": "Alpha", + "author": "Penulis", + "avatar": "Gambar profil", + "back": "Kembali", + "cancel": "Batal", + "change": "Ubah", + "close": "Tutup", + "changes": "Perubahan", + "confirm": "Oke", + "collapse": "Lipat", + "collapse.all": "Lipat Semua", + "color": "Warna", + "coordinates": "Koordinat", + "copy": "Salin", + "copy.all": "Salin semua", + "copy.success": "{count} disalin!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Buat", + "custom": "Kustom", + + "date": "Tanggal", + "date.select": "Pilih tanggal", + + "day": "Hari", + "days.fri": "Jum", + "days.mon": "Sen", + "days.sat": "Sab", + "days.sun": "Min", + "days.thu": "Kam", + "days.tue": "Sel", + "days.wed": "Rab", + + "debugging": "Debugging", + + "delete": "Hapus", + "delete.all": "Hapus semua", + + "dialog.fields.empty": "Dialog ini tidak memiliki bidang", + "dialog.files.empty": "Tidak ada berkas untuk dipilih", + "dialog.pages.empty": "Tidak ada halaman untuk dipilih", + "dialog.text.empty": "Dialog ini tidak mendefinisikan teks apa pun", + "dialog.users.empty": "Tidak ada pengguna untuk dipilih", + + "dimensions": "Dimensi", + "disable": "Disable", + "disabled": "Dimatikan", + "discard": "Buang", + + "drawer.fields.empty": "Drawer ini tidak memiliki bidang", + + "domain": "Domain", + "download": "Unduh", + "duplicate": "Duplikasi", + + "edit": "Sunting", + + "email": "Surel", + "email.placeholder": "surel@contoh.com", + + "enter": "Masuk", + "entries": "Entri", + "entry": "Entri", + + "environment": "Lingkungan", + + "error": "Kesalahan", + "error.access.code": "Kode tidak valid", + "error.access.login": "Upaya masuk tidak valid", + "error.access.panel": "Anda tidak diizinkan mengakses panel", + "error.access.view": "Anda tidak diizinkan mengakses bagian panel ini", + + "error.avatar.create.fail": "Gambar profil tidak dapat diunggah", + "error.avatar.delete.fail": "Gambar profil tidak dapat dihapus", + "error.avatar.dimensions.invalid": "Pastikan lebar dan tinggi gambar profil di bawah 3000 piksel", + "error.avatar.mime.forbidden": "Gambar profil harus berupa berkas JPEG atau PNG", + + "error.blueprint.notFound": "Cetak biru \"{name}\" tidak dapat dimuat", + + "error.blocks.max.plural": "Anda tidak boleh menambahkan lebih dari {max} blok", + "error.blocks.max.singular": "Anda tidak boleh menambahkan lebih dari satu blok", + "error.blocks.min.plural": "Anda setidaknya menambahkan {min} blok", + "error.blocks.min.singular": "Anda setidaknya menambahkan satu blok", + "error.blocks.validation": "Ada kesalahan di bidang \"{field}\" di blok {index} menggunakan \"{fieldset}\" tipe blok", + + "error.cache.type.invalid": "Tipe tembolok tidak valid \"{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": "Ada kesalahan pada bidang \"{field}\" di baris {index}", + + "error.email.preset.notFound": "Surel \"{name}\" tidak dapat ditemukan", + + "error.field.converter.invalid": "Konverter \"{converter}\" tidak valid", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Bidang \"{ name }\": Tipe bidang \"{ type }\" tidak ada", + + "error.file.changeName.empty": "Nama harus diisi", + "error.file.changeName.permission": "Anda tidak diizinkan mengubah nama berkas \"{filename}\"", + "error.file.changeTemplate.invalid": "Templat untuk berkas \"{id}\" tidak dapat diubah menjadi \"{template}\" (valid: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Anda tidak diizinkan mengubah templat untuk berkas \"{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": "Berkas dengan nama \"{filename}\" sudah ada", + "error.file.extension.forbidden": "Ekstensi \"{extension}\" tidak diizinkan", + "error.file.extension.invalid": "Ekstensi tidak valid: {extension}", + "error.file.extension.missing": "Berkas \"{filename}\" harus memiliki ekstensi", + "error.file.maxheight": "Tinggi gambar tidak boleh melebihi {height} piksel", + "error.file.maxsize": "Berkas terlalu besar", + "error.file.maxwidth": "Lebar gambar tidak boleh melebihi {width} piksel", + "error.file.mime.differs": "Berkas yang diunggah harus memiliki tipe mime sama \"{mime}\"", + "error.file.mime.forbidden": "Media dengan tipe mime \"{mime}\" tidak diizinkan", + "error.file.mime.invalid": "Tipe mime tidak valid: {mime}", + "error.file.mime.missing": "Tipe media untuk \"{filename}\" tidak dapat dideteksi", + "error.file.minheight": "Tinggi gambar setidaknya {height} piksel", + "error.file.minsize": "Berkas terlalu kecil", + "error.file.minwidth": "Lebar gambar setidaknya {width} piksel", + "error.file.name.unique": "Nama berkas harus unik", + "error.file.name.missing": "Nama berkas harus diisi", + "error.file.notFound": "Berkas \"{filename}\" tidak dapat ditemukan", + "error.file.orientation": "Orientasi gambar harus \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Anda tidak diizinkan mengunggah berkas dengan tipe {type}", + "error.file.type.invalid": "Tipe berkas tidak valid: {type}", + "error.file.undefined": "Berkas tidak dapat ditemukan", + + "error.form.incomplete": "Pastikan semua bidang telah diisi dengan benar…", + "error.form.notSaved": "Formulir tidak dapat disimpan", + + "error.language.code": "Masukkan kode bahasa yang valid", + "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": "Bahasa sudah ada", + "error.language.name": "Masukkan nama bahasa yang valid", + "error.language.notFound": "Bahasa tidak ditemukan", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Ada kesalahan pada bidang \"{field}\" di blok {blockIndex} menggunakan tipe blok \"{fieldset}\" di tata letak {layoutIndex}", + "error.layout.validation.settings": "Ada kesalahan di pengaturan tata letak {index}", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Masukkan surel yang valid", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "Lisensi tidak dapat diverifikasi", + + "error.login.totp.confirm.invalid": "Kode tidak valid", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "Ada kesalahan di bidang \"{label}\":\n{message}", + + "error.offline": "Panel saat ini luring", + + "error.page.changeSlug.permission": "Anda tidak diizinkan mengubah akhiran URL untuk \"{slug}\"", + "error.page.changeSlug.reserved": "Alur halaman-halaman level atas tidak boleh diawali dengan \"{path}\"", + "error.page.changeStatus.incomplete": "Halaman memiliki kesalahan dan tidak dapat diterbitkan", + "error.page.changeStatus.permission": "Status halaman ini tidak dapat diubah", + "error.page.changeStatus.toDraft.invalid": "Halaman \"{slug}\" tidak dapat dikonversi menjadi draf", + "error.page.changeTemplate.invalid": "Templat untuk halaman \"{slug}\" tidak dapat diubah", + "error.page.changeTemplate.permission": "Anda tidak diizinkan mengubah templat dari \"{slug}\"", + "error.page.changeTitle.empty": "Judul harus diisi", + "error.page.changeTitle.permission": "Anda tidak diizinkan mengubah judul dari \"{slug}\"", + "error.page.create.permission": "Anda tidak diizinkan membuat \"{slug}\"", + "error.page.delete": "Halaman \"{slug}\" tidak dapat dihapus", + "error.page.delete.confirm": "Masukkan judul halaman untuk mengonfirmasi", + "error.page.delete.hasChildren": "Halaman ini memiliki sub-halaman dan tidak dapat dihapus", + "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": "Anda tidak diizinkan menghapus \"{slug}\"", + "error.page.draft.duplicate": "Draf halaman dengan akhiran URL \"{slug}\" sudah ada", + "error.page.duplicate": "Halaman dengan akhiran URL \"{slug}\" sudah ada", + "error.page.duplicate.permission": "Anda tidak diizinkan menduplikasi \"{slug}\"", + "error.page.move.ancestor": "Halaman tidak dapat dipindahkan ke dirinya sendiri", + "error.page.move.directory": "Direktori halaman tidak dapat dipindahkan", + "error.page.move.duplicate": "Suatu sub halaman dengan akhiran URL \"{slug}\" sudah ada", + "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": "Halaman yang dipindahkan tidak dapat ditemukan", + "error.page.move.permission": "Anda tidak diizinkan memindahkan \"{slug}\"", + "error.page.move.template": "Templat \"{template}\" tidak dapat diterima sebagai sub halaman dari \"{parent}\"", + "error.page.notFound": "Halaman \"{slug}\" tidak dapat ditemukan", + "error.page.num.invalid": "Masukkan nomor urut yang valid. Nomor tidak boleh negatif.", + "error.page.slug.invalid": "Masukkan akhiran URL yang valid", + "error.page.slug.maxlength": "Panjang slug harus kurang dari \"{length}\" karakter", + "error.page.sort.permission": "Halaman \"{slug}\" tidak dapat diurutkan", + "error.page.status.invalid": "Atur status halaman yang valid", + "error.page.undefined": "Halaman tidak dapat ditemukan", + "error.page.update.permission": "Anda tidak diizinkan memperbaharui \"{slug}\"", + + "error.section.files.max.plural": "Anda hanya boleh menambahkan maksimal {max} berkas ke bagian \"{section}\"", + "error.section.files.max.singular": "Anda hanya boleh menambahkan satu berkas ke bagian \"{section}\"", + "error.section.files.min.plural": "Bagian \"{section}\" setidaknya memiliki {min} berkas", + "error.section.files.min.singular": "Bagian \"{section}\" setidaknya memiliki satu berkas", + + "error.section.pages.max.plural": "Anda hanya boleh menambahkan maksimal {max} halaman ke bagian \"{section}\"", + "error.section.pages.max.singular": "Anda hanya boleh menambahkan satu halaman ke bagian \"{section}\"", + "error.section.pages.min.plural": "Bagian \"{section}\" setidaknya memiliki {min} halaman", + "error.section.pages.min.singular": "Bagian \"{section}\" setidaknya memiliki satu halaman", + + "error.section.notLoaded": "Bagian \"{name}\" tidak dapat dimuat", + "error.section.type.invalid": "Tipe bagian \"{type}\" tidak valid", + + "error.site.changeTitle.empty": "Judul harus diisi", + "error.site.changeTitle.permission": "Anda tidak diizinkan mengubah judul situs", + "error.site.update.permission": "Anda tidak diizinkan memperbaharui situs", + + "error.structure.validation": "Ada kesalahan pada bidang \"{field}\" di baris {index}", + + "error.template.default.notFound": "Templat bawaan tidak ada", + + "error.unexpected": "Kesalahan tidak terduga terjadi! Hidupkan mode debug untuk informasi lebih lanjut: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Anda tidak diizinkan mengubah surel dari pengguna \"{name}\"", + "error.user.changeLanguage.permission": "Anda tidak diizinkan mengubah bahasa dari pengguna \"{name}\"", + "error.user.changeName.permission": "Anda tidak diizinkan mengubah nama dari pengguna \"{name}\"", + "error.user.changePassword.permission": "Anda tidak diizinkan mengubah sandi dari pengguna \"{name}\"", + "error.user.changeRole.lastAdmin": "Peran dari admin satu-satunya tidak dapat diubah", + "error.user.changeRole.permission": "Anda tidak diizinkan mengubah peran dari pengguna \"{name}\"", + "error.user.changeRole.toAdmin": "Anda tidak diizinkan mempromosikan seseorang menjadi admin", + "error.user.create.permission": "Anda tidak diizinkan membuat pengguna ini", + "error.user.delete": "Pengguna \"{nama}\" tidak dapat dihapus", + "error.user.delete.lastAdmin": "Admin satu-satunya tidak dapat dihapus", + "error.user.delete.lastUser": "Pengguna satu-satunya tidak dapat dihapus", + "error.user.delete.permission": "Anda tidak diizinkan menghapus pengguna \"{name}\"", + "error.user.duplicate": "Pengguna dengan surel \"{email}\" sudah ada", + "error.user.email.invalid": "Masukkan surel yang valid", + "error.user.language.invalid": "Masukkan bahasa yang valid", + "error.user.notFound": "Pengguna \"{name}\" tidak dapat ditemukan", + "error.user.password.excessive": "Masukkan sandi yang valid. Sandi tidak boleh lebih dari 1000 karakter.", + "error.user.password.invalid": "Masukkan sandi yang valid. Sandi setidaknya mengandung 8 karakter.", + "error.user.password.notSame": "Sandi tidak cocok", + "error.user.password.undefined": "Pengguna tidak memiliki sandi", + "error.user.password.wrong": "Kata sandi salah", + "error.user.role.invalid": "Masukkan peran yang valid", + "error.user.undefined": "Pengguna tidak dapat ditemukan", + "error.user.update.permission": "Anda tidak diizinkan memperbaharui pengguna \"{name}\"", + + "error.validation.accepted": "Mohon konfirmasi", + "error.validation.alpha": "Masukkan hanya karakter a-z", + "error.validation.alphanum": "Masukkan hanya karakter a-z atau 0-9", + "error.validation.anchor": "Masukkan tautan yang valid", + "error.validation.between": "Masukkan nilai antara \"{min}\" dan \"{max}\"", + "error.validation.boolean": "Mohon konfirmasi atau tolak", + "error.validation.color": "Masukkan warna yang valid dalam format {format}", + "error.validation.contains": "Masukkan nilai yang mengandung \"{needle}\"", + "error.validation.date": "Masukkan tanggal yang valid", + "error.validation.date.after": "Masukkan tanggal setelah {date}", + "error.validation.date.before": "Masukkan tanggal sebelum {date}", + "error.validation.date.between": "Masukkan tanggal antara {min} dan {max}", + "error.validation.denied": "Mohon tolak", + "error.validation.different": "Nilai harus selain \"{other}\"", + "error.validation.email": "Masukkan surel yang valid", + "error.validation.endswith": "Nilai harus diakhiri dengan \"{end}\"", + "error.validation.filename": "Masukkan nama berkas yang valid", + "error.validation.in": "Masukkan satu dari berikut: ({in})", + "error.validation.integer": "Masukkan bilangan bulat yang valid", + "error.validation.ip": "Masukkan IP yang valid", + "error.validation.less": "Masukkan nilai kurang dari {max}", + "error.validation.linkType": "Tipe tautan tidak diizinkan", + "error.validation.match": "Nilai tidak cocok dengan pola yang semestinya", + "error.validation.max": "Masukkan nilai yang sama dengan atau kurang dari {max}", + "error.validation.maxlength": "Masukkan nilai yang lebih pendek. (maksimal {max} karakter)", + "error.validation.maxwords": "Masukkan tidak lebih dari {max} kata", + "error.validation.min": "Masukkan nilai yang sama dengan atau lebih dari {min}", + "error.validation.minlength": "Masukkan nilai yang lebih panjang. (minimal {min} karakter)", + "error.validation.minwords": "Masukkan setidaknya {min} kata", + "error.validation.more": "Masukkan nilai yang lebih besar dari {min}", + "error.validation.notcontains": "Masukkan nilai yang tidak mengandung \"{needle}\"", + "error.validation.notin": "Jangan masukkan satupun: ({notIn})", + "error.validation.option": "Pilih opsi yang valid", + "error.validation.num": "Masukkan nomor yang valid", + "error.validation.required": "Masukkan sesuatu", + "error.validation.same": "Masukkan \"{other}\"", + "error.validation.size": "Ukuran dari nilai harus \"{size}\"", + "error.validation.startswith": "Nilai harus diawali dengan \"{start}\"", + "error.validation.tel": "Masukkan nomor telepon tanpa format", + "error.validation.time": "Masukkan waktu yang valid", + "error.validation.time.after": "Masukkan waktu setelah {time}", + "error.validation.time.before": "Masukkan waktu sebelum {time}", + "error.validation.time.between": "Masukkan waktu antara {min} dan {max}", + "error.validation.uuid": "Masukkan UUID yang valid", + "error.validation.url": "Masukkan URL yang valid", + + "expand": "Luaskan", + "expand.all": "Luaskan Semua", + + "field.invalid": "Bidang tidak valid", + "field.required": "Bidang ini wajib", + "field.blocks.changeType": "Ubah tipe", + "field.blocks.code.name": "Kode", + "field.blocks.code.language": "Bahasa", + "field.blocks.code.placeholder": "Kode Anda …", + "field.blocks.delete.confirm": "Anda yakin menghapus blok ini?", + "field.blocks.delete.confirm.all": "Anda yakin menghapus semua blok?", + "field.blocks.delete.confirm.selected": "Anda yakin menghapus blok yang dipilih?", + "field.blocks.empty": "Belum ada blok", + "field.blocks.fieldsets.empty": "Belum ada set bidang", + "field.blocks.fieldsets.label": "Pilih tipe blok …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galeri", + "field.blocks.gallery.images.empty": "Belum ada gambar", + "field.blocks.gallery.images.label": "Gambar", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Penajukan", + "field.blocks.heading.text": "Teks", + "field.blocks.heading.placeholder": "Penajukan …", + "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": "Teks alternatif", + "field.blocks.image.caption": "Keterangan", + "field.blocks.image.crop": "Pangkas", + "field.blocks.image.link": "Tautan", + "field.blocks.image.location": "Lokasi", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Gambar", + "field.blocks.image.placeholder": "Pilih gambar", + "field.blocks.image.ratio": "Rasio", + "field.blocks.image.url": "URL Gambar", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "Daftar", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Teks", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Kutipan", + "field.blocks.quote.text.label": "Teks", + "field.blocks.quote.text.placeholder": "Kutipan …", + "field.blocks.quote.citation.label": "Sitasi", + "field.blocks.quote.citation.placeholder": "oleh …", + "field.blocks.text.name": "Teks", + "field.blocks.text.placeholder": "Teks …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Deskripsi", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Lokasi", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Masukkan URL video", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "URL Video", + "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": "Belum ada entri", + + "field.files.empty": "Belum ada berkas yang dipilih", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Hapus tata letak", + "field.layout.delete.confirm": "Anda yakin menghapus tata letak ini?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Belum ada baris", + "field.layout.select": "Pilih tata letak", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Belum ada halaman yang dipilih", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Anda yakin menghapus baris ini?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Belum ada entri", + + "field.users.empty": "Belum ada pengguna yang dipilih", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Berkas", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Ubah templat", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Anda yakin menghapus
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Ubah posisi", + + "files": "Berkas", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Belum ada berkas", + + "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": "Sembunyikan", + "hour": "Jam", + "hue": "Hue", + "import": "Import", + "info": "Info", + "insert": "Sisipkan", + "insert.after": "Sisipkan setelah", + "insert.before": "Sisipkan sebelum", + "install": "Pasang", + + "installation": "Pemasangan", + "installation.completed": "Panel sudah dipasang", + "installation.disabled": "Pemasang panel dimatikan di server publik secara bawaan. Mohon jalankan di server lokal atau ubah opsi panel.install untuk menjalankan di server saat ini.", + "installation.issues.accounts": "Folder /site/accounts tidak ada atau tidak dapat ditulis", + "installation.issues.content": "Folder /content tidak ada atau tidak dapat ditulis", + "installation.issues.curl": "Ekstensi CURL diperlukan", + "installation.issues.headline": "Panel tidak dapat dipasang", + "installation.issues.mbstring": "Ekstensi MB String diperlukan", + "installation.issues.media": "Folder /media tidak ada atau tidak dapat ditulis", + "installation.issues.php": "Pastikan Anda menggunakan PHP 8+", + "installation.issues.sessions": "Folder /site/sessions tidak ada atau tidak dapat ditulis", + + "language": "Bahasa", + "language.code": "Kode", + "language.convert": "Atur sebagai bawaan", + "language.convert.confirm": "

Anda yakin mengubah {name} menjadi bahasa bawaan? Ini tidak dapat dibatalkan.

Jika {name} memiliki konten yang tidak diterjemahkan, tidak akan ada pengganti yang valid dan dapat menyebabkan beberapa bagian dari situs Anda menjadi kosong.

", + "language.create": "Tambah bahasa baru", + "language.default": "Bahasa bawaan", + "language.delete.confirm": "Anda yakin menghapus bahasa {name} termasuk semua terjemahannya? Ini tidak dapat dibatalkan!", + "language.deleted": "Bahasa sudah dihapus", + "language.direction": "Arah baca", + "language.direction.ltr": "Kiri ke kanan", + "language.direction.rtl": "Kanan ke kiri", + "language.locale": "String \"PHP locale\"", + "language.locale.warning": "Anda menggunakan pengaturan lokal ubah suaian. Ubah di berkas bahasa di /site/languages", + "language.name": "Nama", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Bahasa sudah diperbaharui", + "language.variables": "Language variables", + "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", + + "languages": "Bahasa", + "languages.default": "Bahasa bawaan", + "languages.empty": "Belum ada bahasa", + "languages.secondary": "Bahasa sekunder", + "languages.secondary.empty": "Belum ada bahasa sekunder", + + "license": "Lisensi Kirby", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Beli lisensi", + "license.code": "Kode", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Masukkan kode lisensi Anda", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Terima kasih atas dukungan untuk Kirby", + "license.unregistered.label": "Unregistered", + + "link": "Tautan", + "link.text": "Teks tautan", + + "loading": "Memuat", + + "lock.unsaved": "Perubahan belum tersimpan", + "lock.unsaved.empty": "Tidak ada lagi perubahan belum tersimpan", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Buka kunci", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Masuk", + "login.code.label.login": "Kode masuk", + "login.code.label.password-reset": "Kode atur ulang sandi", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Jika alamat surel terdaftar, kode yang diminta dikirim via surel", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Kode masuk Anda", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Kode atur ulang sandi Anda", + "login.remember": "Biarkan tetap masuk", + "login.reset": "Atur ulang sandi", + "login.toggleText.code.email": "Masuk via surel", + "login.toggleText.code.email-password": "Masuk dengan sandi", + "login.toggleText.password-reset.email": "Lupa sandi Anda?", + "login.toggleText.password-reset.email-password": "← Kembali ke masuk", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Keluar", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipe Media", + "minutes": "Menit", + + "month": "Bulan", + "months.april": "April", + "months.august": "Agustus", + "months.december": "Desember", + "months.february": "Februari", + "months.january": "Januari", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "Maret", + "months.may": "Mei", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Lebih lanjut", + "move": "Move", + "name": "Nama", + "next": "Selanjutnya", + "night": "Night", + "no": "tidak", + "off": "mati", + "on": "hidup", + "open": "Buka", + "open.newWindow": "Buka di jendela baru", + "option": "Option", + "options": "Opsi", + "options.none": "Tidak ada opsi", + "options.all": "Show all {count} options", + + "orientation": "Orientasi", + "orientation.landscape": "Rebah", + "orientation.portrait": "Tegak", + "orientation.square": "Persegi", + + "page": "Halaman", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Ubah URL", + "page.changeSlug.fromTitle": "Buat dari judul", + "page.changeStatus": "Ubah status", + "page.changeStatus.position": "Pilih posisi", + "page.changeStatus.select": "Pilih status baru", + "page.changeTemplate": "Ubah templat", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Anda yakin menghapus {title}?", + "page.delete.confirm.subpages": "Halaman ini memiliki sub-halaman.
Semua sub-halaman akan ikut dihapus.", + "page.delete.confirm.title": "Masukkan judul halaman untuk mengonfirmasi", + "page.duplicate.appendix": "Salin", + "page.duplicate.files": "Salin berkas", + "page.duplicate.pages": "Salin halaman", + "page.move": "Move page", + "page.sort": "Ubah posisi", + "page.status": "Status", + "page.status.draft": "Draf", + "page.status.draft.description": "Halaman ini ada pada mode draf dan hanya dapat dilihat oleh penyunting atau via tautan rahasia", + "page.status.listed": "Publik", + "page.status.listed.description": "Halaman publik untuk siapapun", + "page.status.unlisted": "Tidak tercantum", + "page.status.unlisted.description": "Halaman hanya dapat diakses via URL", + + "pages": "Halaman", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Belum ada halaman", + "pages.status.draft": "Draf", + "pages.status.listed": "Dipublikasikan", + "pages.status.unlisted": "Tidak tercantum", + + "pagination.page": "Halaman", + + "password": "Sandi", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "Piksel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Sebelumnya", + "preview": "Pratinjau", + + "publish": "Publish", + "published": "Dipublikasikan", + + "remove": "Hapus", + "rename": "Ubah nama", + "renew": "Renew", + "replace": "Ganti", + "replace.with": "Replace with", + "retry": "Coba lagi", + "revert": "Kembalikan", + "revert.confirm": "Anda yakin menghapus semua perubahan yang belum tersimpan?", + + "role": "Peran", + "role.admin.description": "Admin memiliki semua izin", + "role.admin.title": "Admin", + "role.all": "Semua", + "role.empty": "Tidak ada pengguna dengan peran ini", + "role.description.placeholder": "Tidak ada deskripsi", + "role.nobody.description": "Ini adalah peran cadangan tanpa permisi apapun", + "role.nobody.title": "Tidak siapapun", + + "save": "Simpan", + "saved": "Saved", + "search": "Cari", + "searching": "Searching", + "search.min": "Masukkan {min} karakter untuk mencari", + "search.all": "Show all {count} results", + "search.results.none": "Tidak ada hasil", + + "section.invalid": "Bagian ini tidak valid", + "section.required": "Bagian ini wajib", + + "security": "Keamanan", + "select": "Pilih", + "server": "Peladen", + "settings": "Pengaturan", + "show": "Tampilkan", + "site.blueprint": "Situs ini belum memiliki cetak biru. Anda dapat mendefinisikannya di /site/blueprints/site.yml", + "size": "Ukuran", + "slug": "Akhiran URL", + "sort": "Urutkan", + "sort.drag": "Geser untuk mengurutkan …", + "split": "Pisahkan", + + "stats.empty": "Tidak ada laporan", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "Folder konten nampaknya terekspos", + "system.issues.eol.kirby": "Versi instalasi Kirby Anda sudah mencapai akhir dan tidak akan lagi mendapat pembaruan keamanan", + "system.issues.eol.plugin": "Versi instalasi plugin { plugin } Anda sudah mencapai akhir dan tidak akan lagi mendapatkan pembaruan keamanan", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Instalasi Anda mungkin terpengaruh oleh celah keamanan berikut ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Instalasi Anda mungkin terpengaruh oleh celah keamanan di dalam plugin { plugin } ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Templat", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Judul", + "today": "Hari ini", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Kode", + "toolbar.button.bold": "Tebal", + "toolbar.button.email": "Surel", + "toolbar.button.headings": "Penajukan", + "toolbar.button.heading.1": "Penajukan 1", + "toolbar.button.heading.2": "Penajukan 2", + "toolbar.button.heading.3": "Penajukan 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "Miring", + "toolbar.button.file": "Berkas", + "toolbar.button.file.select": "Pilih berkas", + "toolbar.button.file.upload": "Unggah berkas", + "toolbar.button.link": "Tautan", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Coret", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Daftar berurut", + "toolbar.button.underline": "Garis bawah", + "toolbar.button.ul": "Daftar tidak berurut", + + "translation.author": "Tim Kirby", + "translation.direction": "ltr", + "translation.name": "Bahasa Indonesia", + "translation.locale": "id_ID", + + "type": "Type", + + "upload": "Unggah", + "upload.error.cantMove": "Berkas unggahan tidak dapat dipindahkan", + "upload.error.cantWrite": "Gagal menyimpan berkas", + "upload.error.default": "Berkas tidak dapat diunggah", + "upload.error.extension": "Unggahan berkas diblokir dengan ekstensi", + "upload.error.formSize": "Berkas unggahan mencapai acuan MAX_FILE_SIZE yang diatur di formulir", + "upload.error.iniPostSize": "Berkas unggahan mencapai acuan post_max_size di php.ini", + "upload.error.iniSize": "Berkas unggahan mencapai acuan upload_max_filesize di php.ini", + "upload.error.noFile": "Tidak ada berkas diunggah", + "upload.error.noFiles": "Tidak ada berkas diunggah", + "upload.error.partial": "Berkas unggahan hanya berhasil diunggah sebagian", + "upload.error.tmpDir": "Folder sementara tidak ada", + "upload.errors": "Kesalahan", + "upload.progress": "Mengunggah…", + + "url": "Url", + "url.placeholder": "https://contoh.com", + + "user": "Pengguna", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Ubah surel", + "user.changeLanguage": "Ubah bahasa", + "user.changeName": "Ubah nama pengguna ini", + "user.changePassword": "Ubah sandi", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Sandi baru", + "user.changePassword.new.confirm": "Konfirmasi sandi baru…", + "user.changeRole": "Ubah peran", + "user.changeRole.select": "Pilih peran baru", + "user.create": "Tambah pengguna baru", + "user.delete": "Hapus pengguna ini", + "user.delete.confirm": "Anda yakin menghapus
{email}?", + + "users": "Pengguna", + + "version": "Versi", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "Akun Anda", + "view.installation": "Pemasangan", + "view.languages": "Bahasa", + "view.resetPassword": "Atur ulang sandi", + "view.site": "Situs", + "view.system": "System", + "view.users": "Pengguna", + + "welcome": "Selamat datang", + "year": "Tahun", + "yes": "ya" +} diff --git a/public/kirby/i18n/translations/is_IS.json b/public/kirby/i18n/translations/is_IS.json new file mode 100644 index 0000000..dec18ce --- /dev/null +++ b/public/kirby/i18n/translations/is_IS.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Breyta nafninu þínu", + "account.delete": "Eyða notandareikning þínum", + "account.delete.confirm": "Ertu alveg viss um að þú viljir endanlega eyða reikningnum þínum? Þú munt verða útskráð/ur án tafar. Ómögulegt verður að endurheimta reikninginn þinn.", + + "activate": "Virkja", + "add": "Bæta við", + "alpha": "Gagnsæi", + "author": "Höfundur", + "avatar": "Prófíl mynd", + "back": "Til baka", + "cancel": "Hætta við", + "change": "Breyta", + "close": "Loka", + "changes": "Breytingar", + "confirm": "OK", + "collapse": "Fella", + "collapse.all": "Fella allt", + "color": "Litur", + "coordinates": "Hnit", + "copy": "Afrita", + "copy.all": "Afrita allt", + "copy.success": "Afritaði {count}!", + "copy.success.multiple": "Afritaði {count}!", + "copy.url": "Afrita slóð", + "create": "Stofna", + "custom": "Sérstillt", + + "date": "Dagsetning", + "date.select": "Veldu dagsetningu", + + "day": "Dagur", + "days.fri": "Fös", + "days.mon": "Mán", + "days.sat": "Lau", + "days.sun": "Sun", + "days.thu": "Fim", + "days.tue": "Þri", + "days.wed": "Mið", + + "debugging": "Aflúsun", + + "delete": "Eyða", + "delete.all": "Eyða hreint öllu", + + "dialog.fields.empty": "Þessi valmynd hefur engin svið", + "dialog.files.empty": "Engar skrár til að velja úr", + "dialog.pages.empty": "Engar síður til að velja úr", + "dialog.text.empty": "þessi valmynd skilgreinir engan texta", + "dialog.users.empty": "Engir notendur til að velja úr", + + "dimensions": "Rýmd", + "disable": "Afvirkja", + "disabled": "Óvirkt", + "discard": "Hunsa", + + "drawer.fields.empty": "Þessi skúffa hefur engin svið", + + "domain": "Lén", + "download": "Hlaða niður", + "duplicate": "Klóna", + + "edit": "Breyta", + + "email": "Netfang", + "email.placeholder": "nafn@netfang.is", + + "enter": "Venda", + "entries": "Færslur", + "entry": "Færsla", + + "environment": "Umhverfi", + + "error": "Villa", + "error.access.code": "Ógildur kóði", + "error.access.login": "Ógild innskráning", + "error.access.panel": "Þú hefur ekkert leyfi til að nota panelinn", + "error.access.view": "Þú hefur ekkert leyfi til að nota þennan hluta panelsins", + + "error.avatar.create.fail": "Það gekk ekki að hlaða inn prófílmyndinni", + "error.avatar.delete.fail": "Ekki tókst að eyða prófílmyndinni", + "error.avatar.dimensions.invalid": "Vinsamlegast hafðu myndina ekki breiðari né hærri en 3000 punkta", + "error.avatar.mime.forbidden": "Snið myndarinnar þarf að vera af gerðinni JPEG eða PNG", + + "error.blueprint.notFound": "Ekki tókst að hlaða bláprentið: \"{name}\". Reyndu aftur?", + + "error.blocks.max.plural": "Ekki fleiri en {max} bálka", + "error.blocks.max.singular": "Ekki meira en einn bálkur", + "error.blocks.min.plural": "Minnst {min}. bálka", + "error.blocks.min.singular": "Allavegana einn bálkur takk", + "error.blocks.validation": "Það er villa í {field} sviðinu í bálkinum {index} sem notar {fieldset} bálkgerðina", + + "error.cache.type.invalid": "Ógyld skyndiminnisgerð \"{type}\"", + + "error.content.lock.delete": "Þessi útgáfa er læst og henni verður ekki eytt", + "error.content.lock.move": "Þessi útgáfa er læst og hún verður ekki færð", + "error.content.lock.publish": "Þessi útgáfa er núþegar útgefin", + "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": "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": "Ó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", + "error.file.changeName.permission": "Þú mátt ekkert breyta nafninu á skránni \"{filename}\"", + "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": "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}", + "error.file.extension.missing": "Skrárendinguna fyrir \"{filename}\" vantar", + "error.file.maxheight": "Hæð myndarinnar má ekki vera meiri en {height} punktar", + "error.file.maxsize": "Skráinn er alltof stór", + "error.file.maxwidth": "Breydd myndarinnar má alls ekki vera meiri en {width} punktar", + "error.file.mime.differs": "Upphlaðna skráin þarf að vera sömu tegundar: \"{mime}\"", + "error.file.mime.forbidden": "Gagnasniðið \"{mime}\" er ekki leyft hér", + "error.file.mime.invalid": "Ógyllt gagnasnið: {mime}", + "error.file.mime.missing": "Gagnasnið skránnar \"{filename}\" er óþekkt", + "error.file.minheight": "Hæð myndarinnar þarf að vera minnst {height} punktar", + "error.file.minsize": "Skráin er of smá", + "error.file.minwidth": "Breidd myndarinnar þarf að vera minnst {width} punktar", + "error.file.name.unique": "Skrárnafnið þarf að vera einstakt", + "error.file.name.missing": "Skrárnafnið má ekki skilja eftir tómt", + "error.file.notFound": "Skráin \"{filename}\" fannst ekki", + "error.file.orientation": "Snið myndarinnar þarf að vera \"{orientation}\"", + "error.file.sort.permission": "Þú mátt ekkert breyta röðuninni á \"{filename}\"", + "error.file.type.forbidden": "Þú mátt ekkert hlaða inn {type} skrám", + "error.file.type.invalid": "Ógild skrártegund: {type}", + "error.file.undefined": "Skráin fannst ekki", + + "error.form.incomplete": "Vinsamlegast lagfærðu villurnar í forminu…", + "error.form.notSaved": "Ekki tókst að vista upplýsingar úr forminu", + + "error.language.code": "Gófúslega settu inn gildan kóða fyrir tungumál", + "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": "Þetta tungumál er nú þegar skráð", + "error.language.name": "Gott og gyllt nafn fyrir tungumálið", + "error.language.notFound": "Tungumálið fannst ekkert", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Það er villa í {field} sviðinu í bálkinum {blockIndex} sem notar {fieldset} bálkgerðina í rammanum {layoutIndex}", + "error.layout.validation.settings": "Hér er villa í sitllingum fyrir ramman {index}", + + "error.license.domain": "Lénið fyrir skráningarleyfið vantar", + "error.license.email": "Almennilegt netfang hér", + "error.license.format": "Vinsamlegast og fyrir alla muni settu inn gildan leyfiskóða", + "error.license.verification": "Ekki heppnaðist að staðfesta leyfið", + + "error.login.totp.confirm.invalid": "Ógildur kóði", + "error.login.totp.confirm.missing": "Settu inn núverandi lykilkóða", + + "error.object.validation": "Það er villa í \"{label}\" sviðinu:\n{message}", + + "error.offline": "Stjórnborðið er óvirkt eins og stendur.", + + "error.page.changeSlug.permission": "Þú hefur ekkert leyfi til þess að breyta slóðarviðskeytinu fyrir \"{slug}\"", + "error.page.changeSlug.reserved": "Slóð síðna í rótinni verður að byrja með \"{path}\"", + "error.page.changeStatus.incomplete": "Það eru villur á síðunni og við getum ekki gefið hana út", + "error.page.changeStatus.permission": "Stöðu síðunnar var ekki hægt að breyta", + "error.page.changeStatus.toDraft.invalid": "Síðunni \"{slug}\" er ekki hægt að breyta í uppkast", + "error.page.changeTemplate.invalid": "Sniðmáti fyrir síðuna \"{slug}\" er ekki hægt að breyta", + "error.page.changeTemplate.permission": "Þú hefur engan veginn leyfi til að breyta sniðmáti fyrir síðuna \"{slug}\"", + "error.page.changeTitle.empty": "Titillinn getur ekki verið óskilgreindur", + "error.page.changeTitle.permission": "Þú mátt ekki breyta titlinum fyrir \"{slug}\"", + "error.page.create.permission": "Þú hefur ekki leyfi til að stofna \"{slug}\"", + "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": "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", + "error.page.duplicate.permission": "Þú mátt ekki klóna \"{slug}\"", + "error.page.move.ancestor": "Það er ekki hægt að færa síðuna á sjálfa sig.", + "error.page.move.directory": "Ekki er reyndist unnt að færa möppu síðunnar.", + "error.page.move.duplicate": "Undirsíða með slóðinni og forskeytinu \"{slug}\" er núþegar til", + "error.page.move.noSections": "Síðan \"{parent}\" getur ekki átt undirsíður þar sem tilskylin svið til umsýslu á undirsíðum vantar", + "error.page.move.notFound": "Síðan sem færð var finnst því miður ekki", + "error.page.move.permission": "Þú mátt ekkert færa \"{slug}\"", + "error.page.move.template": "Sniðmátið \"{template}\" er ekki gillt sem undirsíða af \"{parent}\"", + "error.page.notFound": "Síðan \"{slug}\" fannst ekkert", + "error.page.num.invalid": "Veldu ákjósanlega raðtölu. Neikvæðar tölur bannaðar.", + "error.page.slug.invalid": "Veldu ákjósanlega vefslóð", + "error.page.slug.maxlength": "Vefslóð þarf að vera a.m.k. \"{length}\" stafir", + "error.page.sort.permission": "Ekki reyndist unnt að raða síðunni \"{slug}\"", + "error.page.status.invalid": "Ákjósanlega síðustöðu takk", + "error.page.undefined": "Síðan fannst ekkert", + "error.page.update.permission": "Þú mátt ekkert uppfæra síðuna \"{slug}\"", + + "error.section.files.max.plural": "Ekki fleiri en {max} skrár í \"{section}\" svæðið", + "error.section.files.max.singular": "Aðeins ein skrá í \"{section}\" svæðið", + "error.section.files.min.plural": "\"{section}\" svæðið krefst a.m.k. {min} skrá sem innihalds", + "error.section.files.min.singular": "\"{section}\" þarf minnst eina skrá til að það virki", + + "error.section.pages.max.plural": "Alls ekki fleiri en {max} síður í \"{section}\" svæðið", + "error.section.pages.max.singular": "Ekki fleiri en ein síða í \"{section}\" svæðið", + "error.section.pages.min.plural": "\"{section}\" svæðið krefst a.m.k {min}. síðna", + "error.section.pages.min.singular": "\"{section}\" krefst a.m.k. einnar síðu", + + "error.section.notLoaded": "Svæðið \"{name}\" var því miður ekki hægt að sækja", + "error.section.type.invalid": "Svæðiðsgerðin \"{type}\" er því miður ekki gild", + + "error.site.changeTitle.empty": "Ekki skilja titilinn eftir tóman", + "error.site.changeTitle.permission": "Þú mátt ekkert breyta titil vefsvæðisins", + "error.site.update.permission": "Þú mátt ekkert uppfæra vefsvæðið", + + "error.structure.validation": "Það er villa í \"{field}\" sviðinu í röð {index}", + + "error.template.default.notFound": "Ekkert sjálfgefið sniðmát fannst", + + "error.unexpected": "Það átti sér stað óvænt villa. Notaðu lúsarleitarhaminn (e. debug mode) til að skilja þetta betur. \nFyrir nánari upplýsingar: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Þú mátt ekkert breyta netfangi notandans \"{name}\"", + "error.user.changeLanguage.permission": "Þú hefur ekki leyfi til að breyta tungumáli notandans \"{name}\"", + "error.user.changeName.permission": "Þú mátt alls ekki breyta nafni notandans \"{name}\"", + "error.user.changePassword.permission": "Þér er harðbannað að breyta lykilorði notandans \"{name}\"", + "error.user.changeRole.lastAdmin": "Þetta er síðasti stjórinn og því má ekki breyta hlutverki", + "error.user.changeRole.permission": "Þú hefur ekki leyfi til að breyta hlutverki fyrir notandan \"{name}\"", + "error.user.changeRole.toAdmin": "Þú hefur ekkert leyfi til að gera notendur að stjórum", + "error.user.create.permission": "Þú mátt ekki stofna þennan notanda", + "error.user.delete": "Ekki reyndist unnt að eyða notandanum \"{name}\"", + "error.user.delete.lastAdmin": "Síðasta stjóranum er ekki hægt að eyða", + "error.user.delete.lastUser": "Síðasta notandanum er ekki hægt að eyða", + "error.user.delete.permission": "Þú mátt ekkert eyða notandanum \"{name}\"", + "error.user.duplicate": "Nú þegar finnst notandi með þetta netfang: \"{email}\"", + "error.user.email.invalid": "Vinsamlegast ákjósanlegt netfang", + "error.user.language.invalid": "Vinsamlegast ákjósanlegt tungumál", + "error.user.notFound": "Þessi notandi; \"{name}\" fannst ekki", + "error.user.password.excessive": "Vinsamlegast settu inn gilt lykilorð. Lykilorð hér meiga ekki vera lengri en 1000 stafabil.", + "error.user.password.invalid": "Veldu ákjósanlegt lykilorð. Minnst 8 stafa langt.", + "error.user.password.notSame": "Lykilorðin stemma ekki", + "error.user.password.undefined": "Þessi notandi hefur ekki lykilorð", + "error.user.password.wrong": "Rangt lykilorð", + "error.user.role.invalid": "Veldu ákjósanlegt hlutverk", + "error.user.undefined": "Notandinn fannst ekkert", + "error.user.update.permission": "Þú mátt ekkert breyta notandanum \"{name}\"", + + "error.validation.accepted": "Staðfestu", + "error.validation.alpha": "Aðeins stafir úr Enska stafrófinu, a-z", + "error.validation.alphanum": "Aðeins stafir úr Enska stafrófinu, a-z eða tölustafir 0-9", + "error.validation.anchor": "Vinsamlegast rétt og gillt merki", + "error.validation.between": "Gildi milli \"{min}\" og \"{max}\"", + "error.validation.boolean": "Staðfestu eða hafnaðu þessu", + "error.validation.color": "Endilega settu inn gildan lit í sniðinu {format}", + "error.validation.contains": "Settu inni gildi er inniheldur \"{needle}\"", + "error.validation.date": "Ákjósanlega dagsetningu", + "error.validation.date.after": "Dagsetningu eftir {date}", + "error.validation.date.before": "Dagsetningu fyrir {date}", + "error.validation.date.between": "Dagsetningu milli {min} og {max}", + "error.validation.denied": "Hafnaðu", + "error.validation.different": "Gildið má ekki vera \"{other}\"", + "error.validation.email": "Ákjósanlegt netfang", + "error.validation.endswith": "Gildið verður að enda á \"{end}\"", + "error.validation.filename": "Ákjósanlegt skrárnafn", + "error.validation.in": "Vinsamlegast skráðu eitt af eftirfarandi: ({in})", + "error.validation.integer": "Skráðu heiltölu", + "error.validation.ip": "Skráðu ákjósanlega IP tölu", + "error.validation.less": "Skráðu gildi lægra en {max}", + "error.validation.linkType": "Þessi tengilsgerð er ekki leyfð hér um slóðir.", + "error.validation.match": "Gildið er ekki eftir væntingum", + "error.validation.max": "Skráðu gildi sem er ekki hærra en {max}", + "error.validation.maxlength": "Veldu eitthvað styttra. (hámark {max} stafir)", + "error.validation.maxwords": "Ekki skrá fleiri en {max}. orð", + "error.validation.min": "Skráðu gildi ekki lægra en {min}", + "error.validation.minlength": "Hafðu þetta lengra en {min}. stafi", + "error.validation.minwords": "Lágmark {min}. orð", + "error.validation.more": "Eitthvað hærra en {min}", + "error.validation.notcontains": "Skráðu eitthvað sem inniheldur ekki \"{needle}\"", + "error.validation.notin": "Ekki skrá neitt af þessu: ({notIn})", + "error.validation.option": "Veldu ákjósanlegan kost", + "error.validation.num": "Notaðu tölugildi", + "error.validation.required": "Skráðu eitthvað", + "error.validation.same": "Skráðu \"{other}\"", + "error.validation.size": "Gildið þarf að vera \"{size}\"", + "error.validation.startswith": "Þetta þarf að byrja á \"{start}\"", + "error.validation.tel": "Vinsamlegast ósniðið símanúmer hér.", + "error.validation.time": "Ákjósanlegur tími", + "error.validation.time.after": "Veldu tíma eftir {time}", + "error.validation.time.before": "Veldu tíma fyrir{time}", + "error.validation.time.between": "Veldu tíma milli {min} og {max}", + "error.validation.uuid": "Vinsamlegast gillt UUID (Notandakenni)", + "error.validation.url": "Ákjósanleg vefslóð", + + "expand": "Þenja út", + "expand.all": "Þenja allt út", + + "field.invalid": "Þetta svið er bara ógillt sem stendur.", + "field.required": "Þetta svið er nauðsynlegt", + "field.blocks.changeType": "Breyta um bálkagerð", + "field.blocks.code.name": "Kóði", + "field.blocks.code.language": "Tungumal", + "field.blocks.code.placeholder": "Kóðinn þinn …", + "field.blocks.delete.confirm": "Ætlarðu virkilega að eyða þessum bálk?", + "field.blocks.delete.confirm.all": "Ertu nú alveg viss um að þú viljir eyða öllum þessum bálkum?", + "field.blocks.delete.confirm.selected": "Viltu virkilega eyða völdum bálkum?", + "field.blocks.empty": "Öngvir bálkar enn", + "field.blocks.fieldsets.empty": "Engin sviðasett enn", + "field.blocks.fieldsets.label": "Veldu bálkagerð …", + "field.blocks.fieldsets.paste": "Ýttu á {{ shortcut }} til þess að flytja raðir/bálka hingað Aðeins þeir sem eru gildir hér mun verða færðir hingað.", + "field.blocks.gallery.name": "Myndasafn", + "field.blocks.gallery.images.empty": "Engar myndir enn", + "field.blocks.gallery.images.label": "Myndir", + "field.blocks.heading.level": "Stig", + "field.blocks.heading.name": "Fyrirsögn", + "field.blocks.heading.text": "Texti/Prósi", + "field.blocks.heading.placeholder": "Fyrirsögn …", + "field.blocks.figure.back.plain": "Látlaust", + "field.blocks.figure.back.pattern.light": "Mynstur (ljóst)", + "field.blocks.figure.back.pattern.dark": "Mynstur (dökkt)", + "field.blocks.image.alt": "ALT texti", + "field.blocks.image.caption": "Myndartexti", + "field.blocks.image.crop": "Kroppa", + "field.blocks.image.link": "Tengill", + "field.blocks.image.location": "Staðsetning", + "field.blocks.image.location.internal": "Þetta vefsvæði", + "field.blocks.image.location.external": "Ytri kelda", + "field.blocks.image.name": "Mynd", + "field.blocks.image.placeholder": "Veldu mynd", + "field.blocks.image.ratio": "Hlutfall", + "field.blocks.image.url": "Slóð myndar", + "field.blocks.line.name": "Lína", + "field.blocks.list.name": "Listi", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Texti", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Tilvitnun", + "field.blocks.quote.text.label": "Innihald tilvitnunar", + "field.blocks.quote.text.placeholder": "Þessi tilvitnun …", + "field.blocks.quote.citation.label": "Heimild", + "field.blocks.quote.citation.placeholder": "eftir …", + "field.blocks.text.name": "Prósi", + "field.blocks.text.placeholder": "Þessi prósi …", + "field.blocks.video.autoplay": "Sjálfspila", + "field.blocks.video.caption": "Myndskeiðstexti", + "field.blocks.video.controls": "Stjórnhnappar", + "field.blocks.video.location": "Staðsetning", + "field.blocks.video.loop": "Lykkja", + "field.blocks.video.muted": "Þaggað", + "field.blocks.video.name": "Myndskeið", + "field.blocks.video.placeholder": "Vefslóð myndskeiðs (URL)", + "field.blocks.video.poster": "Plakkat", + "field.blocks.video.preload": "Forhlaða", + "field.blocks.video.url.label": "Vefslóð", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Ætlar þú virkilega að eyða öllum færslum?", + "field.entries.empty": "Engar færslur enn", + + "field.files.empty": "Engar skrár valdar ennþá", + "field.files.empty.single": "Engin skrá valin enn", + + "field.layout.change": "Breyta uppsetningu ramma", + "field.layout.delete": "Eyða ramma", + "field.layout.delete.confirm": "Ætlarðu virkilega að eyða þessum ramma?", + "field.layout.delete.confirm.all": "Ætlarðu virkilega að eyða öllum römmum?", + "field.layout.empty": "Nei. Engir rammar enn.", + "field.layout.select": "Veldu rammategund", + + "field.object.empty": "Engar upplýsingar enn", + + "field.pages.empty": "Engar síður valdar ennþá", + "field.pages.empty.single": "Engin síða valin enn", + + "field.structure.delete.confirm": "Viltu virkilega eyða þessari röð?", + "field.structure.delete.confirm.all": "Ætlar þú virkilega að eyða öllum færslum?", + "field.structure.empty": "Engar færslur enn", + + "field.users.empty": "Engir notendur valdir enn", + "field.users.empty.single": "Enginn notandi valinn enn", + + "fields.empty": "Hér eru engin svið enn", + + "file": "Skrár", + "file.blueprint": "Þessi skrá hefur ekki skipan (e. blueprint) ennþá. Þú mátt skilgreina skipanina í /site/blueprints/{template}.yml", + "file.changeTemplate": "Breyta sniðmáti", + "file.changeTemplate.notice": "Að breyta sniðmáti skránnar mun fjarlæjga efnið er tilheyrir þeim sviðum er ekki passar við viðkomandi gerð. Ef nýja sniðmátið er skilgreint með ákveðnum reglum s.s. stærð mynda þá verða þær breytingar óafturkræfar. Notist með gát.", + "file.delete.confirm": "Ætlarðu virkilega að eyða
{filename}?", + "file.focus.placeholder": "Settu brennipunkt", + "file.focus.reset": "Fjarlægðu brennipunkt", + "file.focus.title": "Fókus", + "file.sort": "Breyta röðun", + + "files": "Skrár", + "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 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", + "form.preview.draft": "Skoða uppkast", + + "hide": "Fela", + "hour": "Klukkustund", + "hue": "Blær", + "import": "Hlaða inn", + "info": "Upplýsingar", + "insert": "Setja inn", + "insert.after": "Setja eftir", + "insert.before": "Setja fyrir", + "install": "Setja upp", + + "installation": "Uppsettning", + "installation.completed": "Panellinn er uppsettur", + "installation.disabled": "Paneluppsetning er sjálfgefið óvirk á vefþjónum á Veraldarvefnum. Reyndu frekar að setja Panelinn upp í lokuðu umhverfi eða virkjaðu panel.install möguleikan.", + "installation.issues.accounts": "/site/accounts mappan er annaðhvort ekki til eða er ekki skrifanleg.", + "installation.issues.content": "/content mappan er annaðhvort ekki til eða er ekki skrifanleg", + "installation.issues.curl": "CURL viðbótin er hér bráðnauðsynleg", + "installation.issues.headline": "Uppsetning Panelsins mistókst hrapalega", + "installation.issues.mbstring": "MB String er hér bráðnauðsynleg", + "installation.issues.media": "/media mappan er annaðhvort ekki til eða er ekki skrifanleg", + "installation.issues.php": "Notaðu PHP 8+", + "installation.issues.sessions": "/site/sessions mappan er annaðhvort ekki til eða er ekki skrifanleg", + + "language": "Tungumál", + "language.code": "Kóði", + "language.convert": "Gera sjálfgefið", + "language.convert.confirm": "

Ertu viss um að þú viljir breyta {name} í sjálfgefið (lesist aðal) tungumál? Þessu verður ekki viðsnúið.

Ef {name} hefur innihald sem ekki hefur verið þýtt, þá verða engir möguleikar til þrautarvara og hluti vefsins gæti birtst tómur.

", + "language.create": "Bættu við nýju tungumáli", + "language.default": "Aðal tungumál", + "language.delete.confirm": "Ertu nú viss um að þú viljir eyða {name} og öllum tilheyrandi þýðingum? Þetta verður ekki tekið til baka!", + "language.deleted": "Tungumálinu hefur verið eytt", + "language.direction": "Lesátt", + "language.direction.ltr": "Vinstra til hægri", + "language.direction.rtl": "Hægra til vinstri", + "language.locale": "PHP locale strengur", + "language.locale.warning": "Þú ert að nota sérsniðna locale uppsetningu. Vinsamlegast breyttu tungumálaskránni á slóðinni /site/languages", + "language.name": "Nafn tungumáls", + "language.secondary": "Auka tungumál", + "language.settings": "Tungumálastillingar", + "language.updated": "Tungumálið hefur verið uppfært", + "language.variables": "Tungumálabreytur", + "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", + + "languages": "Tungumál", + "languages.default": "Aðal tungumál", + "languages.empty": "Það eru engin frekari tungumál skilgreind enn", + "languages.secondary": "Auka tungumál", + "languages.secondary.empty": "Það eru engin auka tungumál skilgreind enn", + + "license": "Leyfi", + "license.activate": "Virkja þetta nú", + "license.activate.label": "Vinsamlegast virkjaðu leyfið þitt", + "license.activate.domain": "Leyfið þitt verður virkjað fyrir og tengt við {host}.", + "license.activate.local": "Þú ert að fara virkja leyfið þitt fyrir staðbundinn (e. local) vef: {host}. Ef það er meiningin að færa vefinn síðar út á netið þá vinsamlegast virkjaðu leyfið þar. Ef {host} er lénið sem þú vilt tengja leyfið við þá vinsamlegast haltu áfram.", + "license.activated": "Virkjað", + "license.buy": "Kaupa leyfi", + "license.code": "Kóðasnið", + "license.code.help": "Þú fékkst leyfiskóðan sendan í tölvupósti eftir að þú borgaðir fyrir leyfið. Vinsamlegast afritaðu hann hingað.", + "license.code.label": "Vinsamlegast settu inn leyfiskóðan", + "license.status.active.info": "Felur í sér allar útgáfur þar til {date}", + "license.status.active.label": "Gilt skráningarleyfi", + "license.status.demo.info": "Þessi uppsetning er til prófunar.", + "license.status.demo.label": "Prófunarútgáfa", + "license.status.inactive.info": "Endurnýja skráningarleyfi fyrir uppfærslur á nýjum útgáfum", + "license.status.inactive.label": "Engar nýjar útgáfur", + "license.status.legacy.bubble": "Klár í að endurnýja skráningarleyfið?", + "license.status.legacy.info": "Skráningarleyfið þitt og sá kóði sem fylgir gildir ekkert fyrir þessa útgáfu", + "license.status.legacy.label": "Vinsamlegast endurnýjaðu skráningarleyfið þitt", + "license.status.missing.bubble": "Er allt tilbúið til að gefa vefinn út?", + "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": "Óþekkt", + "license.manage": "Sýslaðu með leyfin þín", + "license.purchased": "Verslað", + "license.success": "Þakka þér fyrir að velja Kirby", + "license.unregistered.label": "Óskráð", + + "link": "Tengill", + "link.text": "Tengilstexti", + + "loading": "Hleð", + + "lock.unsaved": "Óvistað breytingar", + "lock.unsaved.empty": "Það eru öngvar óvistaðar breytingar", + "lock.unsaved.files": "Óvistaðar skrár", + "lock.unsaved.pages": "Óvistaðar síður", + "lock.unsaved.users": "Óvistaðir notendareikningar", + "lock.isLocked": "Óvistaðar breytingar framkvæmdar af {email}", + "lock.unlock": "Aflæsa", + "lock.unlock.submit": "Aflæsa og yfirskrifa óvistaðar breytingar framkvæmdar af {email}", + "lock.isUnlocked": "Læsing fjalægð af öðrum notanda", + + "login": "Innskrá", + "login.code.label.login": "Innskráningarkóði", + "login.code.label.password-reset": "Kóði fyrir endurstillingu lykilorðs", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Ef netfangið þitt er skráð þá bíður þín nýr tölvupóstur.", + "login.code.text.totp": "Settu inn kóðan frá auðkenningar appinu.", + "login.email.login.body": "Já halló {user.nameOrEmail},\n\nNýlega baðstu um innskráningarkóða fyrir bakendan á {site}.\nEftirfarandi kóði er virkur í {timeout} mínútur:\n\n{code}\n\nEf þú óskaðir ekki eftir þessu þá hunsaðu þennan tölvupóst eða talaðu við vefstjóran ef þú vilt fræðast nánar.\nAf öryggisástæðum vinsamlegast áframsendu þennan tölvupóst ALLS EKKI.", + "login.email.login.subject": "Innskráningarkóðinn þinn", + "login.email.password-reset.body": "Nei halló {user.nameOrEmail},\n\nNýverið baðstu um að lykilorði þínu væri endurstillt fyrir bakendan á {site}. \nEftirfarandi kóði er virkur í {timeout} mínútur:\n\n{code}\n\nEf þú óskaðir ekki eftir þessu þá hunsaðu þennan tölvupóst eða talaðu við vefstjóran ef þú vilt fræðast nánar.\nAf öryggisástæðum vinsamlegast áframsendu þennan tölvupóst ALLS EKKI.", + "login.email.password-reset.subject": "Kóðinn þinn fyrir endurstillingu lykilorðs", + "login.remember": "Vista innskráningu", + "login.reset": "Endurheimta lykilorð takk", + "login.toggleText.code.email": "Innskrá með netfangi", + "login.toggleText.code.email-password": "Innskrá með lykilorði", + "login.toggleText.password-reset.email": "Mannstu ekki lykilorðið?", + "login.toggleText.password-reset.email-password": "← Aftur í innskráningu", + "login.totp.enable.option": "Setja upp einnota kóða.", + "login.totp.enable.intro": "Auðkenningaröpp framleiða einnota sex stafa kóða sem notaður er seinni þáttur þegar þú skráir þig inn.", + "login.totp.enable.qr.label": "Skannaðu QR kóðan.", + "login.totp.enable.qr.help": "Virkar ekki að skanna? Bættu við uppsetningarkóðanum {secret} fyrir auðkenningarappið.", + "login.totp.enable.confirm.headline": "2. Staðfestu með auðkenningar kóða", + "login.totp.enable.confirm.text": "Appið þitt framleiðir nýjan einnota kóða á 30 sekúndna fresti. Setti inn núverandi kóða til að ljúka uppsetningu.", + "login.totp.enable.confirm.label": "Núverandi kóði", + "login.totp.enable.confirm.help": "Eftir uppsetninguna þá munum við biðja um einnota kóða í hvert skipti sem þú skráir þig inn.", + "login.totp.enable.success": "Einnota skráningarkóði virkjaður", + "login.totp.disable.option": "Afvirkjaðir einnota kóðar.", + "login.totp.disable.label": "Sláðu inn lykilorðið þitt til að afvirkja einnota kóða.", + "login.totp.disable.help": "Framveigis þá mun nýr seinniþáttar kóði verða sendur í tölvupósti til þín þegar þú skráir þig inn. Þú munt alltaf geta sett upp einnota kóðana aftur síðar.", + "login.totp.disable.admin": "

Þetta mun afvirkja einnota kóða fyrir {user}.

Framvegis mun nýr seinniþáttarkóði verða sendur í tölvupósti þegar notendur skrá sig inn. {user} getur sett upp einnota kóðana eftir næstu innskráningu.

", + "login.totp.disable.success": "Einnota skráningarkóði afvirkjaður", + + "logout": "Útskrá", + + "merge": "Splæsa", + "menu": "Valmynd", + "meridiem": "AM/PM", + "mime": "Miðilsgerð", + "minutes": "Mínútur", + + "month": "Mánuður", + "months.april": "Apríl", + "months.august": "Ágúst", + "months.december": "Desember", + "months.february": "Febrúar", + "months.january": "Janúar", + "months.july": "Júlí", + "months.june": "Júní", + "months.march": "Mars", + "months.may": "Maí", + "months.november": "Nóvember", + "months.october": "Október", + "months.september": "September", + + "more": "Meira", + "move": "Færa", + "name": "Nafn", + "next": "Næst", + "night": "Nótt", + "no": "nei", + "off": "Af", + "on": "Á", + "open": "Opna", + "open.newWindow": "Opna í nýjum glugga", + "option": "Kostur", + "options": "Valmöguleikar", + "options.none": "Engir valmöguleikar", + "options.all": "Sýna alla {count} möguleika", + + "orientation": "Snúningur", + "orientation.landscape": "Langsnið", + "orientation.portrait": "Skammsnið", + "orientation.square": "Ferningur", + + "page": "Síða", + "page.blueprint": "Þessi síða hefur ekki skipan (e. blueprint) ennþá. Þú mátt skilgreina skipanina í /site/blueprints/{template}.yml", + "page.changeSlug": "Breyta vefslóð", + "page.changeSlug.fromTitle": "Slóð af titli", + "page.changeStatus": "Breyta stöðu", + "page.changeStatus.position": "Veldu ákjósanlega röðun", + "page.changeStatus.select": "Veldu nýja stöðu", + "page.changeTemplate": "Breyta sniðmáti", + "page.changeTemplate.notice": "Að breyta sniðmáti síðunnar mun fjarlægja efni fyrir svið er ekki passa við gerð nýja sniðmátsins. Notist með gát.", + "page.create": "Stofna", + "page.delete.confirm": "Viltu virkilega farga {title}?", + "page.delete.confirm.subpages": "Þessi síða hefur undirsíður.
Þeim mun verða fargað líka.", + "page.delete.confirm.title": "Skráðu síðutitilinn til staðfestingar", + "page.duplicate.appendix": "Afrita", + "page.duplicate.files": "Afrita skrár", + "page.duplicate.pages": "Afrita síður", + "page.move": "Færa síðu", + "page.sort": "Breyta röðun", + "page.status": "Staða", + "page.status.draft": "Uppkast", + "page.status.draft.description": "Þessi síða er uppkast og er aðeins sýnileg vefstjórum eða gegnum laumu tengil.", + "page.status.listed": "Útgefin og listuð", + "page.status.listed.description": "Síðan er útgefin og listuð.", + "page.status.unlisted": "Útgefin en ólistuð", + "page.status.unlisted.description": "Síðan er útgefin en þó ólistuð.", + + "pages": "Síður", + "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", + "pages.status.unlisted": "Útgefnar en ólistaðar", + + "pagination.page": "Síða", + + "password": "Lykilorð", + "paste": "Líma", + "paste.after": "Líma eftir", + "paste.success": "{count} límt!", + "pixel": "Punkta", + "plugin": "Viðbót", + "plugins": "Viðbætur", + "prev": "Fyrri", + "preview": "Forskoða", + + "publish": "Útgefa", + "published": "Útgefnar og listaðar", + + "remove": "Fjarlægja", + "rename": "Endurnefna", + "renew": "Endurnýja", + "replace": "Setja í stað", + "replace.with": "Endursetja með", + "retry": "Reyndu aftur", + "revert": "Taka upp fyrri siði", + "revert.confirm": "Viltu virkilega eyða öllum óvistuðum breytingum?", + + "role": "Hlutverk", + "role.admin.description": "Stjórinn hefur öll réttindi", + "role.admin.title": "Stjóri", + "role.all": "Öll", + "role.empty": "Það eru engir notendur með þetta hlutverk", + "role.description.placeholder": "Engin lýsing", + "role.nobody.description": "Þetta hlutverk er til þrautarvara en hefur engin réttindi", + "role.nobody.title": "Enginn", + + "save": "Vista", + "saved": "Vistað", + "search": "Leita", + "searching": "Leita ..", + "search.min": "Lágmark {min} stafir til að leita", + "search.all": "Sýna allar {count} niðurstöður.", + "search.results.none": "Engar niðurstöður", + + "section.invalid": "Þetta svæði er bara ógillt sem stendur.", + "section.required": "Þetta svæði er nauðsynlegt", + + "security": "Öryggi", + "select": "Velja", + "server": "Vefþjónn", + "settings": "Stillingar", + "show": "Sýna", + "site.blueprint": "Þessi vefur hefur ekki skipan (e. blueprint) ennþá. Þú mátt skilgreina skipanina í /site/blueprints/site.yml", + "size": "Stærð", + "slug": "Slóðar viðskeyti", + "sort": "Raða", + "sort.drag": "Dragðu til að raða", + "split": "Skipta", + + "stats.empty": "Engar skýrslur", + "status": "Staða", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "Efnismappan virðist vera berskjölduð", + "system.issues.eol.kirby": "Uppsett Kirby eintak þitt hefur runnið sitt skeið á enda og mun ekki verða uppfært framar", + "system.issues.eol.plugin": "Uppsett eintak þitt af viðbótinni { plugin } hefur runnið sitt skeið á enda og mun ekki verða uppfærð framar", + "system.issues.eol.php": "Núverandi PHP útgáfa {release} hefur runnið sitt skeið og mun ekki verða uppfærð með öryggisuppfærslum.", + "system.issues.debug": "Aflúsun ætti alltaf að vera óvirk í útgefnum vef", + "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": "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": "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", + "system.updateStatus.error": "Gat því miður ekki athugað með uppfærslur", + "system.updateStatus.not-vulnerable": "Engir þekktir veikleikar", + "system.updateStatus.security-update": "Ókeypis öryggisuppfærsla { version } fáanleg", + "system.updateStatus.security-upgrade": "Uppfærsla { version } með öryggisuppfærslum fáanleg", + "system.updateStatus.unreleased": "Þróunarútgáfa", + "system.updateStatus.up-to-date": "Allt spikk og span", + "system.updateStatus.update": "Ókeypis uppfærsla { version } fáanleg", + "system.updateStatus.upgrade": "Uppfærsla fyrir { version } fáanleg", + + "tel": "Sími", + "tel.placeholder": "+3548561234", + "template": "Sniðmát", + + "theme": "Þema", + "theme.light": "Ljósin kveikt", + "theme.dark": "Ljósin slökkt", + "theme.automatic": "Nota kerfisstillingu", + + "title": "Titill", + "today": "Núna", + + "toolbar.button.clear": "Hreinsa snið", + "toolbar.button.code": "Kóðasnið", + "toolbar.button.bold": "Feitletrun", + "toolbar.button.email": "Netfang", + "toolbar.button.headings": "Fyrirsagnir", + "toolbar.button.heading.1": "Fyrirsögn 1", + "toolbar.button.heading.2": "Fyrirsögn 2", + "toolbar.button.heading.3": "Fyrirsögn 3", + "toolbar.button.heading.4": "Fyrirsögn 4", + "toolbar.button.heading.5": "Fyrirsögn 5", + "toolbar.button.heading.6": "Fyrirsögn 6", + "toolbar.button.italic": "Skáletrun", + "toolbar.button.file": "Skrár", + "toolbar.button.file.select": "Veldu skrá", + "toolbar.button.file.upload": "Hlaða inn skrá", + "toolbar.button.link": "Tengill", + "toolbar.button.paragraph": "Efnisgrein", + "toolbar.button.strike": "Gegnumstrika", + "toolbar.button.sub": "Hnéletur", + "toolbar.button.sup": "Höfuðletur", + "toolbar.button.ol": "Raðaður listi", + "toolbar.button.underline": "Undirstrika", + "toolbar.button.ul": "Áherslumerktur listi", + + "translation.author": "Kirby Teymið", + "translation.direction": "ltr", + "translation.name": "Íslenska", + "translation.locale": "is_IS", + + "type": "Gerð", + + "upload": "Hlaða inn", + "upload.error.cantMove": "Innhlöðnu skránni var ekki haggað", + "upload.error.cantWrite": "Það mistókst að skrifa skránna í geymslu", + "upload.error.default": "Ekki heppnaðist að hlaða inn skránni", + "upload.error.extension": "Innhleðsla stöðvuð vegna skrárendingar", + "upload.error.formSize": "Innhlaðna skráin er stærri en MAX_FILE_SIZE leyfilegt er.", + "upload.error.iniPostSize": "Innhlaðna skráin er stærri en því sem nemur í post_max_size stillingunni í php.ini", + "upload.error.iniSize": "Innhlaðna skráin er stærri en því sem nemur í upload_max_filesize stillingunni í php.ini", + "upload.error.noFile": "Engri skrá far hlaðið inn", + "upload.error.noFiles": "Engum skrám var hlaðið inn", + "upload.error.partial": "Innhlöðnu skránni var aðeins sótt að hluta", + "upload.error.tmpDir": "Vantar skruggumöppu", + "upload.errors": "Villa", + "upload.progress": "Hleð inn…", + + "url": "Slóð", + "url.placeholder": "https://tildaem.is/", + + "user": "Notandi", + "user.blueprint": "Þér er óhætt að skilgreina fleiri svæði fyrir þetta notenda hlutverk í /site/blueprints/users/{role}.yml", + "user.changeEmail": "Breyta netfangi", + "user.changeLanguage": "Breyta tungumáli", + "user.changeName": "Endurnefna þennan notanda", + "user.changePassword": "Breyta lykilorð", + "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", + "user.changeRole.select": "Veldu nýtt hlutverk", + "user.create": "Bæta við nýjum notenda", + "user.delete": "Farga þessum notenda", + "user.delete.confirm": "Viltu virkilega eyða
{email}?", + + "users": "Notendur", + + "version": "Útgáfa", + "version.changes": "Breytt útgáfa", + "version.compare": "Bera saman útgáfur", + "version.current": "Núverandi útgáfa", + "version.latest": "Nýjasta útgáfa", + "versionInformation": "Útgáfuupplýsingar", + + "view": "Sýn", + "view.account": "Þínar stillingar", + "view.installation": "Uppsetning", + "view.languages": "Tungumál", + "view.resetPassword": "Endurstilla lykilorð", + "view.site": "Vefsvæðið", + "view.system": "Kerfi", + "view.users": "Notendur", + + "welcome": "Komið þér fagnandi", + "year": "Ár", + "yes": "já" +} diff --git a/public/kirby/i18n/translations/it.json b/public/kirby/i18n/translations/it.json new file mode 100644 index 0000000..59bb729 --- /dev/null +++ b/public/kirby/i18n/translations/it.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Cambia il tuo nome", + "account.delete": "Elimina l'account", + "account.delete.confirm": "Vuoi davvero eliminare il tuo account? Verrai disconnesso immediatamente. Il tuo account non potrà essere recuperato.", + + "activate": "Attiva", + "add": "Aggiungi", + "alpha": "Alpha", + "author": "Autore", + "avatar": "Immagine del profilo", + "back": "Indietro", + "cancel": "Annulla", + "change": "Cambia", + "close": "Chiudi", + "changes": "Changes", + "confirm": "OK", + "collapse": "Comprimi", + "collapse.all": "Comprimi tutto", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Copia", + "copy.all": "Copia tutto", + "copy.success": "Copiato", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copia URL", + "create": "Crea", + "custom": "Custom", + + "date": "Data", + "date.select": "Scegli una data", + + "day": "Giorno", + "days.fri": "Ve", + "days.mon": "Lu", + "days.sat": "Sa", + "days.sun": "Do", + "days.thu": "Gi", + "days.tue": "Ma", + "days.wed": "Me", + + "debugging": "Debugging", + + "delete": "Elimina", + "delete.all": "Elimina tutti", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "Nessun file selezionabile", + "dialog.pages.empty": "Nessuna pagina selezionabile", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Nessuno user selezionabile", + + "dimensions": "Dimensioni", + "disable": "Disattiva", + "disabled": "Disabilitato", + "discard": "Abbandona", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Dominio", + "download": "Scarica", + "duplicate": "Duplica", + + "edit": "Modifica", + + "email": "Email", + "email.placeholder": "mail@esempio.com", + + "enter": "Enter", + "entries": "Voci", + "entry": "Voce", + + "environment": "Ambiente", + + "error": "Error", + "error.access.code": "Codice non valido", + "error.access.login": "Login invalido", + "error.access.panel": "Non ti è permesso accedere al pannello", + "error.access.view": "Non ti è permesso accedere a questa parte del pannello", + + "error.avatar.create.fail": "Non è stato possibile caricare l'immagine del profilo", + "error.avatar.delete.fail": "Non è stato possibile eliminare l'immagine del profilo", + "error.avatar.dimensions.invalid": "Per favore mantieni l'altezza e la larghezza dell'immagine del profilo inferiore ai 3000 pixel", + "error.avatar.mime.forbidden": "L'immagine del profilo dev'essere un file JPEG o PNG", + + "error.blueprint.notFound": "Non è stato possibile caricare il blueprint \"{name}\"", + + "error.blocks.max.plural": "Non puoi aggiungere più di {max} blocchi", + "error.blocks.max.singular": "Non puoi aggiungere più di un blocco", + "error.blocks.min.plural": "Devi aggiungere almeno {min} blocchi", + "error.blocks.min.singular": "Devi aggiungere almeno un blocco", + "error.blocks.validation": "C'è un errore sul campo \"{field}\" nel blocco {index} che utilizza il tipo di blocco \"{fieldset}\"", + + "error.cache.type.invalid": "Tipo di cache \"{type}\" non valido", + + "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": "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": "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", + "error.file.changeName.permission": "Non ti è permesso modificare il nome di \"{filename}\"", + "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": "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}", + "error.file.extension.missing": "Il file \"{filename}\" non ha estensione", + "error.file.maxheight": "L'immagine non dev'essere più alta di {height} pixel", + "error.file.maxsize": "Il file è troppo pesante", + "error.file.maxwidth": "L'immagine non dev'essere più larga di {width} pixel", + "error.file.mime.differs": "Il file caricato dev'essere dello stesso MIME type \"{mime}\"", + "error.file.mime.forbidden": "Il MIME type \"{mime}\" non è consentito", + "error.file.mime.invalid": "Tipo mime non valido: {mime}", + "error.file.mime.missing": "Il MIME type per \"{filename}\" non può essere rilevato", + "error.file.minheight": "L'immagine dev'essere alta almeno {height} pixel", + "error.file.minsize": "Il file è troppo leggero", + "error.file.minwidth": "L'immagine dev'essere larga almeno {width} pixel", + "error.file.name.unique": "The filename must be unique", + "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": "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", + + "error.form.incomplete": "Correggi tutti gli errori nel form...", + "error.form.notSaved": "Non è stato possibile salvare il form", + + "error.language.code": "Inserisci un codice valido per la lingua", + "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": "La lingua esiste già", + "error.language.name": "Inserisci un nome valido per la lingua", + "error.language.notFound": "La lingua non è stata trovata", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "C'è un errore sul campo \"{field}\" nel blocco {blockIndex} che utilizza il tipo di blocco \"{fieldset}\" nel layout {layoutIndex}", + "error.layout.validation.settings": "C'è un errore nelle impostazioni del layout {index}", + + "error.license.domain": "Il dominio per la licenza è assente", + "error.license.email": "Inserisci un indirizzo email valido", + "error.license.format": "Per favore inserisci un codice di licenza valido", + "error.license.verification": "Non è stato possibile verificare la licenza", + + "error.login.totp.confirm.invalid": "Codice non valido", + "error.login.totp.confirm.missing": "Inserisci il codice attuale", + + "error.object.validation": "C'è un errore nel campo \"{label}\":\n{message}", + + "error.offline": "Il pannello di controllo è attualmente offline", + + "error.page.changeSlug.permission": "Non ti è permesso cambiare l'URL di \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "La pagina contiene errori e non può essere pubblicata", + "error.page.changeStatus.permission": "Lo stato di questa pagina non può essere cambiato", + "error.page.changeStatus.toDraft.invalid": "La pagina \"{slug}\" non può essere convertita in bozza", + "error.page.changeTemplate.invalid": "Il template della pagina \"{slug}\" non può essere cambiato", + "error.page.changeTemplate.permission": "Non ti è permesso modificare il template di \"{slug}\"", + "error.page.changeTitle.empty": "Il titolo non può essere vuoto", + "error.page.changeTitle.permission": "Non ti è permesso modificare il titolo di \"{slug}\"", + "error.page.create.permission": "Non ti è permesso creare \"{slug}\"", + "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": "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à", + "error.page.duplicate.permission": "Non ti è permesso duplicare \"{slug}\"", + "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": "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}\"", + "error.page.notFound": "La pagina \"{slug}\" non è stata trovata", + "error.page.num.invalid": "Inserisci un numero di ordinamento valido. I numeri non devono essere negativi", + "error.page.slug.invalid": "Per favore inserisci un suffisso valido per l'URL", + "error.page.slug.maxlength": "Lo \"slug\" dev'essere più corto di \"{length}\" caratteri", + "error.page.sort.permission": "La pagina \"{slug}\" non può essere ordinata", + "error.page.status.invalid": "Imposta uno stato valido per la pagina", + "error.page.undefined": "La pagina non \u00e8 stata trovata", + "error.page.update.permission": "Non ti è permesso modificare \"{slug}\"", + + "error.section.files.max.plural": "Non puoi aggiungere più di {max} file alla sezione \"{section}\"", + "error.section.files.max.singular": "Non puoi aggiungere più di un file alla sezione \"{section}\"", + "error.section.files.min.plural": "La sezione \"{section}\" richiede almeno {min} file", + "error.section.files.min.singular": "La sezione \"{section}\" richiede almeno un file", + + "error.section.pages.max.plural": "Non puoi aggiungere più di {max} pagine alla sezione \"{section}\"", + "error.section.pages.max.singular": "Non puoi aggiungere più di una pagina alla sezione \"{section}\"", + "error.section.pages.min.plural": "La sezione \"{section}\" richiede almeno {min} pagine", + "error.section.pages.min.singular": "La sezione \"{section}\" richiede almeno una pagina", + + "error.section.notLoaded": "Non è stato possibile caricare la sezione \"{name}\"", + "error.section.type.invalid": "Il tipo di sezione \"{type}\" non è valido", + + "error.site.changeTitle.empty": "Il titolo non può essere vuoto", + "error.site.changeTitle.permission": "Non ti è permesso modificare il titolo del sito", + "error.site.update.permission": "Non ti è permesso modificare i contenuti globali del sito", + + "error.structure.validation": "C'è un errore nel campo \"{field}\" nella riga {index}", + + "error.template.default.notFound": "Il template \"default\" non esiste", + + "error.unexpected": "Si è verificato un errore inaspettato! Abilita la modalità \"debug\" per ulteriori informazioni: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Non ti è permesso modificare l'indirizzo email di \"{name}\"", + "error.user.changeLanguage.permission": "Non ti è permesso modificare la lingua per l'utente \"{name}\"", + "error.user.changeName.permission": "Non ti è permesso modificare il nome dell'utente \"{name}\"", + "error.user.changePassword.permission": "Non ti è permesso modificare la password dell'utente \"{name}\"", + "error.user.changeRole.lastAdmin": "Il ruolo dell'ultimo amministratore non può esser cambiato", + "error.user.changeRole.permission": "Non ti è permesso modificare il ruolo dell'utente \"{name}\"", + "error.user.changeRole.toAdmin": "Non ti è permesso assegnare il ruolo di amministratore ad altri utenti", + "error.user.create.permission": "Non ti è permesso creare questo utente", + "error.user.delete": "L'utente non pu\u00f2 essere eliminato", + "error.user.delete.lastAdmin": "L'ultimo amministratore non può essere eliminato", + "error.user.delete.lastUser": "L'ultimo utente non può essere eliminato", + "error.user.delete.permission": "Non ti \u00e8 permesso eliminare questo utente ", + "error.user.duplicate": "Esiste già un utente con l'indirizzo email \"{email}\"", + "error.user.email.invalid": "Inserisci un indirizzo email valido", + "error.user.language.invalid": "Inserisci una lingua valida", + "error.user.notFound": "L'utente non \u00e8 stato trovato", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Per favore inserisci una password valida. Le password devono essere lunghe almeno 8 caratteri", + "error.user.password.notSame": "Le password non corrispondono", + "error.user.password.undefined": "L'utente non ha una password", + "error.user.password.wrong": "Password sbagliata", + "error.user.role.invalid": "Inserisci un ruolo valido", + "error.user.undefined": "L'utente non è stato trovato", + "error.user.update.permission": "Non ti è permesso aggiornare l'utente \"{name}\"", + + "error.validation.accepted": "Per favore conferma", + "error.validation.alpha": "Puoi inserire solo caratteri tra a-z", + "error.validation.alphanum": "Puoi inserire solo caratteri tra a-z e numeri 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Inserisci un valore tra \"{min}\" e \"{max}\"", + "error.validation.boolean": "Per favore conferma o nega", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Inserisci un valore che contiene \"{needle}\"", + "error.validation.date": "Inserisci una data valida", + "error.validation.date.after": "Inserisci una data dopo il {date}", + "error.validation.date.before": "Inserisci una data prima del {date}", + "error.validation.date.between": "Inserisci una data tra {min} e {max}", + "error.validation.denied": "Per favore nega", + "error.validation.different": "Il valore non dev'essere \"{other}\"", + "error.validation.email": "Inserisci un indirizzo email valido", + "error.validation.endswith": "Il valore non deve finire con \"{end}\"", + "error.validation.filename": "Inserisci un nome del file valido", + "error.validation.in": "Inserisci uno dei seguenti valori: ({in})", + "error.validation.integer": "Inserisci un numero intero", + "error.validation.ip": "Inserisci un indirizzo IP valido", + "error.validation.less": "Inserisci un valore inferiore a {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Il valore non corrisponde al pattern previsto", + "error.validation.max": "Inserisci un valore inferiore o uguale a {max}", + "error.validation.maxlength": "Inserisci un testo più corto. (max. {max} caratteri)", + "error.validation.maxwords": "Non inserire più di {max} parola/e", + "error.validation.min": "Inserisci un valore superiore o uguale a {min}", + "error.validation.minlength": "Inserisci un testo più lungo. (min. {min} caratteri)", + "error.validation.minwords": "Inserisci almeno {min} parola/e", + "error.validation.more": "Inserisci un valore superiore a {min}", + "error.validation.notcontains": "Inserisci un valore che non contenga \"{needle}\"", + "error.validation.notin": "Non inserire nessuno dei valori seguenti: ({notIn})", + "error.validation.option": "Seleziona un'opzione valida", + "error.validation.num": "Inserisci un numero valido", + "error.validation.required": "Inserisci qualcosa", + "error.validation.same": "Inserisci \"{other}\"", + "error.validation.size": "La dimensione del valore dev'essere \"{size}\"", + "error.validation.startswith": "Il valore deve iniziare con \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Inserisci un orario valido", + "error.validation.time.after": "Inserisci un orario dopo le {time}", + "error.validation.time.before": "Inserisci un orario prima delle {time}", + "error.validation.time.between": "Inserisci un orario tra le {min} e le {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Inserisci un URL valido", + + "expand": "Espandi", + "expand.all": "Espandi tutto", + + "field.invalid": "The field is invalid", + "field.required": "Il campo è obbligatorio", + "field.blocks.changeType": "Cambia tipo", + "field.blocks.code.name": "Codice", + "field.blocks.code.language": "Lingua", + "field.blocks.code.placeholder": "Il tuo codice …", + "field.blocks.delete.confirm": "Vuoi veramente eliminare questo blocco?", + "field.blocks.delete.confirm.all": "Vuoi veramente eliminare tutti i blocchi? ", + "field.blocks.delete.confirm.selected": "Vuoi veramente eliminare i blocchi selezionati?", + "field.blocks.empty": "Nessun blocco inserito", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Seleziona il tipo di blocco …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galleria", + "field.blocks.gallery.images.empty": "Nessuna immagine inserita", + "field.blocks.gallery.images.label": "Immagini", + "field.blocks.heading.level": "Livello", + "field.blocks.heading.name": "Titolo", + "field.blocks.heading.text": "Testo", + "field.blocks.heading.placeholder": "Titolo …", + "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", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Posizione", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Immagine", + "field.blocks.image.placeholder": "Seleziona un'immagine", + "field.blocks.image.ratio": "Rapporto", + "field.blocks.image.url": "URL immagine", + "field.blocks.line.name": "Linea", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Testo", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Citazione", + "field.blocks.quote.text.label": "Testo", + "field.blocks.quote.text.placeholder": "Citazione …", + "field.blocks.quote.citation.label": "Fonte", + "field.blocks.quote.citation.placeholder": "di …", + "field.blocks.text.name": "Testo", + "field.blocks.text.placeholder": "Testo …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Didascalia", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Posizione", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Inserisci un URL di un video", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "URL Video", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Vuoi davvero cancellare tutte le voci?", + "field.entries.empty": "Non contiene ancora voci", + + "field.files.empty": "Nessun file selezionato", + "field.files.empty.single": "Nessun file selezionato", + + "field.layout.change": "Change layout", + "field.layout.delete": "Elimina layout", + "field.layout.delete.confirm": "Vuoi veramente eliminare questo layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Nessuna riga inserita", + "field.layout.select": "Scegli un layout", + + "field.object.empty": "Ancora nessuna informazione", + + "field.pages.empty": "Nessuna pagina selezionata", + "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 contiene ancora voci", + + "field.users.empty": "Nessun utente selezionato", + "field.users.empty.single": "Nessun utente selezionato", + + "fields.empty": "No fields yet", + + "file": "File", + "file.blueprint": "Questo file non ha ancora un blueprint. Puoi definire la sua configurazione in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Cambia template", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Sei sicuro di voler eliminare questo file?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Cambia posizione", + + "files": "Files", + "files.delete.confirm.selected": "Vuoi davvero eliminare i file selezionati? Questa azione è irreversibile.", + "files.empty": "Nessun file caricato", + + "filter": "Filter", + + "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", + "hue": "Hue", + "import": "Importa", + "info": "Info", + "insert": "Inserisci", + "insert.after": "Inserisci dopo", + "insert.before": "Inserisci prima", + "install": "Installa", + + "installation": "Installazione", + "installation.completed": "Il pannello è stato installato", + "installation.disabled": "L'installazione del pannello è disabilitata di default sui server pubblici. Esegui l'installazione in locale oppure abilitala usando l'opzione panel.install.", + "installation.issues.accounts": "/site/accounts non esiste o non dispone dei permessi di scrittura", + "installation.issues.content": "La cartella /content non esiste o non dispone dei permessi di scrittura", + "installation.issues.curl": "È necessaria l'estensione CURL", + "installation.issues.headline": "Il pannello non può esser installato", + "installation.issues.mbstring": "È necessaria l'estensione MB String", + "installation.issues.media": "La cartella /media non esiste o non dispone dei permessi di scrittura", + "installation.issues.php": "Assicurati di utilizzare PHP 8+", + "installation.issues.sessions": "La cartella /site/sessionsnon esiste o non dispone dei permessi di scrittura", + + "language": "Lingua", + "language.code": "Codice", + "language.convert": "Imposta come predefinito", + "language.convert.confirm": "

Sei sicuro di voler convertire {name} nella lingua predefinita? Questa operazione non può essere annullata.

Se {name} non contiene tutte le traduzioni, non ci sarà più una versione alternativa valida e parti del sito potrebbero rimanere vuote.

", + "language.create": "Aggiungi una nuova lingua", + "language.default": "Lingua di default", + "language.delete.confirm": "Sei sicuro di voler eliminare la lingua {name} con tutte le traduzioni? Non sarà possibile annullare!", + "language.deleted": "La lingua è stata eliminata", + "language.direction": "Direzione di lettura", + "language.direction.ltr": "Sinistra a destra", + "language.direction.rtl": "Destra a sinistra", + "language.locale": "Stringa \"PHP locale\"", + "language.locale.warning": "Stai usando una impostazione personalizzata per il locale. Modificalo nel file della lingua situato in /site/languages", + "language.name": "Nome", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "La lingua è stata aggiornata", + "language.variables": "Language variables", + "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", + + "languages": "Lingue", + "languages.default": "Lingua di default", + "languages.empty": "Non ci sono lingue impostate", + "languages.secondary": "Lingue secondarie", + "languages.secondary.empty": "Non ci sono lingue secondarie impostate", + + "license": "Licenza di Kirby", + "license.activate": "Attiva ora", + "license.activate.label": "Attiva la tua licenza ora", + "license.activate.domain": "La tua licenza sarà attivata per {host}.", + "license.activate.local": "Stai per attivare la licenza per il dominio locale {host}. Se questo sito verrà rilasciato su un dominio pubblico, ti preghiamo di attivarla lì. Se {host} è il dominio per il quale desideri ottenere la licenza di Kirby, procedi pure.", + "license.activated": "Attivata", + "license.buy": "Acquista una licenza", + "license.code": "Codice", + "license.code.help": "Hai ricevuto il codice di licenza tramite email dopo l'acquisto. Per favore inseriscilo per registrare Kirby.", + "license.code.label": "Inserisci il codice di licenza", + "license.status.active.info": "Comprende nuove versioni major entro il {date}", + "license.status.active.label": "Licenza valida", + "license.status.demo.info": "Questa è un'installazione demo", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Rinnova la licenza per aggiornare a nuove versioni major", + "license.status.inactive.label": "Nessuna nuova versione major", + "license.status.legacy.bubble": "Pronto per rinnovare la licenza?", + "license.status.legacy.info": "La tua licenza non include questa versione", + "license.status.legacy.label": "Per favore rinnova la tua licenza", + "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": "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", + "license.unregistered.label": "Non registrato", + + "link": "Link", + "link.text": "Testo del link", + + "loading": "Caricamento", + + "lock.unsaved": "Modifiche non salvate", + "lock.unsaved.empty": "Non ci sono altre modifiche non salvate", + "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}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Accedi", + "login.code.label.login": "Codice di accesso", + "login.code.label.password-reset": "Codice per reimpostare la password", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Qualora il tuo indirizzo email fosse registrato, il codice richiesto è stato inviato tramite email.", + "login.code.text.totp": "Inserisci il codice monouso dalla tua app di autenticazione.", + "login.email.login.body": "Ciao {user.nameOrEmail},\n\nHai recentemente richiesto un codice di login per il pannello di controllo di {site}.\nIl seguente codice di login sarà valido per {timeout} minuti:\n\n{code}\n\nSe non hai richiesto un codice di login, per favore ignora questa mail o contatta il tuo amministratore in caso di domande.\nPer sicurezza, per favore NON inoltrare questa email.", + "login.email.login.subject": "Il tuo codice di accesso", + "login.email.password-reset.body": "Ciao {user.nameOrEmail},\n\nHai recentemente richiesto di resettare la password per il pannello di controllo di {site}.\nIl seguente codice di reset della password sarà valido per {timeout} minuti:\n\n{code}\n\nSe non hai richiesto di resettare la password per favore ignora questa email o contatta il tuo amministratore in caso di domande.\nPer sicurezza, per favore NON inoltrare questa email.", + "login.email.password-reset.subject": "Il tuo codice di reimpostazione della password", + "login.remember": "Resta collegato", + "login.reset": "Reimposta la password", + "login.toggleText.code.email": "Accedi tramite email", + "login.toggleText.code.email-password": "Accedi con la password", + "login.toggleText.password-reset.email": "Hai dimenticato la password?", + "login.toggleText.password-reset.email-password": "← Torna al login", + "login.totp.enable.option": "Configura i codici monouso", + "login.totp.enable.intro": "Le app di autenticazione generano codici monouso che puoi usare come secondo fattore quando accedi al tuo account.", + "login.totp.enable.qr.label": "1. Scansiona il codice QR", + "login.totp.enable.qr.help": "Impossibile eseguire la scansione? Aggiungi manualmente la chiave di configurazione {secret} alla tua app di autenticazione.", + "login.totp.enable.confirm.headline": "2. Conferma con il codice generato", + "login.totp.enable.confirm.text": "La tua app genera un nuovo codice monouso ogni 30 secondi. Inserisci il codice attuale per completare la configurazione:", + "login.totp.enable.confirm.label": "Codice attuale", + "login.totp.enable.confirm.help": "Dopo la configurazione, ti chiederemo un codice monouso ogni volta che effettuerai l'accesso. ", + "login.totp.enable.success": "Codici monouso attivati", + "login.totp.disable.option": "Disattiva i codici monouso", + "login.totp.disable.label": "Inserisci la tua password per disattivare i codici monouso", + "login.totp.disable.help": "In futuro, un secondo fattore diverso, come un codice login inviato tramite email, sarà richiesto per l'accesso. Potrai sempre reimpostare i codici monouso più tardi.", + "login.totp.disable.admin": "

Questo disattiverà i codici monouso per {user}.

In futuro verrà richiesto un secondo fattore diverso per l'accesso, per esempio un codice inviato per email. {user} potrà impostare nuovamente i codici monouso dopo il suo prossimo accesso.

", + "login.totp.disable.success": "Codici monouso disattivati", + + "logout": "Esci", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "MIME Type", + "minutes": "Minuti", + + "month": "Mese", + "months.april": "Aprile", + "months.august": "Agosto", + "months.december": "Dicembre", + "months.february": "Febbraio", + "months.january": "Gennaio", + "months.july": "Luglio", + "months.june": "Giugno", + "months.march": "Marzo", + "months.may": "Maggio", + "months.november": "Novembre", + "months.october": "Ottobre", + "months.september": "Settembre", + + "more": "Di più", + "move": "Move", + "name": "Nome", + "next": "Prossimo", + "night": "Night", + "no": "no", + "off": "off", + "on": "on", + "open": "Apri", + "open.newWindow": "Apri in una finestra nuova", + "option": "Option", + "options": "Opzioni", + "options.none": "Nessuna opzione", + "options.all": "Mostra tutte le {count} opzioni", + + "orientation": "Orientamento", + "orientation.landscape": "Orizzontale", + "orientation.portrait": "Verticale", + "orientation.square": "Quadrato", + + "page": "Page", + "page.blueprint": "Questa pagina non ha ancora un blueprint. Puoi definire la sua configurazione in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Modifica URL", + "page.changeSlug.fromTitle": "Crea in base al titolo", + "page.changeStatus": "Cambia stato", + "page.changeStatus.position": "Scegli una posizione", + "page.changeStatus.select": "Seleziona un nuovo stato", + "page.changeTemplate": "Cambia template", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Crea come \"{status}\"", + "page.delete.confirm": "Sei sicuro di voler eliminare questa pagina?", + "page.delete.confirm.subpages": "Questa pagina ha sottopagine.
Anche tutte le sottopagine verranno eliminate.", + "page.delete.confirm.title": "Inserisci il titolo della pagina per confermare", + "page.duplicate.appendix": "Copia", + "page.duplicate.files": "Copia file", + "page.duplicate.pages": "Copia pagine", + "page.move": "Move page", + "page.sort": "Cambia posizione", + "page.status": "Stato", + "page.status.draft": "Bozza", + "page.status.draft.description": "Questa pagina è una bozza ed è visibile soltanto agli utenti registrati o tramite link segreto", + "page.status.listed": "Pubblico", + "page.status.listed.description": "La pagina è pubblicata per tutti", + "page.status.unlisted": "Non in elenco", + "page.status.unlisted.description": "La pagina è accessibile soltanto tramite URL", + + "pages": "Pagine", + "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", + "pages.status.unlisted": "Non in elenco", + + "pagination.page": "Pagina", + + "password": "Password", + "paste": "Incolla", + "paste.after": "Incolla dopo", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Precedente", + "preview": "Anteprima", + + "publish": "Pubblica", + "published": "Pubblicato", + + "remove": "Rimuovi", + "rename": "Rinomina", + "renew": "Rinnova", + "replace": "Sostituisci", + "replace.with": "Replace with", + "retry": "Riprova", + "revert": "Abbandona", + "revert.confirm": "Sei sicuro di voler cancellare tutte le modifiche non salvate?", + + "role": "Ruolo", + "role.admin.description": "L'amministratore ha tutti i permessi", + "role.admin.title": "Amministratore", + "role.all": "Tutti", + "role.empty": "Non ci sono utenti con questo ruolo", + "role.description.placeholder": "Nessuna descrizione", + "role.nobody.description": "Questo è un ruolo \"fallback\" senza permessi", + "role.nobody.title": "Nessuno", + + "save": "Salva", + "saved": "Salvato", + "search": "Cerca", + "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", + + "section.invalid": "The section is invalid", + "section.required": "La sezione è obbligatoria", + + "security": "Sicurezza", + "select": "Seleziona", + "server": "Server", + "settings": "Impostazioni", + "show": "Mostra", + "site.blueprint": "Il sito non ha ancora un \"blueprint\". Puoi impostarne uno in /site/blueprints/site.yml", + "size": "Dimensioni", + "slug": "URL", + "sort": "Ordina", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "Nessuna segnalazione", + "status": "Stato", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "La cartella content sembra essere esposta", + "system.issues.eol.kirby": "La versione di Kirby installata è giunta alla fine del suo ciclo di vita e non riceverà ulteriori aggiornamenti di sicurezza ", + "system.issues.eol.plugin": "La versione installata del plugin { plugin } è giunta alla fine del suo ciclo di vita e non riceverà ulteriori aggiornamenti di sicurezza", + "system.issues.eol.php": "La versione {release} di PHP installata è giunta alla fine del suo ciclo di vita e non riceverà ulteriori aggiornamenti di sicurezza", + "system.issues.debug": "Il debug deve essere disattivato in produzione", + "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": "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": "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", + "system.updateStatus.error": "Impossibile verificare gli aggiornamenti", + "system.updateStatus.not-vulnerable": "Nessuna vulnerabilità conosciuta", + "system.updateStatus.security-update": "Aggiornamento di sicurezza gratuito { version } disponibile", + "system.updateStatus.security-upgrade": "Aggiornamento { version } con le correzioni di sicurezza disponibili", + "system.updateStatus.unreleased": "Versione non rilasciata", + "system.updateStatus.up-to-date": "Aggiornato", + "system.updateStatus.update": "Aggiornamento gratuito { version } disponibile", + "system.updateStatus.upgrade": "Aggiornamento { version } disponibile", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Template", + + "theme": "Tema", + "theme.light": "Luci accese", + "theme.dark": "Luci spente", + "theme.automatic": "Usa impostazione predefinita del sistema", + + "title": "Titolo", + "today": "Oggi", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Codice", + "toolbar.button.bold": "Grassetto", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Titoli", + "toolbar.button.heading.1": "Titolo 1", + "toolbar.button.heading.2": "Titolo 2", + "toolbar.button.heading.3": "Titolo 3", + "toolbar.button.heading.4": "Titolo 4", + "toolbar.button.heading.5": "Titolo 5", + "toolbar.button.heading.6": "Titolo 6", + "toolbar.button.italic": "Corsivo", + "toolbar.button.file": "File", + "toolbar.button.file.select": "Seleziona un file", + "toolbar.button.file.upload": "Carica un file", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Paragrafo", + "toolbar.button.strike": "Barrato", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Elenco numerato", + "toolbar.button.underline": "Sottolinea", + "toolbar.button.ul": "Elenco puntato", + + "translation.author": "Kirby Team, Roman Steiner, Manu Moreale", + "translation.direction": "ltr", + "translation.name": "Italiano", + "translation.locale": "it_IT", + + "type": "Tipo", + + "upload": "Carica", + "upload.error.cantMove": "Non è stato possibile spostare il file caricato", + "upload.error.cantWrite": "Impossibile scrivere il file su disco", + "upload.error.default": "Impossibile caricare il file", + "upload.error.extension": "Caricamento del file interrotto per via dell'estensione", + "upload.error.formSize": "La dimensione del file caricato supera la direttiva MAX_FILE_SIZE specificata nel form", + "upload.error.iniPostSize": "La dimensione del file caricato supera la direttiva post_max_size specificata in php.ini", + "upload.error.iniSize": "La dimensione del file caricato supera la direttiva upload_max_filesize specificata in php.ini", + "upload.error.noFile": "Il file non è stato caricato", + "upload.error.noFiles": "Nessun file è stato caricato", + "upload.error.partial": "Il file è stato caricato solo parzialmente", + "upload.error.tmpDir": "Manca la cartella temporanea", + "upload.errors": "Errore", + "upload.progress": "Caricamento...", + + "url": "URL", + "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", + "user.changeEmail": "Modifica email", + "user.changeLanguage": "Cambia lingua", + "user.changeName": "Rinomina questo utente", + "user.changePassword": "Cambia password", + "user.changePassword.current": "La password attuale", + "user.changePassword.new": "Nuova password", + "user.changePassword.new.confirm": "Conferma la nuova password...", + "user.changeRole": "Cambia ruolo", + "user.changeRole.select": "Seleziona un nuovo ruolo", + "user.create": "Aggiungi nuovo utente", + "user.delete": "Elimina questo utente", + "user.delete.confirm": "Sei sicuro di voler eliminare l'utente
{email}?", + + "users": "Utenti", + + "version": "Versione di Kirby", + "version.changes": "Versione modificata", + "version.compare": "Confronta le versioni", + "version.current": "Versione corrente", + "version.latest": "Ultima versione", + "versionInformation": "Informazioni sulla versione", + + "view": "Visualizza", + "view.account": "Il tuo account", + "view.installation": "Installazione", + "view.languages": "Lingue", + "view.resetPassword": "Reimposta la password", + "view.site": "Sito", + "view.system": "Sistema", + "view.users": "Utenti", + + "welcome": "Benvenuto", + "year": "Anno", + "yes": "sì" +} diff --git a/public/kirby/i18n/translations/ko.json b/public/kirby/i18n/translations/ko.json new file mode 100644 index 0000000..a01a5c9 --- /dev/null +++ b/public/kirby/i18n/translations/ko.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "이름 변경", + "account.delete": "계정 삭제", + "account.delete.confirm": "계정을 삭제할까요? 계정을 삭제한 뒤에는 즉시 로그아웃되며, 삭제된 계정은 복구할 수 없습니다.", + + "activate": "활성화", + "add": "\ucd94\uac00", + "alpha": "알파", + "author": "저자", + "avatar": "프로필 이미지", + "back": "뒤로", + "cancel": "\ucde8\uc18c", + "change": "\ubcc0\uacbd", + "close": "\ub2eb\uae30", + "changes": "변경", + "confirm": "확인", + "collapse": "접기", + "collapse.all": "모두 접기", + "color": "색", + "coordinates": "좌표", + "copy": "복사", + "copy.all": "모두 복사", + "copy.success": "복사되었습니다. ({count})", + "copy.success.multiple": "복사되었습니다. ({count})", + "copy.url": "URL 복사", + "create": "등록", + "custom": "개인화", + + "date": "날짜", + "date.select": "날짜 지정", + + "day": "일", + "days.fri": "\uae08", + "days.mon": "\uc6d4", + "days.sat": "\ud1a0", + "days.sun": "\uc77c", + "days.thu": "\ubaa9", + "days.tue": "\ud654", + "days.wed": "\uc218", + + "debugging": "디버그", + + "delete": "\uc0ad\uc81c", + "delete.all": "모두 삭제", + + "dialog.fields.empty": "필드가 없습니다.", + "dialog.files.empty": "선택할 파일이 없습니다.", + "dialog.pages.empty": "선택할 페이지가 없습니다.", + "dialog.text.empty": "정의된 텍스트가 없습니다.", + "dialog.users.empty": "선택할 사용자가 없습니다.", + + "dimensions": "크기", + "disable": "비활성화", + "disabled": "비활성화", + "discard": "무시", + + "drawer.fields.empty": "필드가 없습니다.", + + "domain": "도메인", + "download": "다운로드", + "duplicate": "복제", + + "edit": "\ud3b8\uc9d1", + + "email": "\uc774\uba54\uc77c \uc8fc\uc18c", + "email.placeholder": "mail@example.com", + + "enter": "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": "\ud504\ub85c\ud544 \uc774\ubbf8\uc9c0\ub97c \uc0ad\uc81c\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "error.avatar.dimensions.invalid": "프로필 이미지의 너비와 높이를 3,000픽셀 이하로 설정하세요.", + "error.avatar.mime.forbidden": "프로필 이미지의 확장자(JPG, JPEG, PNG)를 확인하세요.", + + "error.blueprint.notFound": "블루프린트({name})를 불러올 수 없습니다.", + + "error.blocks.max.plural": "블록을 {max}개 이상 추가할 수 없습니다.", + "error.blocks.max.singular": "블록을 하나 이상 추가할 수 없습니다.", + "error.blocks.min.plural": "블록을 {min}개 이상 추가하세요.", + "error.blocks.min.singular": "블록을 하나 이상 추가하세요.", + "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": "엔트리를 하나 이상 추가할 수 없습니다.", + "error.entries.min.plural": "엔트리를 {min}개 이상 추가하세요.", + "error.entries.min.singular": "엔트리를 하나 이상 추가하세요.", + "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})으로 변경할 수 없습니다. (valid: \"{blueprints}\")", + "error.file.changeTemplate.permission": "파일({id}) 템플릿을 변경할 수 없습니다.", + + "error.file.delete.multiple": "모든 파일을 삭제할 수 없습니다. 각 파일을 확인하세요.", + "error.file.duplicate": "파일명이 같은 파일({filename})이 있습니다.", + "error.file.extension.forbidden": "이 확장자({extension})는 업로드할 수 없습니다.", + "error.file.extension.invalid": "확장자({extension})가 올바르지 않습니다.", + "error.file.extension.missing": "파일({filename})에 확장자가 없습니다.", + "error.file.maxheight": "이미지의 높이는 {height}픽셀을 초과할 수 없습니다.", + "error.file.maxsize": "파일이 너무 큽니다.", + "error.file.maxwidth": "이미지의 너비는 {width}픽셀을 초과할 수 없습니다.", + "error.file.mime.differs": "기존 파일과 MIME 형식({mime})이 다릅니다.", + "error.file.mime.forbidden": "이 MIME 형식({mime})은 업로드할 수 없습니다.", + "error.file.mime.invalid": "MIME 형식({mime})이 올바르지 않습니다.", + "error.file.mime.missing": "파일({filename})의 MIME 형식을 확인할 수 없습니다.", + "error.file.minheight": "이미지의 높이를 {height}픽셀 이상으로 설정하세요.", + "error.file.minsize": "파일이 너무 작습니다.", + "error.file.minwidth": "이미지의 너비를 {width}픽셀 이상으로 설정하세요.", + "error.file.name.unique": "고유한 파일명을 지정하세요.", + "error.file.name.missing": "파일명을 입력하세요.", + "error.file.notFound": "파일({filename})이 없습니다.", + "error.file.orientation": "이미지의 비율({orientation})을 확인하세요.", + "error.file.sort.permission": "파일({filename})을 정렬할 권한이 없습니다.", + "error.file.type.forbidden": "이 형식({type})의 파일을 업로드할 권한이 없습니다.", + "error.file.type.invalid": "파일 형식({type})이 올바르지 않습니다.", + "error.file.undefined": "\ud30c\uc77c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", + + "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": "올바른 이메일 주소를 입력하세요.", + "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": "고유 주소({slug})를 변경할 권한이 없습니다.", + "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": "페이지({slug})가 없습니다.", + "error.page.num.invalid": "올바른 정수를 입력하세요.", + "error.page.slug.invalid": "올바른 URL을 입력하세요.", + "error.page.slug.maxlength": "고유 주소를 {length}자 이하로 입력하세요.", + "error.page.sort.permission": "페이지({slug})를 정렬할 수 없습니다.", + "error.page.status.invalid": "올바른 상태를 설정하세요.", + "error.page.undefined": "\ud398\uc774\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", + "error.page.update.permission": "페이지({slug})를 변경할 권한이 없습니다.", + + "error.section.files.max.plural": "이 섹션({section})에는 파일을 {max}개 이상 추가할 수 없습니다.", + "error.section.files.max.singular": "이 섹션({section})에는 파일을 하나 이상 추가할 수 없습니다.", + "error.section.files.min.plural": "이 섹션({section})에는 파일이 {min}개 이상 필요합니다.", + "error.section.files.min.singular": "이 섹션({section})에는 파일이 하나 이상 필요합니다.", + + "error.section.pages.max.plural": "이 섹션({section})에는 페이지를 {max}개 이상 추가할 수 없습니다.", + "error.section.pages.max.singular": "이 섹션({section})에는 페이지를 하나 이상 추가할 수 없습니다.", + "error.section.pages.min.plural": "이 섹션({section})에는 페이지가 {min}개 이상 필요합니다.", + "error.section.pages.min.singular": "이 섹션({section})에는 페이지가 하나 이상 필요합니다.", + + "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": "사용자({name})를 삭제할 수 없습니다.", + "error.user.delete.lastAdmin": "\ucd5c\uc885 \uad00\ub9ac\uc790\ub294 \uc0ad\uc81c\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "error.user.delete.lastUser": "최종 사용자는 삭제할 수 없습니다.", + "error.user.delete.permission": "사용자({name})를 삭제할 권한이 없습니다.", + "error.user.duplicate": "이메일 주소({email})가 같은 사용자가 있습니다.", + "error.user.email.invalid": "올바른 이메일 주소를 입력하세요.", + "error.user.language.invalid": "올바른 언어를 입력하세요.", + "error.user.notFound": "사용자({name})가 없습니다.", + "error.user.password.excessive": "올바른 암호를 입력하세요. 암호는 1,000자를 넘을 수 없습니다.", + "error.user.password.invalid": "암호를 8자 이상으로 설정하세요.", + "error.user.password.notSame": "\uc554\ud638\ub97c \ud655\uc778\ud558\uc138\uc694.", + "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": "로마자(a~z)만 입력할 수 있습니다.", + "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": "{needle}에 포함된 값을 입력하세요.", + "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": "올바른 이메일 주소를 입력하세요.", + "error.validation.endswith": "값은 다음({end})으로 끝나야 합니다.", + "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}자 이하를 입력하세요.", + "error.validation.min": "{min} 이상의 값을 입력하세요.", + "error.validation.minlength": "{min}자 이상의 값을 입력하세요.", + "error.validation.minwords": "{min}자 이상을 입력하세요.", + "error.validation.more": "{min} 이상의 값을 입력하세요.", + "error.validation.notcontains": "{needle}에 포함된 값은 입력할 수 없습니다.", + "error.validation.notin": "{notIn}에 포함된 값은 입력할 수 없습니다.", + "error.validation.option": "올바른 옵션을 선택하세요.", + "error.validation.num": "올바른 숫자를 입력하세요.", + "error.validation.required": "해당 항목을 확인하세요.", + "error.validation.same": "이 값({other})을 입력하세요.", + "error.validation.size": "값의 크기({size})를 확인하세요. ", + "error.validation.startswith": "값은 다음({start})으로 시작해야 합니다.", + "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": "올바른 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": "이미지 URL", + "field.blocks.line.name": "가로줄", + "field.blocks.list.name": "목록", + "field.blocks.markdown.name": "마크다운", + "field.blocks.markdown.label": "마크다운", + "field.blocks.markdown.placeholder": "마크다운", + "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": "영상 URL 입력", + "field.blocks.video.poster": "대표 이미지", + "field.blocks.video.preload": "미리 로드", + "field.blocks.video.url.label": "영상 URL", + "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": "이 항목을 삭제할까요?", + "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": "파일({filename})을 삭제할까요?", + "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": "\uc0bd\uc785", + "insert.after": "뒤에 삽입", + "insert.before": "앞에 삽입", + "install": "설치", + + "installation": "설치", + "installation.completed": "패널을 설치했습니다.", + "installation.disabled": "패널 설치 관리자는 로컬 서버에서 실행하거나 panel.install 옵션을 설정하세요.", + "installation.issues.accounts": "/site/accounts 폴더의 쓰기 권한을 확인하세요.", + "installation.issues.content": "/content 폴더의 쓰기 권한을 확인하세요.", + "installation.issues.curl": "cURL 확장 모듈이 필요합니다.", + "installation.issues.headline": "패널을 설치할 수 없습니다.", + "installation.issues.mbstring": "MB String 확장 모듈이 필요합니다.", + "installation.issues.media": "/media 폴더의 쓰기 권한을 확인하세요.", + "installation.issues.php": "PHP 버전이 8 이상인지 확인하세요.", + "installation.issues.sessions": "/site/sessions 폴더의 쓰기 권한을 확인하세요.", + + "language": "\uc5b8\uc5b4", + "language.code": "언어 코드", + "language.convert": "기본 언어로 지정", + "language.convert.confirm": "이 언어({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": "사용자 지정 로캘을 사용 중입니다. /site/languages 폴더의 언어 파일을 수정하세요.", + "language.name": "언어명", + "language.secondary": "보조 언어", + "language.settings": "언어 설정", + "language.updated": "언어를 변경했습니다.", + "language.variables": "언어 변수", + "language.variables.empty": "번역이 없습니다.", + + "language.variable.delete.confirm": "변수({key})를 삭제할까요?", + "language.variable.entries": "값", + "language.variable.entries.help": "각 문자열은 해당하는 개수에 맞게 사용됩니다. 예를 들어 세 개의 문자열은 0, 1, 2 및 그 이상의 개수에 순서대로 대응합니다. 실제 개수를 표시하려면 {Count}를 사용하세요.", + "language.variable.key": "키", + "language.variable.multiple": "셀 수 있나요?", + "language.variable.multiple.text": "다른 번역 문자열을 사용하세요.", + "language.variable.multiple.help": "언어 변수와 함께 전달하는 개수에 따라 다른 값을 사용할 수 있으므로 단수형이나 복수형 같은 동적 번역을 구현할 수 있습니다.", + "language.variable.notFound": "변수를 찾을 수 없습니다.", + "language.variable.value": "값", + + "languages": "언어", + "languages.default": "기본 언어", + "languages.empty": "언어가 없습니다.", + "languages.secondary": "보조 언어", + "languages.secondary.empty": "보조 언어가 없습니다.", + + "license": "라이선스", + "license.activate": "지금 활성화하세요.", + "license.activate.label": "라이선스를 활성화하세요.", + "license.activate.domain": "{host}에 대한 라이선스를 활성화합니다.", + "license.activate.local": "로컬 도메인({host})에 대한 라이선스를 활성화합니다. 현재 도메인({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": "유효한 라이선스가 없습니다.", + "license.status.missing.label": "라이선스를 활성화하세요.", + "license.status.unknown.info": "라이선스 상태를 알 수 없습니다.", + "license.status.unknown.label": "알 수 없음", + "license.manage": "라이선스 관리", + "license.purchased": "구입했습니다.", + "license.success": "Kirby와 함께해주셔서 감사합니다.", + "license.unregistered.label": "Kirby가 등록되지 않았습니다.", + + "link": "\uc77c\ubc18 \ub9c1\ud06c", + "link.text": "\ubb38\uc790", + + "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} 패널에서 요청한 로그인 코드는 다음과 같습니다. 로그인 코드는 {timeout}분 동안 유효합니다.\n\n{code}\n\n로그인 코드를 요청한 적이 없다면, 이 이메일을 무시하거나 관리자에게 문의하세요. 보안을 위해 이 이메일은 다른 사람과 공유하지 마세요.", + "login.email.login.subject": "로그인 코드", + "login.email.password-reset.body": "{user.nameOrEmail} 님,\n\n{site} 패널에서 요청한 암호 초기화 코드는 다음과 같습니다. 암호 초기화 코드는 {timeout}분 동안 유효합니다.\n\n{code}\n\n암호 초기화 코드를 요청한 적이 없다면, 이 이메일을 무시하거나 관리자에게 문의하세요. 보안을 위해 이 이메일은 다른 사람과 공유하지 마세요.", + "login.email.password-reset.subject": "암호 초기화 코드", + "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} 설정 키를 수동으로 추가하세요.", + "login.totp.enable.confirm.headline": "2. 생성된 코드로 확인하세요.", + "login.totp.enable.confirm.text": "앱은 매 30초마다 새로운 일회용 코드를 생성합니다. 설정을 완료하기 위해 현재 코드를 입력하세요.", + "login.totp.enable.confirm.label": "현재 코드", + "login.totp.enable.confirm.help": "로그인할 때마다 일회용 코드를 요청합니다.", + "login.totp.enable.success": "일회용 코드가 활성화되었습니다.", + "login.totp.disable.option": "일회용 코드 비활성화", + "login.totp.disable.label": "비밀번호를 입력해 일회용 코드를 비활성화하세요.", + "login.totp.disable.help": "이후 로그인할 때 이메일로 발송된 로그인 코드와 같은 다른 두 번째 인증 요소를 요청합니다. 언제든 일회용 코드를 나중에 다시 설정할 수 있습니다.", + "login.totp.disable.admin": "사용자({user})의 일회용 코드를 비활성화합니다. 사용자({user})가 로그인할 때 이메일로 전송된 로그인 코드와 같은 다른 두 번째 인증 요소가 요청됩니다. 사용자({user})는 다음 로그인 후에 다시 일회용 코드를 설정할 수 있습니다.", + "login.totp.disable.success": "일회용 코드가 비활성화되었습니다.", + + "logout": "로그아웃", + + "merge": "합치기", + "menu": "메뉴", + "meridiem": "오전/오후", + "mime": "MIME 형식", + "minutes": "분", + + "month": "월", + "months.april": "4\uc6d4", + "months.august": "8\uc6d4", + "months.december": "12\uc6d4", + "months.february": "2월", + "months.january": "1\uc6d4", + "months.july": "7\uc6d4", + "months.june": "6\uc6d4", + "months.march": "3\uc6d4", + "months.may": "5\uc6d4", + "months.november": "11\uc6d4", + "months.october": "10\uc6d4", + "months.september": "9\uc6d4", + + "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": "고유 주소 변경", + "page.changeSlug.fromTitle": "제목에서 가져오기", + "page.changeStatus": "상태 변경", + "page.changeStatus.position": "순서를 지정하세요.", + "page.changeStatus.select": "새 상태 선택", + "page.changeTemplate": "템플릿 변경", + "page.changeTemplate.notice": "템플릿을 변경하면 유형이 일치하지 않은 필드에 입력한 콘텐츠가 삭제됩니다.", + "page.create": "해당 상태({status})로 생성", + "page.delete.confirm": "페이지({title})를 삭제할까요?", + "page.delete.confirm.subpages": "페이지에 하위 페이지가 있습니다. 모든 하위 페이지가 삭제됩니다.", + "page.delete.confirm.title": "페이지 제목을 입력하세요.", + "page.duplicate.appendix": "복사", + "page.duplicate.files": "파일 복사", + "page.duplicate.pages": "페이지 복사", + "page.move": "페이지 이동", + "page.sort": "순서 변경", + "page.status": "상태", + "page.status.draft": "초안", + "page.status.draft.description": "로그인한 사용자나 URL을 통해 접근할 수 있습니다.", + "page.status.listed": "공개", + "page.status.listed.description": "누구나 읽을 수 있습니다.", + "page.status.unlisted": "비공개", + "page.status.unlisted.description": "오직 URL을 통해 접근할 수 있습니다.", + + "pages": "페이지", + "pages.delete.confirm.selected": "선택한 페이지를 삭제할까요?", + "pages.empty": "페이지가 없습니다.", + "pages.status.draft": "초안", + "pages.status.listed": "발행", + "pages.status.unlisted": "비공개", + + "pagination.page": "페이지", + + "password": "\uc554\ud638", + "paste": "붙여넣기", + "paste.after": "뒤로 붙여넣기", + "paste.success": "붙여넣었습니다. ({count})", + "pixel": "픽셀", + "plugin": "플러그인", + "plugins": "플러그인", + "prev": "이전", + "preview": "미리 보기", + + "publish": "발행", + "published": "발행", + + "remove": "삭제", + "rename": "이름 변경", + "renew": "갱신", + "replace": "\uad50\uccb4", + "replace.with": "다음으로 교체", + "retry": "\ub2e4\uc2dc \uc2dc\ub3c4", + "revert": "복원", + "revert.confirm": "저장되지 않은 내용을 삭제할까요?", + + "role": "역할", + "role.admin.description": "관리자는 모든 권한이 있습니다.", + "role.admin.title": "관리자", + "role.all": "전체", + "role.empty": "이 역할에 해당하는 사용자가 없습니다.", + "role.description.placeholder": "설명이 없습니다.", + "role.nobody.description": "대체 사용자는 아무 권한이 없습니다.", + "role.nobody.title": "사용자가 없습니다.", + + "save": "\uc800\uc7a5", + "saved": "저장했습니다.", + "search": "검색", + "searching": "검색 중", + "search.min": "{min}자 이상 입력하세요.", + "search.all": "모든 결과({count}) 보기", + "search.results.none": "해당하는 결과가 없습니다.", + + "section.invalid": "섹션이 올바르지 않습니다.", + "section.required": "섹션이 필요합니다.", + + "security": "보안", + "select": "선택", + "server": "서버", + "settings": "설정", + "show": "보기", + "site.blueprint": "블루프린트(/site/blueprints/site.yml)를 설정하세요.", + "size": "크기", + "slug": "고유 주소", + "sort": "정렬", + "sort.drag": "Drag to sort …", + "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 폴더의 권한을 확인하세요.", + "system.issues.vue.compiler": "Vue 템플릿 컴파일러를 활성화했습니다.", + "system.issues.vulnerability.kirby": "설치한 시스템에 취약점이 있습니다.\n심각도: {severity}\n{description}", + "system.issues.vulnerability.plugin": "설치한 플러그인({plugin})에 취약점이 있습니다.\n심각도: {severity}\n{ 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": "Phone", + "tel.placeholder": "+49123456789", + "template": "\ud15c\ud50c\ub9bf", + + "theme": "테마", + "theme.light": "밝게", + "theme.dark": "어둡게", + "theme.automatic": "시스템 기본값과 일치", + + "title": "제목", + "today": "오늘", + + "toolbar.button.clear": "서식 제거", + "toolbar.button.code": "코드", + "toolbar.button.bold": "강조", + "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": "강조 2", + "toolbar.button.file": "파일", + "toolbar.button.file.select": "파일 선택", + "toolbar.button.file.upload": "파일 업로드", + "toolbar.button.link": "링크", + "toolbar.button.paragraph": "문단", + "toolbar.button.strike": "취소선", + "toolbar.button.sub": "아래 첨자", + "toolbar.button.sup": "위 첨자", + "toolbar.button.ol": "숫자 목록", + "toolbar.button.underline": "밑줄", + "toolbar.button.ul": "기호 목록", + + "translation.author": "Kirby 팀", + "translation.direction": "ltr", + "translation.name": "한국어", + "translation.locale": "ko_KR", + + "type": "유형", + + "upload": "업로드", + "upload.error.cantMove": "파일을 이동할 수 없습니다.", + "upload.error.cantWrite": "디스크를 읽을 수 없습니다.", + "upload.error.default": "파일을 업로드할 수 없습니다.", + "upload.error.extension": "파일 확장자를 확인하세요.", + "upload.error.formSize": "업로드한 파일이 허용된 크기(MAX_FILE_SIZE)를 초과했습니다.", + "upload.error.iniPostSize": "업로드한 파일이 PHP 환경 설정 파일(php.ini)에서 허용된 크기(post_max_size)를 초과했습니다.", + "upload.error.iniSize": "업로드한 파일이 PHP 환경 설정 파일(php.ini)에서 허용된 크기(upload_max_filesize)를 초과했습니다.", + "upload.error.noFile": "업로드한 파일이 없습니다.", + "upload.error.noFiles": "업로드한 파일이 없습니다.", + "upload.error.partial": "일부 파일을 업로드했습니다.", + "upload.error.tmpDir": "임시 폴더가 없습니다.", + "upload.errors": "오류", + "upload.progress": "업로드 중…", + + "url": "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": "계정", + "view.installation": "\uc124\uce58", + "view.languages": "언어", + "view.resetPassword": "암호 초기화", + "view.site": "사이트", + "view.system": "시스템", + "view.users": "\uc0ac\uc6a9\uc790", + + "welcome": "반갑습니다.", + "year": "년", + "yes": "네" +} diff --git a/public/kirby/i18n/translations/lt.json b/public/kirby/i18n/translations/lt.json new file mode 100644 index 0000000..fd04cdf --- /dev/null +++ b/public/kirby/i18n/translations/lt.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Pakeisti savo vardą", + "account.delete": "Panaikinti savo paskyrą", + "account.delete.confirm": "Ar tikrai norite panaikinti savo paskyrą? Jūs iš karto atsijungsite. Paskyros bus neįmanoma atstatyti.", + + "activate": "Aktyvuoti", + "add": "Pridėti", + "alpha": "Alpha", + "author": "Autorius", + "avatar": "Profilio nuotrauka", + "back": "Atgal", + "cancel": "Atšaukti", + "change": "Keisti", + "close": "Uždaryti", + "changes": "Changes", + "confirm": "Ok", + "collapse": "Sutraukti", + "collapse.all": "Sutraukti viską", + "color": "Color", + "coordinates": "Coordinates", + "copy": "Kopijuoti", + "copy.all": "Kopijuoti visus", + "copy.success": "{count} nukopijuota!", + "copy.success.multiple": "{count} nukopijuota!", + "copy.url": "Copy URL", + "create": "Sukurti", + "custom": "Custom", + + "date": "Data", + "date.select": "Pasirinkite datą", + + "day": "Diena", + "days.fri": "Pen", + "days.mon": "Pir", + "days.sat": "Šeš", + "days.sun": "Sek", + "days.thu": "Ket", + "days.tue": "Ant", + "days.wed": "Tre", + + "debugging": "Debugging", + + "delete": "Pašalinti", + "delete.all": "Pašalinti viską", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "Nėra failų pasirinkimui", + "dialog.pages.empty": "Nėra puslapių pasirinkimui", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Nėra vartotojų pasirinkimui", + + "dimensions": "Išmatavimai", + "disable": "Išjungti", + "disabled": "Išjungta", + "discard": "Atšaukti", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domenas", + "download": "Parsisiųsti", + "duplicate": "Dublikuoti", + + "edit": "Redaguoti", + + "email": "El. paštas", + "email.placeholder": "info@pavyzdys.lt", + + "enter": "Enter", + "entries": "Įrašai", + "entry": "Įrašas", + + "environment": "Aplinka", + + "error": "Error", + "error.access.code": "Neteisinas kodas", + "error.access.login": "Neteisingas prisijungimo vardas", + "error.access.panel": "Neturite teisės prisijungti prie valdymo pulto", + "error.access.view": "Neturite teisės peržiūrėti šios valdymo pulto dalies", + + "error.avatar.create.fail": "Nepavyko įkelti profilio nuotraukos", + "error.avatar.delete.fail": "Nepavyko pašalinti profilio nuotraukos", + "error.avatar.dimensions.invalid": "Profilio nuotraukos plotis ar aukštis turėtų būti iki 3000 pikselių", + "error.avatar.mime.forbidden": "Profilio nuotrauka turi būti JPEG arba PNG", + + "error.blueprint.notFound": "Blueprint \"{name}\" negali būti užkrautas", + + "error.blocks.max.plural": "Didžiausias įmanomas blokų kiekis: {max}", + "error.blocks.max.singular": "Jūs galite pridėti daugiausiai vieną bloką", + "error.blocks.min.plural": "Minimalus blokų kiekis: {min}", + "error.blocks.min.singular": "Jūs turite pridėti bent vieną bloką", + "error.blocks.validation": "Yra klaida laukelyje \"{field}\" bloke {index} naudojant bloko tipą \"{fieldset}\"", + + "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": "El. pašto paruoštukas \"{name}\" nerastas", + + "error.field.converter.invalid": "Neteisingas konverteris \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Laukelis \"{ name }\": Šio tipo (\"{ type }\") laukelis neegzistuoja", + + "error.file.changeName.empty": "Pavadinimas negali būti tuščias", + "error.file.changeName.permission": "Neturite teisės pakeisti failo pavadinimo \"{filename}\"", + "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": "Failas su pavadinimu \"{filename}\" jau yra", + "error.file.extension.forbidden": "Failo tipas (plėtinys) \"{extension}\" neleidžiamas", + "error.file.extension.invalid": "Neteisingas plėtinys: {extension}", + "error.file.extension.missing": "Failui \"{filename}\" trūksta tipo (plėtinio)", + "error.file.maxheight": "Failo aukštis neturi viršyti {height} px", + "error.file.maxsize": "Failas per didelis", + "error.file.maxwidth": "Failo plotis neturi viršyti {width} px", + "error.file.mime.differs": "Įkėliamas failas turi būti tokio pat mime tipo \"{mime}\"", + "error.file.mime.forbidden": "Media tipas \"{mime}\" neleidžiamas", + "error.file.mime.invalid": "Neteisingas mime tipas: {mime}", + "error.file.mime.missing": "Failui \"{filename}\" nepavyko atpažinti media (mime) tipo", + "error.file.minheight": "Failo aukštis turi būti bent {height} px", + "error.file.minsize": "Failas per mažas", + "error.file.minwidth": "Failo plotis turi būti bent {width} px", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Failo pavadinimas negali būti tuščias", + "error.file.notFound": "Failas \"{filename}\" nerastas", + "error.file.orientation": "Failo orientacija turi būti \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Jūs neturite teisės įkelti {type} tipo failų", + "error.file.type.invalid": "Neteisingas failo tipas: {type}", + "error.file.undefined": "Failas nerastas", + + "error.form.incomplete": "🙏 Prašome ištaisyti visas formos klaidas…", + "error.form.notSaved": "Formos nepavyko išsaugoti", + + "error.language.code": "Prašome įrašyti teisingą kalbos kodą", + "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": "Tokia kalba jau yra", + "error.language.name": "Prašome įrašyti teisingą kalbos pavadinimą", + "error.language.notFound": "Nepavyko rasti šios kalbos", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "Yra klaida išdėstymo {index} nustatymuose", + + "error.license.domain": "Licencijai trūksta domeno", + "error.license.email": "Prašome įrašyti teisingą el. pašto adresą", + "error.license.format": "Prašome įrašyti teisingą licencijos kodą", + "error.license.verification": "Nepavyko patikrinti licenzijos", + + "error.login.totp.confirm.invalid": "Neteisinas kodas", + "error.login.totp.confirm.missing": "Prašome įrašyti kodą", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "Valdymo pultas dabar yra offline", + + "error.page.changeSlug.permission": "Neturite teisės pakeisti \"{slug}\" URL", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "Puslapis turi klaidų ir negali būti paskelbtas", + "error.page.changeStatus.permission": "Šiam puslapiui negalima pakeisti statuso", + "error.page.changeStatus.toDraft.invalid": "Puslapio \"{slug}\" negalima paversti juodraščiu", + "error.page.changeTemplate.invalid": "Šablono puslapiui \"{slug}\" negalima keisti", + "error.page.changeTemplate.permission": "Neturite leidimo keisti šabloną puslapiui \"{slug}\"", + "error.page.changeTitle.empty": "Pavadinimas negali būti tuščias", + "error.page.changeTitle.permission": "Neturite leidimo keisti pavadinimo puslapiui \"{slug}\"", + "error.page.create.permission": "Neturite leidimo sukurti \"{slug}\"", + "error.page.delete": "Puslapio \"{slug}\" negalima pašalinti", + "error.page.delete.confirm": "Įrašykite puslapio pavadinimą, tam kad patvirtintumėte", + "error.page.delete.hasChildren": "Puslapis turi vidinių puslapių, dėl to negalima jo pašalinti", + "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": "Neturite leidimo šalinti \"{slug}\"", + "error.page.draft.duplicate": "Puslapio juodraštis su URL pabaiga \"{slug}\" jau yra", + "error.page.duplicate": "Puslapis su URL pabaiga \"{slug}\" jau yra", + "error.page.duplicate.permission": "Neturite leidimo dubliuoti \"{slug}\"", + "error.page.move.ancestor": "Puslapio negalima perkelti į save patį", + "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.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}\"", + "error.page.notFound": "Puslapis \"{slug}\" nerastas", + "error.page.num.invalid": "Įrašykite teisingą eiliškumo numerį. Numeris negali būti neigiamas.", + "error.page.slug.invalid": "Įrašykite teisingą URL priedą", + "error.page.slug.maxlength": "url adreso maksimalus simbolių kiekis: \"{length}\"", + "error.page.sort.permission": "Puslapiui \"{slug}\" negalima pakeisti eiliškumo", + "error.page.status.invalid": "Nustatykite teisingą puslapio statusą", + "error.page.undefined": "Puslapis nerastas", + "error.page.update.permission": "Neturite leidimo atnaujinti \"{slug}\"", + + "error.section.files.max.plural": "Į sekciją \"{section}\" negalima pridėti daugiau nei {max} failų", + "error.section.files.max.singular": "Į sekciją \"{section}\" negalima pridėti daugiau nei vieną failą", + "error.section.files.min.plural": "Sekcija \"{section}\" reikalauja bent {min} failų", + "error.section.files.min.singular": "Sekcija \"{section}\" reikalauja bent vieno failo", + + "error.section.pages.max.plural": "Į sekciją \"{section}\" negalima pridėti daugiau nei {max} puslapių", + "error.section.pages.max.singular": "Į sekciją \"{section}\" negalima pridėti daugiau nei vieną puslapį", + "error.section.pages.min.plural": "Sekcija \"{section}\" reikalauja bent {min} puslapių", + "error.section.pages.min.singular": "Sekcija \"{section}\" reikalauja bent vieno puslapio", + + "error.section.notLoaded": "Sekcija \"{name}\" negali būti užkrauta", + "error.section.type.invalid": "Sekcijos tipas \"{type}\" yra neteisingas", + + "error.site.changeTitle.empty": "Pavadinimas negali būti tuščias", + "error.site.changeTitle.permission": "Neturite leidimo keisti svetainės pavadinimo", + "error.site.update.permission": "Neturite leidimo atnaujinti svetainės", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Nėra šablono pagal nutylėjimą", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Neturite leidimo keisti vartotojo \"{name}\" el. paštą", + "error.user.changeLanguage.permission": "Neturite leidimo keisti vartotojo \"{name}\" kalbą", + "error.user.changeName.permission": "Neturite leidimo keisti vartotojo \"{name}\" vardą", + "error.user.changePassword.permission": "Neturite leidimo keisti vartotojo \"{name}\" slaptažodį", + "error.user.changeRole.lastAdmin": "Vienintelio administratoriaus rolės negalima pakeisti", + "error.user.changeRole.permission": "Neturite leidimo pakeisti vartotojo \"{name}\" rolės", + "error.user.changeRole.toAdmin": "Jūs neturite teisių suteikti administratoriaus rolę", + "error.user.create.permission": "Neturite leidimo sukurti šį vartotoją", + "error.user.delete": "Vartotojo \"{name}\" negalima pašalinti", + "error.user.delete.lastAdmin": "Vienintelio administratoriaus negalima pašalinti", + "error.user.delete.lastUser": "Vienintelio vartotojo negalima pašalinti", + "error.user.delete.permission": "Neturite leidimo pašalinti vartotoją \"{name}\"", + "error.user.duplicate": "Vartotojas su el. paštu \"{email}\" jau yra", + "error.user.email.invalid": "Įrašykite teisingą el. pašto adresą", + "error.user.language.invalid": "Įrašykite teisingą kalbą", + "error.user.notFound": "Vartotojas \"{name}\" nerastas", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Prašome įrašyti galiojantį slaptažodį. Slaptažodį turi sudaryti bent 8 simboliai.", + "error.user.password.notSame": "Slaptažodžiai nesutampa", + "error.user.password.undefined": "Vartotojas neturi slaptažodžio", + "error.user.password.wrong": "Neteisingas slaptažodis", + "error.user.role.invalid": "Įrašykite teisingą rolę", + "error.user.undefined": "Vartotojas nerastas", + "error.user.update.permission": "Neturite teisės keisti vartotojo \"{name}\"", + + "error.validation.accepted": "Prašome patvirtinti", + "error.validation.alpha": "Prašome įrašyti tik raides a-z", + "error.validation.alphanum": "Prašome įrašyti tik raides a-z arba skaičius 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Prašome įrašyti reikšmę tarp \"{min}\" ir \"{max}\"", + "error.validation.boolean": "Patvirtinkite arba atšaukite", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Prašome įrašyti reikšmę, kuri turėtų \"{needle}\"", + "error.validation.date": "Prašome įrašyti korektišką datą", + "error.validation.date.after": "Įrašykite datą nuo {date}", + "error.validation.date.before": "Įrašykite datą iki {date}", + "error.validation.date.between": "Įrašykite datą tarp {min} ir {max}", + "error.validation.denied": "Prašome neleisti", + "error.validation.different": "Reikšmė neturi būti \"{other}\"", + "error.validation.email": "Prašome įrašyti korektišką el. paštą", + "error.validation.endswith": "Reikšmė turi baigtis su \"{end}\"", + "error.validation.filename": "Prašome įrašyti teisingą failo pavadinimą", + "error.validation.in": "Prašome įrašyti vieną iš šių: ({in})", + "error.validation.integer": "Prašome įrašyti teisingą sveiką skaičių", + "error.validation.ip": "Prašome įrašyti teisingą IP adresą", + "error.validation.less": "Prašome įrašyti mažiau nei {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Reikšmė nesutampa su laukiamu šablonu", + "error.validation.max": "Prašome įrašyti reikšmę lygią arba didesnę, nei {max}", + "error.validation.maxlength": "Prašome įrašyti trumpesnę reikšmę. (max. {max} characters)", + "error.validation.maxwords": "Please enter no more than {max} word(s)", + "error.validation.min": "Please enter a value equal to or greater than {min}", + "error.validation.minlength": "Prašome įrašyti ilgesnę reikšmę. (min. {min} characters)", + "error.validation.minwords": "Prašome įrašyti bent {min} žodžius", + "error.validation.more": "Prašome įrašyti daugiau nei {min}", + "error.validation.notcontains": "Prašome įrašyti reikšmę, kuri neturi \"{needle}\"", + "error.validation.notin": "Prašome neįrašyti vieną iš šių: ({notIn})", + "error.validation.option": "Prašome pasirinkti korektišką opciją", + "error.validation.num": "Prašome įrašyti teisingą numerį", + "error.validation.required": "Prašome įrašyti ką nors", + "error.validation.same": "Prašome įrašyti \"{other}\"", + "error.validation.size": "Reikšmės dydis turi būti \"{size}\"", + "error.validation.startswith": "Reikšmė turi prasidėti su \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Prašome įrašyti korektišką laiką", + "error.validation.time.after": "Įrašykite laiką po {time}", + "error.validation.time.before": "Įrašykite laiką prieš {time}", + "error.validation.time.between": "Įrašykite laiką tarp {min} ir {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Prašome įrašyti teisingą URL", + + "expand": "Išskleisti", + "expand.all": "Išskleisti viską", + + "field.invalid": "The field is invalid", + "field.required": "Laukas privalomas", + "field.blocks.changeType": "Pakeisti tipą", + "field.blocks.code.name": "Kodas", + "field.blocks.code.language": "Kalba", + "field.blocks.code.placeholder": "Jūsų kodas ...", + "field.blocks.delete.confirm": "Ar tikrai norite pašalinti šį bloką?", + "field.blocks.delete.confirm.all": "Ar tikrai norite pašalinti visus blokus?", + "field.blocks.delete.confirm.selected": "Ar tikrai norite pašalinti pasirinktus blokus?", + "field.blocks.empty": "Dar nėra blokų", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Pasirinkite bloko tipą ...", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galerija", + "field.blocks.gallery.images.empty": "Dar nėra nuotraukų", + "field.blocks.gallery.images.label": "Nuotraukos", + "field.blocks.heading.level": "Lygis", + "field.blocks.heading.name": "Antraštė", + "field.blocks.heading.text": "Tekstas", + "field.blocks.heading.placeholder": "Antraštė ...", + "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": "Alternatyvus tekstas", + "field.blocks.image.caption": "Aprašymas", + "field.blocks.image.crop": "Kirpti", + "field.blocks.image.link": "Nuoroda", + "field.blocks.image.location": "Šaltinis", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Nuotrauka", + "field.blocks.image.placeholder": "Pasirinkite nuotrauką", + "field.blocks.image.ratio": "Proporcijos", + "field.blocks.image.url": "Nuotraukos URL", + "field.blocks.line.name": "Linija", + "field.blocks.list.name": "Sąrašas", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Tekstas", + "field.blocks.markdown.placeholder": "Markdown ...", + "field.blocks.quote.name": "Citata", + "field.blocks.quote.text.label": "Tekstas", + "field.blocks.quote.text.placeholder": "Citata ...", + "field.blocks.quote.citation.label": "Citatos turinys", + "field.blocks.quote.citation.placeholder": "autorius", + "field.blocks.text.name": "Tekstas", + "field.blocks.text.placeholder": "Tekstas ...", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Aprašymas", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Šaltinis", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Įrašykite video URL", + "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": "Ar tikrai norite išrtinti visus įrašus?", + "field.entries.empty": "Dar nėra įrašų", + + "field.files.empty": "Pasirinkti", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Pašalinti eilutę", + "field.layout.delete.confirm": "Ar tikrai norite pašalinti šią eilutę", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "Dar nėra eilučių", + "field.layout.select": "Pasirinkite išdėstymą", + + "field.object.empty": "Dar nėra informacijos", + + "field.pages.empty": "Dar nėra puslapių", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Ar tikrai norite pašalinti šią eilutę?", + "field.structure.delete.confirm.all": "Ar tikrai norite išrtinti visus įrašus?", + "field.structure.empty": "Dar nėra įrašų", + + "field.users.empty": "Dar nėra vartotojų", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Failas", + "file.blueprint": "Šis failas dar neturi blueprint. Galite nustatyti jį per /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Pakeisti šabloną", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Ar tikrai norite pašalinti
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Pakeisti poziciją", + + "files": "Failai", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Įkelti", + + "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": "Paslėpti", + "hour": "Valanda", + "hue": "Hue", + "import": "Importuoti", + "info": "Info", + "insert": "Įterpti", + "insert.after": "Įterpti po", + "insert.before": "Įterpti prieš", + "install": "Įdiegti", + + "installation": "Įdiegimas", + "installation.completed": "Valdymo pultas įdiegtas", + "installation.disabled": "Pagal nutylėjimą valdymo pulto įdiegimas viešuose serveriuose yra negalimas. Prašome įdiegti lokalioje aplinkoje arba įgalinkite jį su panel.install opcija.", + "installation.issues.accounts": "Katalogas /site/accounts neegzistuoja arba neturi įrašymo teisių", + "installation.issues.content": "Katalogas /content neegzistuoja arba neturi įrašymo teisių", + "installation.issues.curl": "Plėtinys CURL yra privalomas", + "installation.issues.headline": "Nepavyko įdiegti valdymo pulto", + "installation.issues.mbstring": "Plėtinys MB String yra privalomas", + "installation.issues.media": "Katalogas /media neegzistuoja arba neturi įrašymo teisių", + "installation.issues.php": "Įsitikinkite, kad naudojama PHP 8+", + "installation.issues.sessions": "Katalogas /site/sessions neegzistuoja arba neturi įrašymo teisių", + + "language": "Kalba", + "language.code": "Kodas", + "language.convert": "Padaryti pagrindinį", + "language.convert.confirm": "

Do you really want to convert {name} to the default language? This cannot be undone.

If {name} has untranslated content, there will no longer be a valid fallback and parts of your site might be empty.

", + "language.create": "Pridėti naują kalbą", + "language.default": "Pagrindinė kalba", + "language.delete.confirm": "Ar tikrai norite pašalinti {name} kalbą, kartu su visais vertimais? Grąžinti nebus įmanoma! 🙀", + "language.deleted": "Kalba pašalinta", + "language.direction": "Skaitymo kryptis", + "language.direction.ltr": "Iš kairės į dešinę", + "language.direction.rtl": "Iš dešinės į kairę", + "language.locale": "PHP locale string", + "language.locale.warning": "Jūs naudojate pasirinktinį lokalės nustatymą. Prašome pakeisti jį faile /site/languages", + "language.name": "Pavadinimas", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Kalba atnaujinta", + "language.variables": "Language variables", + "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", + + "languages": "Kalbos", + "languages.default": "Pagrindinė kalba", + "languages.empty": "Dar nėra kalbų", + "languages.secondary": "Papildomos kalbos", + "languages.secondary.empty": "Dar nėra papildomų kalbų", + + "license": "Licenzija", + "license.activate": "Aktyvuoti dabar", + "license.activate.label": "Prašome aktyvuoti jūsų licenciją", + "license.activate.domain": "Jūsų licencija bus akvytuota šiam domenui: {host}", + "license.activate.local": "Jūs ruošiatės aktyvuoti jūsų Kirby licenciją vietiniam domenui {host}. Jei ši svetainė veiks su viešu domenu, aktyvuokite jį. Arba jei {host} yra tikrai tas domenas, kurį norite naudoti, galite tęsti.", + "license.activated": "Aktyvuota", + "license.buy": "Pirkti licenziją", + "license.code": "Kodas", + "license.code.help": "Jūs gavote licencijos kodą po pirkimo el. paštu. Nukopijuokite jį ir įterpkite čia.", + "license.code.label": "Prašome įrašyti jūsų licenzijos kodą", + "license.status.active.info": "Įeina naujos pagrindinės versijos iki {date}", + "license.status.active.label": "Galiojanti licencija", + "license.status.demo.info": "Tai demo versija", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Atnaujinkite licenciją, kad būtų galimybė atnaujinti iki naujų pagrindinių versijų", + "license.status.inactive.label": "Nėra naujų pagrindinių versijų", + "license.status.legacy.bubble": "Pasiruošę atnaujinti jūsų licenciją?", + "license.status.legacy.info": "Jūsų licencija negalioja šiai versijai", + "license.status.legacy.label": "Prašome atnaujinti jūsų licenciją", + "license.status.missing.bubble": "Pasiruošę paleisti naują svetainę?", + "license.status.missing.info": "Nėra galiojančios licencijos", + "license.status.missing.label": "Prašome aktyvuoti jūsų licenciją", + "license.status.unknown.info": "The license status is unknown", + "license.status.unknown.label": "Unknown", + "license.manage": "Valdyti savo licencijas", + "license.purchased": "Nupirkta", + "license.success": "Ačiū, kad palaikote Kirby", + "license.unregistered.label": "Neregistruota", + + "link": "Nuoroda", + "link.text": "Nuorodos tekstas", + + "loading": "Kraunasi", + + "lock.unsaved": "Neišsaugoti pakeitimai", + "lock.unsaved.empty": "Nebeliko neišsaugotų pakeitimų", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Unsaved changes by {email}", + "lock.unlock": "Atrakinti", + "lock.unlock.submit": "Unlock and overwrite unsaved changes by {email}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Prisijungti", + "login.code.label.login": "Prisijungimo kodas", + "login.code.label.password-reset": "Slaptažodžio atstatymo kodas", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Jei jūsų el. paštas yra užregistruotas, užklaistas kodas buvo išsiųstas el. paštu.", + "login.code.text.totp": "Prašome įrašyti vienkartinį kodą iš jūsų autentifikavimo programėlės.", + "login.email.login.body": "Sveiki, {user.nameOrEmail},\n\nNeseniai užklausėte prisijungimo kodo svetainėje {site}.\nŠis kodas galios {timeout} min.:\n\n{code}\n\nJei neprašėte šio kodo, tiesiog ignoruokite, arba susisiekite su administratoriumi.\nDėl saugumo, prašome NEPERSIŲSTI šio laiško.", + "login.email.login.subject": "Jūsų prisijungimo kodas", + "login.email.password-reset.body": "Sveiki, {user.nameOrEmail},\n\nNeseniai užklausėte naujo slaptažodžio kūrimo kodo svetainėje {site}.\nŠis kodas galios {timeout} min.:\n\n{code}\n\nJei neprašėte šio kodo, tiesiog ignoruokite, arba susisiekite su administratoriumi.\nDėl saugumo, prašome NEPERSIŲSTI šio laiško", + "login.email.password-reset.subject": "Jūsų slaptažodžio atstatymo kodas ", + "login.remember": "Likti prisijungus", + "login.reset": "Sukurti naują slaptažodį", + "login.toggleText.code.email": "Prisijungti su el. paštu", + "login.toggleText.code.email-password": "Prisijungti su slaptažodžiu", + "login.toggleText.password-reset.email": "Pamiršote slaptažodį?", + "login.toggleText.password-reset.email-password": "← Atgal į prisijungimą", + "login.totp.enable.option": "Nustatyti vienkartinius kodus", + "login.totp.enable.intro": "Autentifikavimo programėlės gali generuoti vienkartinius kodus, kurie bus naudojami kaip 2-factor prisijungiant prie svetainės.", + "login.totp.enable.qr.label": "1. Nuskenuokite šį QR kodą", + "login.totp.enable.qr.help": "Negalite nuskenuoti? Pridėkite raktą {secret} rankiniu būdu į savo autentifikavimo programėlę.", + "login.totp.enable.confirm.headline": "2. Patvirtinti su sugeneruotu kodu", + "login.totp.enable.confirm.text": "Jūsų programėlė generuoja naują vienkartinį kodą kas 30 sekundžių. Įrašykite dabartinį kodą, norėdami užbaigti:", + "login.totp.enable.confirm.label": "Dabartinis kodas", + "login.totp.enable.confirm.help": "Po šio nustatymo, iš jūsų bus prašomas vienkartinis kodas jungiantis kiekvieną kartą.", + "login.totp.enable.success": "Vienkartiniai kodai įjungti", + "login.totp.disable.option": "Išjungti vienkartinius kodus", + "login.totp.disable.label": "Įrašykite savo slaptažodį norėdami išjungti vienkartinius kodus", + "login.totp.disable.help": "Ateityje kitoks 2-factor bus prašomas prisijungiant, pvz. login kodas, siunčiamas el. paštu. Jūs galite visada nustatyti vienkartinius kodus vėl vėliau.", + "login.totp.disable.admin": "

Tai išjungs vienkartinius kodus vartotojui {user}. Ateityje kitoks 2-factor bus prašomas prisijungiant, pvz. login kodas, siunčiamas el. paštu. Jūs galite visada nustatyti vienkartinius kodus vėl vėliau. {user} galės nustatyti vienkartinius kodus, kai jungsis kitą kartą.", + "login.totp.disable.success": "Vienkartiniai kodai išjungti", + + "logout": "Atsijungti", + + "merge": "Merge", + "menu": "Meniu", + "meridiem": "AM/PM", + "mime": "Media Tipas", + "minutes": "Minutės", + + "month": "Mėnuo", + "months.april": "Balandis", + "months.august": "August", + "months.december": "Gruodis", + "months.february": "Vasaris", + "months.january": "Sausis", + "months.july": "Liepa", + "months.june": "Birželis", + "months.march": "Kovas", + "months.may": "Gegužė", + "months.november": "Lapkritis", + "months.october": "Spalis", + "months.september": "Rugsėjis", + + "more": "Daugiau", + "move": "Move", + "name": "Pavadinimas", + "next": "Toliau", + "night": "Night", + "no": "ne", + "off": "ne", + "on": "taip", + "open": "Atidaryti", + "open.newWindow": "Atidaryti naujame lange", + "option": "Option", + "options": "Pasirinkimai", + "options.none": "Nėra pasirinkimų", + "options.all": "Rodyti visas {count} opcijas", + + "orientation": "Orientacija", + "orientation.landscape": "Horizontali", + "orientation.portrait": "Portretas", + "orientation.square": "Kvadratas", + + "page": "Puslapis", + "page.blueprint": "Šis puslapis dar neturi blueprint. Galite jį nustatyti per /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Pakeisti URL", + "page.changeSlug.fromTitle": "Sukurti URL pagal pavadinimą", + "page.changeStatus": "Pakeisti statusą", + "page.changeStatus.position": "Pasirinkite poziciją", + "page.changeStatus.select": "Pasirinkite statusą", + "page.changeTemplate": "Pakeisti šabloną", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Sukurti kaip {status}", + "page.delete.confirm": "🙀 Ar tikrai norite pašalinti puslapį {title}?", + "page.delete.confirm.subpages": "Šis puslapis turi sub-puslapių.
Visi sub-puslapiai taip pat bus pašalinti.", + "page.delete.confirm.title": "Įrašykite puslapio pavadinimą tam, kad patvirtinti", + "page.duplicate.appendix": "Kopijuoti", + "page.duplicate.files": "Kopijuoti failus", + "page.duplicate.pages": "Kopijuoti puslapius", + "page.move": "Perkelti puslapį", + "page.sort": "Pakeisti poziciją", + "page.status": "Statusas", + "page.status.draft": "Juodraštis", + "page.status.draft.description": "Šis puslapis yra juodraščio režime ir prieinamas tik redaktoriams arba per slaptą nuorodą", + "page.status.listed": "Paskelbtas", + "page.status.listed.description": "Matomas viešai visiems", + "page.status.unlisted": "Nerodomas", + "page.status.unlisted.description": "Rodomas viešai visiems, bet tik per URL", + + "pages": "Puslapiai", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Dar nėra puslapių", + "pages.status.draft": "Juodraščiai", + "pages.status.listed": "Paskelbti", + "pages.status.unlisted": "Nerodomi", + + "pagination.page": "Puslapis", + + "password": "Slaptažodis", + "paste": "Įterpti", + "paste.after": "Įterpti po", + "paste.success": "{count} pasted!", + "pixel": "Pikselis", + "plugin": "Įskiepas", + "plugins": "Įskiepai", + "prev": "Ankstesnis", + "preview": "Peržiūra", + + "publish": "Publish", + "published": "Paskelbti", + + "remove": "Pašalinti", + "rename": "Pervadinti", + "renew": "Atnaujinti", + "replace": "Apkeisti", + "replace.with": "Replace with", + "retry": "Bandyti dar", + "revert": "Grąžinti", + "revert.confirm": "Ar tikrai norite atšaukti visus neišsaugotus pakeitimus?", + + "role": "Rolė", + "role.admin.description": "Admin turi visas teises", + "role.admin.title": "Admin", + "role.all": "Visos", + "role.empty": "Nėra vartotojų su tokia role", + "role.description.placeholder": "Be aprašymo", + "role.nobody.description": "Ši rolė bus naudojama jei nenustatytos jokios teisės", + "role.nobody.title": "Niekas", + + "save": "Išsaugoti", + "saved": "Saved", + "search": "Ieškoti", + "searching": "Searching", + "search.min": "Minimalus simbolių kiekis paieškai: {min}", + "search.all": "Parodyti visus {count} rezultatus", + "search.results.none": "Nėra rezultatų", + + "section.invalid": "The section is invalid", + "section.required": "Sekcija privaloma", + + "security": "Saugumas", + "select": "Pasirinkti", + "server": "Serveris", + "settings": "Nustatymai", + "show": "Rodyti", + "site.blueprint": "Svetainė neturi blueprint. Jūs galite nustatyti jį /site/blueprints/site.yml", + "size": "Dydis", + "slug": "URL pabaiga", + "sort": "Rikiuoti", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "Nėra pranešimų", + "status": "Statusas", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Jūsų PHP versija { release } pasiekė gyvenimo galą ir daugiau negaus saugumo atnaujinimų", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "Rekomenduojame HTTPS visoms svetainėms", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Atnaujinimų statusas", + "system.updateStatus.error": "Nepavyko patikrinti atnaujinimų", + "system.updateStatus.not-vulnerable": "Nėra žinomų saugumo spragų", + "system.updateStatus.security-update": "Prieinamas nemokamas saugumo atnaujinimas { version }", + "system.updateStatus.security-upgrade": "Prieinama nauja { version } versija su saugumo atnaujinimais", + "system.updateStatus.unreleased": "Neišleista versija", + "system.updateStatus.up-to-date": "Naujausia versija", + "system.updateStatus.update": "Prieinamas nemokamas atnaujinimas { version }", + "system.updateStatus.upgrade": "Prieinamas atnaujinimas { version }", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Puslapio šablonas", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Pavadinimas", + "today": "Šiandien", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Kodas", + "toolbar.button.bold": "Bold", + "toolbar.button.email": "El. paštas", + "toolbar.button.headings": "Antraštės", + "toolbar.button.heading.1": "Heading 1", + "toolbar.button.heading.2": "Heading 2", + "toolbar.button.heading.3": "Heading 3", + "toolbar.button.heading.4": "Antrašte 4", + "toolbar.button.heading.5": "Antrašte 5", + "toolbar.button.heading.6": "Antrašte 6", + "toolbar.button.italic": "Italic", + "toolbar.button.file": "Failas", + "toolbar.button.file.select": "Pasirinkite failą", + "toolbar.button.file.upload": "Įkelti failą", + "toolbar.button.link": "Nuoroda", + "toolbar.button.paragraph": "Paragrafas", + "toolbar.button.strike": "Perbraukimas", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Sąrašas su skaičiais", + "toolbar.button.underline": "Pabraukimas", + "toolbar.button.ul": "Sąrašas su taškais", + + "translation.author": "Roman U", + "translation.direction": "ltr", + "translation.name": "Lietuvių", + "translation.locale": "lt_LT", + + "type": "Type", + + "upload": "Įkelti", + "upload.error.cantMove": "Įkeltas failas negali būti perkeltas", + "upload.error.cantWrite": "Nepavyko įrašyti failo į diską", + "upload.error.default": "Nepavyko įkelti failo", + "upload.error.extension": "Neįmanoma įkelti tokio tipo failo", + "upload.error.formSize": "Įkeltas failas viršija MAX_FILE_SIZE nustatymą, kuris buvo nurodytas formoje", + "upload.error.iniPostSize": "Įkeliamas failas viršija post_max_size nustatymą iš php.ini", + "upload.error.iniSize": "Įkeltas failas viršija upload_max_filesize nustatymą faile php.ini", + "upload.error.noFile": "Failas nebuvo įkeltas", + "upload.error.noFiles": "Failai nebuvo įkelti", + "upload.error.partial": "Failas įkeltas tik iš dalies", + "upload.error.tmpDir": "Trūksta laikinojo katalogo", + "upload.errors": "Klaida", + "upload.progress": "Įkėlimas…", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Vartotojas", + "user.blueprint": "Galite nustatyti papildomas sekcijas ir formos laukelius šiam vartotojui per /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Keisti el. paštą", + "user.changeLanguage": "Keisti kalbą", + "user.changeName": "Pervadinti vartotoją", + "user.changePassword": "Keisti slaptažodį", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Naujas slaptažodis", + "user.changePassword.new.confirm": "Patvirtinti naują slaptažodį…", + "user.changeRole": "Keisti rolę", + "user.changeRole.select": "Pasirinkti naują rolę", + "user.create": "Pridėti naują vartotoją", + "user.delete": "Pašalinti šį vartotoją", + "user.delete.confirm": "Ar tikrai norite pašalinti vartotoją
{email}?", + + "users": "Vartotojai", + + "version": "Versija", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Dabartinė versija", + "version.latest": "Naujausia versija", + "versionInformation": "Versijos informacija", + + "view": "View", + "view.account": "Jūsų paskyra", + "view.installation": "Installation", + "view.languages": "Kalbos", + "view.resetPassword": "Sukurti naują slaptažodį", + "view.site": "Svetainė", + "view.system": "Sistema", + "view.users": "Vartotojai", + + "welcome": "Sveiki", + "year": "Metai", + "yes": "taip" +} diff --git a/public/kirby/i18n/translations/nb.json b/public/kirby/i18n/translations/nb.json new file mode 100644 index 0000000..acd8d66 --- /dev/null +++ b/public/kirby/i18n/translations/nb.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Endre navnet ditt", + "account.delete": "Slett kontoen din", + "account.delete.confirm": "Er du sikker på at du vil slette kontoen din? Du vil bli logget ut umiddelbart. Kontoen din kan ikke gjenopprettes.", + + "activate": "Aktiver", + "add": "Legg til", + "alpha": "Alfa", + "author": "Forfatter", + "avatar": "Profilbilde", + "back": "Tilbake", + "cancel": "Avbryt", + "change": "Endre", + "close": "Lukk", + "changes": "Endringer", + "confirm": "Lagre", + "collapse": "Skjul", + "collapse.all": "Skjule alle", + "color": "Farge", + "coordinates": "Koordinater", + "copy": "Kopier", + "copy.all": "Kopier alle", + "copy.success": "{count} kopiert!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Opprett", + "custom": "Egendefinert", + + "date": "Dato", + "date.select": "Velg dato", + + "day": "Dag", + "days.fri": "Fre", + "days.mon": "Man", + "days.sat": "L\u00f8r", + "days.sun": "S\u00f8n", + "days.thu": "Tor", + "days.tue": "Tir", + "days.wed": "Ons", + + "debugging": "Feilsøker", + + "delete": "Slett", + "delete.all": "Slett alle", + + "dialog.fields.empty": "Denne dialogen har ingen felter", + "dialog.files.empty": "Ingen filer å velge", + "dialog.pages.empty": "Ingen sider å velge", + "dialog.text.empty": "Denne dialogen definerer ingen tekst", + "dialog.users.empty": "Ingen brukere å velge", + + "dimensions": "Dimensjoner", + "disable": "Deaktivere", + "disabled": "Deaktivert", + "discard": "Forkast", + + "drawer.fields.empty": "Denne skuffen har ingen felt", + + "domain": "Domene", + "download": "Last ned", + "duplicate": "Dupliser", + + "edit": "Rediger", + + "email": "Epost", + "email.placeholder": "epost@eksempel.no", + + "enter": "Enter", + "entries": "Artikler", + "entry": "Artikkel", + + "environment": "Miljø", + + "error": "Feil", + "error.access.code": "Ugyldig kode", + "error.access.login": "Ugyldig innlogging", + "error.access.panel": "Du har ikke tilgang til panelet", + "error.access.view": "Du har ikke tilgang til denne delen av panelet", + + "error.avatar.create.fail": "Profilbildet kunne ikke lastes opp", + "error.avatar.delete.fail": "Profilbildet kunne ikke slettes", + "error.avatar.dimensions.invalid": "Vennligst hold profilbildets bredde og høyde under 3000 piksler", + "error.avatar.mime.forbidden": "Ugyldig MIME-type", + + "error.blueprint.notFound": "Blueprint \"{name}\" kunne ikke lastes inn", + + "error.blocks.max.plural": "Du kan ikke legge til flere enn {max} blokker", + "error.blocks.max.singular": "Du kan ikke legge til mer enn en blokk", + "error.blocks.min.plural": "Du må legge til minst {min} blokker", + "error.blocks.min.singular": "Du må legge til minst en blokk", + "error.blocks.validation": "Det er en feil med feltet \"{field}\" i blokk {index} hvor blokktypen \"{fieldset}\" brukes", + + "error.cache.type.invalid": "Ugyldig type cache \"{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": "Det er en feilmelding på felt \"{field}\" i rad {index}", + + "error.email.preset.notFound": "E-postinnstillingen \"{name}\" ble ikke funnet", + + "error.field.converter.invalid": "Ugyldig omformer \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Felt \"{ name }\": Felttypen \"{ type }\" finnes ikke", + + "error.file.changeName.empty": "Navnet kan ikke være tomt", + "error.file.changeName.permission": "Du har ikke rettighet til å endre navnet til \"{filename}\"", + "error.file.changeTemplate.invalid": "Malen for filen \"{id}\" Kan ikke endres til \"{template}\" (gyldig: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Du har ikke rettigheter til å endre malen for 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.duplicate": "En fil med navnet \"{filename}\" eksisterer allerede", + "error.file.extension.forbidden": "Ugyldig filtype", + "error.file.extension.invalid": "Ugyldig utvidelse: {extension}", + "error.file.extension.missing": "Du kan ikke laste opp filer uten filtype", + "error.file.maxheight": "Høyden til bildet kan ikke overgå {height} piksler", + "error.file.maxsize": "Filen er for stor", + "error.file.maxwidth": "Bredden til bildet kan ikke overgå {width} piksler", + "error.file.mime.differs": "Den opplastede filen må være av samme MIME-type \"{mime}\"", + "error.file.mime.forbidden": "Mediatypen \"{mime}\" er ikke tillatt", + "error.file.mime.invalid": "Ugyldig mediatype: {mime}", + "error.file.mime.missing": "Mediatypen for \"{filename}\" kan ikke gjenkjennes", + "error.file.minheight": "Høyden til bildet må være minst {height} piksler", + "error.file.minsize": "Filen er for liten", + "error.file.minwidth": "Bredden til bildet må være minst {width} piksler", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Filnavnet kan ikke være tomt", + "error.file.notFound": "Filen \"{filename}\" kan ikke bli funnet", + "error.file.orientation": "Bilderetningen må være \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Du har ikke lov til å laste opp filer av typen {type}", + "error.file.type.invalid": "Ugyldig filtype: {type}", + "error.file.undefined": "Finner ikke filen", + + "error.form.incomplete": "Vennligst fiks alle feil…", + "error.form.notSaved": "Skjemaet kunne ikke lagres", + + "error.language.code": "Vennligst skriv inn gyldig språkkode", + "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": "Språket eksisterer allerede", + "error.language.name": "Vennligst skriv inn et gyldig navn for språket", + "error.language.notFound": "Finner ikke språket", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Det er en feilmelding på \"{field}\" feltet i blokk {blockIndex} med bruk av \"{fieldset}\" blokktypen i layout {layoutIndex}", + "error.layout.validation.settings": "Det er en feil i layout {index} innstillinger", + + "error.license.domain": "Domenen for lisensen mangler", + "error.license.email": "Vennligst skriv inn en gyldig e-postadresse", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "Lisensen kunne ikke verifiseres", + + "error.login.totp.confirm.invalid": "Ugyldig kode", + "error.login.totp.confirm.missing": "Vennligst skriv inn nåværende koden", + + "error.object.validation": "Det er en feilmelding i \"{label}\" feltet:\n{message}", + + "error.offline": "Panelet er i øyeblikket offline", + + "error.page.changeSlug.permission": "Du kan ikke endre URLen for denne siden", + "error.page.changeSlug.reserved": "Stien til toppnivåsider kan ikke starte med \"{path}\"", + "error.page.changeStatus.incomplete": "Siden har feil og kan ikke publiseres", + "error.page.changeStatus.permission": "Sidens status kan ikke endres", + "error.page.changeStatus.toDraft.invalid": "Siden \"{slug}\" kan ikke konverteres til et utkast", + "error.page.changeTemplate.invalid": "Malen for siden \"{slug}\" kan ikke endres", + "error.page.changeTemplate.permission": "Du har ikke tillatelse til å endre malen for \"{slug}\"", + "error.page.changeTitle.empty": "Tittelen kan ikke være tom", + "error.page.changeTitle.permission": "Du har ikke tillatelse til å endre tittelen for \"{slug}\"", + "error.page.create.permission": "Du har ikke tillatelse til å opprette \"{slug}\"", + "error.page.delete": "Siden \"{slug}\" kan ikke slettes", + "error.page.delete.confirm": "Vennligst skriv inn sidens tittel for å bekrefte", + "error.page.delete.hasChildren": "Siden har undersider og kan derfor ikke slettes", + "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": "Du har ikke til å slette \"{slug}\"", + "error.page.draft.duplicate": "Et sideutkast med URL-tillegget \"{slug}\" eksisterer allerede", + "error.page.duplicate": "En side med URL-tillegget \"{slug}\" eksisterer allerede", + "error.page.duplicate.permission": "Du har ikke tillatelse til å duplisere \"{slug}\"", + "error.page.move.ancestor": "Siden kan ikke flyttes inn i seg selv", + "error.page.move.directory": "Sidestrukturen kan ikke flyttes", + "error.page.move.duplicate": "En underside med url banen \"{slug}\" finnes allerede", + "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": "Den flyttede siden kan ikke bli funnet", + "error.page.move.permission": "You are not allowed to move \"{slug}\"", + "error.page.move.template": "\"{template}\" malen er ikke akseptert som underside av \"{parent}\"", + "error.page.notFound": "Siden \"{slug}\" ble ikke funnet", + "error.page.num.invalid": "Vennligst skriv inn et gyldig sorteringsnummer. Tallet må ikke være negativt.", + "error.page.slug.invalid": "Vennligst skriv inn en gyldig URL endelse", + "error.page.slug.maxlength": "Slug lengden må være mindre enn \"{length}\" karakterer", + "error.page.sort.permission": "Siden \"{slug}\" kan ikke sorteres", + "error.page.status.invalid": "Vennligst angi en gyldig sidestatus", + "error.page.undefined": "Siden kunne ikke bli funnet", + "error.page.update.permission": "Du har ikke tillatelse til å oppdatere \"{slug}\"", + + "error.section.files.max.plural": "Det er ikke mulig å legge til mer enn {max} filer i seksjonen \"{section}\"", + "error.section.files.max.singular": "Det er ikke mulig å legge til mer enn én fil i seksjonen \"{section}\"", + "error.section.files.min.plural": "Seksjonen \"{section}\" krever minst {min} filer", + "error.section.files.min.singular": "Seksjonen \"{section}\" krever minst en fil", + + "error.section.pages.max.plural": "Det er ikke mulig å legge til mer enn {max} sider i \"{section}\" seksjonen", + "error.section.pages.max.singular": "Det er ikke mulig å legge til mer enn én side i \"{section}\" seksjonen", + "error.section.pages.min.plural": "Seksjonen \"{section}\" krever minst {min} sider", + "error.section.pages.min.singular": "Seksjonen \"{section}\" krever minst en side", + + "error.section.notLoaded": "Seksjonen \"{name}\" kunne ikke lastes inn", + "error.section.type.invalid": "Seksjonstypen \"{type}\" er ikke gyldig", + + "error.site.changeTitle.empty": "Tittelen kan ikke være tom", + "error.site.changeTitle.permission": "Du har ikke tillatelse til å endre tittel på siden", + "error.site.update.permission": "Du har ikke tillatelse til å oppdatere denne siden", + + "error.structure.validation": "Det er en feilmelding på felt \"{field}\" i rad {index}", + + "error.template.default.notFound": "Standardmalen eksisterer ikke", + + "error.unexpected": "En uventet feil oppstod! Aktiver feilsøkmodus for mer info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Du har ikke tillatelse til å endre e-post for brukeren \"{name}\"", + "error.user.changeLanguage.permission": "Du har ikke tillatelse til å endre språk for brukeren \"{name}\"", + "error.user.changeName.permission": "Du har ikke tillatelse til å endre navn for brukeren \"{name}\"", + "error.user.changePassword.permission": "Du har ikke tillatelse til å endre passord for brukeren \"{name}\"", + "error.user.changeRole.lastAdmin": "Rollen for den siste administratoren kan ikke endres", + "error.user.changeRole.permission": "Du har ikke tillatelse til å endre rollen for brukeren \"{name}\"", + "error.user.changeRole.toAdmin": "Du har ikke tillatelse til å endre noen til adminrolle", + "error.user.create.permission": "Du har ikke tillatelse til å opprette denne brukeren", + "error.user.delete": "Denne brukeren kunne ikke bli slettet", + "error.user.delete.lastAdmin": "Siste administrator kan ikke slettes", + "error.user.delete.lastUser": "Den siste brukeren kan ikke slettes", + "error.user.delete.permission": "Du er ikke tillat \u00e5 slette denne brukeren", + "error.user.duplicate": "En bruker med e-postadresse \"{email}\" eksisterer allerede", + "error.user.email.invalid": "Vennligst skriv inn en gyldig e-postadresse", + "error.user.language.invalid": "Vennligst skriv inn et gyldig språk", + "error.user.notFound": "Brukeren kunne ikke bli funnet", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Vennligst skriv inn et gyldig passord. Passordet må minst være 8 tegn langt.", + "error.user.password.notSame": "Vennligst bekreft passordet", + "error.user.password.undefined": "Brukeren har ikke et passord", + "error.user.password.wrong": "Feil passord", + "error.user.role.invalid": "Vennligst skriv inn en gyldig rolle", + "error.user.undefined": "Brukeren kunne ikke bli funnet", + "error.user.update.permission": "Du har ikke tillatelse til å oppdatere brukeren \"{name}\"", + + "error.validation.accepted": "Vennligst bekreft", + "error.validation.alpha": "Vennligst skriv kun tegn mellom a-z", + "error.validation.alphanum": "Vennligst skriv kun tegn mellom a-z eller tall mellom 0-9", + "error.validation.anchor": "Vennligst skriv inn en riktig link-ankertekst", + "error.validation.between": "Vennligst angi en verdi mellom \"{min}\" og \"{max}\"", + "error.validation.boolean": "Vennligst bekreft eller avslå", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Vennligst skriv inn en verdi som inneholder \"{needle}\"", + "error.validation.date": "Vennligst skriv inn en gyldig dato", + "error.validation.date.after": "Vennligst angi en dato etter {date}", + "error.validation.date.before": "Vennligst angi en dato før {date}", + "error.validation.date.between": "Vennligst angi en dato mellom {min} og {max}", + "error.validation.denied": "Vennligst avslå", + "error.validation.different": "Verdien kan ikke være \"{other}\"", + "error.validation.email": "Vennligst skriv inn en gyldig e-postadresse", + "error.validation.endswith": "Verdien må ende med \"{end}\"", + "error.validation.filename": "Vennligst skriv inn et gyldig filnavn", + "error.validation.in": "Vennligst skriv inn en av følgende: ({in})", + "error.validation.integer": "Vennligst skriv inn et gyldig tall", + "error.validation.ip": "Vennligst skriv inn en gyldig IP-adresse", + "error.validation.less": "Vennligst angi en verdi lavere enn {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Verdien samsvarer ikke med det forventede mønsteret", + "error.validation.max": "Vennligst angi en verdi lik eller lavere enn {max}", + "error.validation.maxlength": "Vennligst angi en kortere verdi. (maks. {max} tegn)", + "error.validation.maxwords": "Vennligst ikke skriv inn mer enn {max} ord", + "error.validation.min": "Vennligst angi en verdi lik eller større enn {min}", + "error.validation.minlength": "Vennligst angi en lengre verdi. (minimum. {min} tegn)", + "error.validation.minwords": "Vennligst skriv inn minst {min} ord", + "error.validation.more": "Vennligst angi en verdi større enn {min}", + "error.validation.notcontains": "Vennligst angi en verdi som ikke inneholder \"{needle}\"", + "error.validation.notin": "Vennligst ikke angi noen av følgende:({notIn})", + "error.validation.option": "Vennligst velg et gyldig alternativ", + "error.validation.num": "Vennligst angi et gyldig nummer", + "error.validation.required": "Vennligst skriv inn noe", + "error.validation.same": "Vennligst angi \"{other}\"", + "error.validation.size": "Størrelsen på verdien må være \"{size}\"", + "error.validation.startswith": "Verdien må starte med \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Vennligst angi et gyldig tidspunkt", + "error.validation.time.after": "Vennligst angi et tidspunkt etter {time}", + "error.validation.time.before": "Vennligst angi et tidspunkt før {time}", + "error.validation.time.between": "Vennligst angi et tidspunkt mellom {min} og {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Vennligst skriv inn en gyldig URL", + + "expand": "Utvid", + "expand.all": "Utvid alle", + + "field.invalid": "The field is invalid", + "field.required": "Feltet er påkrevd", + "field.blocks.changeType": "Endre type", + "field.blocks.code.name": "Kode", + "field.blocks.code.language": "Språk", + "field.blocks.code.placeholder": "Din kode…", + "field.blocks.delete.confirm": "Er du sikker på at du vil slette denne blokken?", + "field.blocks.delete.confirm.all": "Er du sikker på at du vil slette alle blokkene?", + "field.blocks.delete.confirm.selected": "Er du sikker på at du vil slette de valgte blokkene?", + "field.blocks.empty": "Ingen blokker enda", + "field.blocks.fieldsets.empty": "Ingen feltsett enda", + "field.blocks.fieldsets.label": "Vennligst velg en blokktype…", + "field.blocks.fieldsets.paste": "Trykk {{ shortcut }} for å importere layout/blokker fra utklippsverktøyet Bare de som er tillat i nåværende felt vil bli limt inn.", + "field.blocks.gallery.name": "Galleri", + "field.blocks.gallery.images.empty": "Ingen bilder enda", + "field.blocks.gallery.images.label": "Bilder", + "field.blocks.heading.level": "Nivå", + "field.blocks.heading.name": "Overskrift", + "field.blocks.heading.text": "Tekst", + "field.blocks.heading.placeholder": "Overskrift…", + "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": "Alternativ tekst", + "field.blocks.image.caption": "Caption", + "field.blocks.image.crop": "Beskjær", + "field.blocks.image.link": "Adresse", + "field.blocks.image.location": "Plassering", + "field.blocks.image.location.internal": "Denne nettsiden", + "field.blocks.image.location.external": "Ekstern kilde", + "field.blocks.image.name": "Bilde", + "field.blocks.image.placeholder": "Velg et bilde", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Bilde URL", + "field.blocks.line.name": "Linje", + "field.blocks.list.name": "Liste", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Tekst", + "field.blocks.markdown.placeholder": "Markdown…", + "field.blocks.quote.name": "Sitat", + "field.blocks.quote.text.label": "Tekst", + "field.blocks.quote.text.placeholder": "Sitat…", + "field.blocks.quote.citation.label": "Kildehenvisning", + "field.blocks.quote.citation.placeholder": "av…", + "field.blocks.text.name": "Tekst", + "field.blocks.text.placeholder": "Tekst…", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Caption", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Plassering", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Legg til en video URL", + "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": "Vil du virkelig slette alle oppføringer?", + "field.entries.empty": "Ingen oppføringer enda", + + "field.files.empty": "Ingen filer har blitt valgt", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Endre layout", + "field.layout.delete": "Slett layout", + "field.layout.delete.confirm": "Er du sikker på at du vil slette denne layouten?", + "field.layout.delete.confirm.all": "Vil du virkelig slette alle layout?", + "field.layout.empty": "Ingen rader enda", + "field.layout.select": "Velg en layout", + + "field.object.empty": "Ingen informasjon enda", + + "field.pages.empty": "Ingen side har blitt valgt", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "\u00d8nsker du virkelig \u00e5 slette denne oppf\u00f8ringen?", + "field.structure.delete.confirm.all": "Vil du virkelig slette alle oppføringer?", + "field.structure.empty": "Ingen oppf\u00f8ringer enda", + + "field.users.empty": "Ingen bruker har blitt valgt", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "Ingen felt enda", + + "file": "Fil", + "file.blueprint": "Denne filen har ikke en blueprint enda. Du kan definere oppsettet i /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Endre mal", + "file.changeTemplate.notice": "Endring av denne filens mal kommer til å fjerne innhold for felter som ikke korresponderer med typen. Dersom den nye malen inneholder gitte regler, f.eks bildedimensjoner, vil også disse bli påført irreversibelt. Bruk varsomt.", + "file.delete.confirm": "Vil du virkelig slette denne filen?", + "file.focus.placeholder": "Sett fokuspunkt", + "file.focus.reset": "Fjern fokuspunkt", + "file.focus.title": "Focus", + "file.sort": "Endre plassering", + + "files": "Filer", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Ingen filer ennå", + + "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": "Skjul", + "hour": "Tid", + "hue": "Hue", + "import": "Importer", + "info": "Info", + "insert": "Sett Inn", + "insert.after": "Sett inn etter", + "insert.before": "Sett inn før", + "install": "Installer", + + "installation": "Installasjon", + "installation.completed": "Panelet har blitt installert", + "installation.disabled": "Installasjonsprogrammet for Panelet er deaktivert på offentlige servere som standard. Vennligst kjør installasjonsprogrammet på en lokal maskin eller aktiver den med panel.install innstillingen.", + "installation.issues.accounts": "\/site\/accounts er ikke skrivbar", + "installation.issues.content": "Mappen content og alt av innhold m\u00e5 v\u00e6re skrivbar.", + "installation.issues.curl": "Utvidelsen CURL er nødvendig", + "installation.issues.headline": "Panelet kan ikke installeres", + "installation.issues.mbstring": "Utvidelsen MB String er nødvendig", + "installation.issues.media": "Mappen /media eksisterer ikke eller er ikke skrivbar", + "installation.issues.php": "Pass på at du bruker PHP 8+", + "installation.issues.sessions": "Mappen /site/sessions eksisterer ikke eller er ikke skrivbar", + + "language": "Spr\u00e5k", + "language.code": "Kode", + "language.convert": "Gjør til standard", + "language.convert.confirm": "

Vil du virkelig konvertere {name} til standardspråk? Dette kan ikke angres.

Dersom {name} har innhold som ikke er oversatt, vil nettstedet mangle innhold å falle tilbake på. Dette kan resultere i at deler av nettstedet fremstår som tomt.

", + "language.create": "Legg til språk", + "language.default": "Standardspråk", + "language.delete.confirm": "Vil du virkelig slette språket {name} inkludert alle oversettelser? Dette kan ikke angres!", + "language.deleted": "Språket har blitt slettet", + "language.direction": "Leseretning", + "language.direction.ltr": "Venstre til høyre", + "language.direction.rtl": "Høyre til venstre", + "language.locale": "PHP locale streng", + "language.locale.warning": "Du bruker et egendefinert lokalt oppsett. Vennligst endre det i språkfilen i /site/languages", + "language.name": "Navn", + "language.secondary": "Sekundærspråk", + "language.settings": "Språkinstillinger", + "language.updated": "Språk har blitt oppdatert", + "language.variables": "Språkvariabler", + "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", + + "languages": "Språk", + "languages.default": "Standardspråk", + "languages.empty": "Det er ingen språk ennå", + "languages.secondary": "Sekundære språk", + "languages.secondary.empty": "Det er ingen andre språk ennå", + + "license": "Kirby lisens", + "license.activate": "Aktiver den nå", + "license.activate.label": "Vennligst skriv inn din lisenskode", + "license.activate.domain": "Lisenses skal bli aktivert for {host}.", + "license.activate.local": "Du er i ferd med å aktivere Kirby lisensen din til lokale domenen din {host}. Hvis nettsiden skal plasseres til en offentlig domene, vennligst aktivere den der isteden. Hvis {host} er domenen du vil bruke med din lisens, vennligst fortsett.", + "license.activated": "Aktivert", + "license.buy": "Kjøp lisens", + "license.code": "Kode", + "license.code.help": "Du har mottatt din lisenskoden via e-post etter kjøpet. Vennligst kopier og lim den inn her.", + "license.code.label": "Vennligst skriv inn din lisenskode", + "license.status.active.info": "Inkluderer nye hovedversjoner til {date}", + "license.status.active.label": "Gyldig lisens", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Forny lisens for å oppdatere til nye hovedversjoner", + "license.status.inactive.label": "Ingen nye hovedversjoner", + "license.status.legacy.bubble": "Klar til å fornye lisensen?", + "license.status.legacy.info": "Lisensen din omfatter ikke denne versjonen", + "license.status.legacy.label": "Vennligst fornye lisensen din", + "license.status.missing.bubble": "Klar til å lansere din nettside?", + "license.status.missing.info": "Ingen gyldig lisens", + "license.status.missing.label": "Vennligst skriv inn din lisenskode", + "license.status.unknown.info": "The license status is unknown", + "license.status.unknown.label": "Unknown", + "license.manage": "Håndter dine lisenser", + "license.purchased": "Kjøpt", + "license.success": "Takk for at du støtter Kirby", + "license.unregistered.label": "Ikke registrert", + + "link": "Adresse", + "link.text": "Koblingstekst", + + "loading": "Laster inn", + + "lock.unsaved": "Ulagrede endringer", + "lock.unsaved.empty": "Det er ingen flere ulagrede endringer", + "lock.unsaved.files": "Unsaved files", + "lock.unsaved.pages": "Unsaved pages", + "lock.unsaved.users": "Unsaved accounts", + "lock.isLocked": "Ulagrede endringer av {email}", + "lock.unlock": "Lås opp", + "lock.unlock.submit": "Lås opp og overskriv ulagrede endringer fra {email}", + "lock.isUnlocked": "Ble låst opp av en annen bruker", + + "login": "Logg Inn", + "login.code.label.login": "Login-kode", + "login.code.label.password-reset": "Passord tilbakestillingskode", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Dersom din e-post er registrert vil den forespurte koden bli sendt via e-post.", + "login.code.text.totp": "Vennligst skriv inn engangskoden fra authenticator appen din.", + "login.email.login.body": "Hei {user.nameOrEmail},\n\nDu ba nylig om en innloggingskode til panelet til {site}.\nFølgende innloggingskode vil være gyldig i {timeout} minutter:\n\n{code}\n\nDersom du ikke ba om en innloggingskode, vennligst ignorer denne e-posten eller kontakt din administrator hvis du har spørsmål.\nFor sikkerhets skyld, vennligst IKKE videresend denne e-posten.", + "login.email.login.subject": "Din innloggingskode", + "login.email.password-reset.body": "Hei {user.nameOrEmail},\n\nDu ba nylig om en tilbakestilling av passord til panelet til {site}.\nFølgende tilbakestillingskode vil være gyldig i {timeout} minutter:\n\n{code}\n\nDersom du ikke ba om en tilbakestillingskode, vennligst ignorer denne e-posten eller kontakt din administrator hvis du har spørsmål.\nFor sikkerhets skyld, vennligst IKKE videresend denne e-posten.", + "login.email.password-reset.subject": "Din kode for tilbakestilling av passord", + "login.remember": "Hold meg innlogget", + "login.reset": "Tilbakestill passord", + "login.toggleText.code.email": "Logg inn via e-post", + "login.toggleText.code.email-password": "Logg inn med passord", + "login.toggleText.password-reset.email": "Glemt passord?", + "login.toggleText.password-reset.email-password": "← Tilbake til innlogging", + "login.totp.enable.option": "Sett opp engangskoder", + "login.totp.enable.intro": "Autentiseringsapper kan generere engangskoder til bruk for totrinnspålogging når du logger inn på din konto.", + "login.totp.enable.qr.label": "1. Scan denne QR-koden", + "login.totp.enable.qr.help": "Kan du ikke scanne? Legg til installasjonsnøkkelen {secret} manuelt i din autentiseringsapp.", + "login.totp.enable.confirm.headline": "2. Bekreft med den genererte koden", + "login.totp.enable.confirm.text": "Din app genererer en engangskode hvert 30ende sekund. Skriv inn koden som vises nå for å ferdigstille oppsettet:", + "login.totp.enable.confirm.label": "Nærværende kode", + "login.totp.enable.confirm.help": "Etter dette er satt opp, vil vi spørre etter en engangskode hver gang du logger inn.", + "login.totp.enable.success": "Engangskoder er aktivert", + "login.totp.disable.option": "Deaktiver engangskoder", + "login.totp.disable.label": "Skriv inn ditt passord for å deaktivere bruk av engangskoder", + "login.totp.disable.help": "I fremtiden vil en annen tofaktorløsning – som en loginkode sendt via epost – bli etterspurt når du logger inn. Du kan alltid sette opp tofaktorkoder igjen på senere tidspunkt.", + "login.totp.disable.admin": "

Dette kommer til å deaktivere engangskoder for {user}.

I fremtiden vil en annen tofaktorløsning – som en loginkode sendt via epost – bli etterspurt når de logger inn. {user} kan alltid sette opp tofaktorkoder igjen på senere tidspunkt.", + "login.totp.disable.success": "Engangskoder deaktivert", + + "logout": "Logg ut", + + "merge": "Slå sammen", + "menu": "Meny", + "meridiem": "AM/PM", + "mime": "Mediatype", + "minutes": "Minutter", + + "month": "Måned", + "months.april": "April", + "months.august": "August", + "months.december": "Desember", + "months.february": "Februar", + "months.january": "Januar", + "months.july": "July", + "months.june": "Juni", + "months.march": "Mars", + "months.may": "Mai", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Mer", + "move": "Flytt", + "name": "Navn", + "next": "Neste", + "night": "Natt", + "no": "nei", + "off": "av", + "on": "på", + "open": "Åpne", + "open.newWindow": "Åpne i nytt vindu", + "option": "Alternativ", + "options": "Alternativer", + "options.none": "Ingen alternativer", + "options.all": "Vis alle {count} alternativ", + + "orientation": "Orientering", + "orientation.landscape": "Landskap", + "orientation.portrait": "Portrett", + "orientation.square": "Kvadrat", + + "page": "Side", + "page.blueprint": "Denne siden har ikke en blueprint enda. Du kan definere oppsettet i /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Endre URL", + "page.changeSlug.fromTitle": "Opprett fra tittel", + "page.changeStatus": "Endre status", + "page.changeStatus.position": "Vennligst velg en posisjon", + "page.changeStatus.select": "Velg ny status", + "page.changeTemplate": "Endre mal", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Lag som {status}", + "page.delete.confirm": "Vil du virkelig slette denne siden?", + "page.delete.confirm.subpages": "Denne siden har undersider.
Alle undersider vil også bli slettet.", + "page.delete.confirm.title": "Skriv inn sidetittel for å bekrefte", + "page.duplicate.appendix": "Kopier", + "page.duplicate.files": "Kopier filer", + "page.duplicate.pages": "Kopier sider", + "page.move": "Flytt side", + "page.sort": "Endre plassering", + "page.status": "Status", + "page.status.draft": "Utkast", + "page.status.draft.description": "Denne siden er i kladdmodus og er kun synlig for innloggede brukere eller via en hemmelig lenke.", + "page.status.listed": "Offentlig", + "page.status.listed.description": "Siden er offentlig og synlig for alle", + "page.status.unlisted": "Unotert", + "page.status.unlisted.description": "Siden er ikke er oppført og er kun tilgjengelig via URL", + + "pages": "Sider", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Ingen sider ennå", + "pages.status.draft": "Utkast", + "pages.status.listed": "Publisert", + "pages.status.unlisted": "Unotert", + + "pagination.page": "Side", + + "password": "Passord", + "paste": "Lim inn", + "paste.after": "Lim inn etter", + "paste.success": "{count} limt inn!", + "pixel": "Piksel", + "plugin": "Utvidelse", + "plugins": "Plugins", + "prev": "Forrige", + "preview": "Forhåndsvisning", + + "publish": "Publish", + "published": "Publisert", + + "remove": "Fjern", + "rename": "Endre navn", + "renew": "Fornye", + "replace": "Erstatt", + "replace.with": "Erstatt med", + "retry": "Pr\u00f8v p\u00e5 nytt", + "revert": "Forkast", + "revert.confirm": "Er du sikker på at vil slette alle ulagrede endringer?", + + "role": "Rolle", + "role.admin.description": "Administrator har alle rettigheter", + "role.admin.title": "Admin", + "role.all": "Alle", + "role.empty": "Det er ingen brukere med denne rollen", + "role.description.placeholder": "Ingen beskrivelse", + "role.nobody.description": "Dette er en fallback rolle uten noen rettigheter.", + "role.nobody.title": "Ingen", + + "save": "Lagre", + "saved": "Saved", + "search": "Søk", + "searching": "Searching", + "search.min": "Skriv inn {min} tegn for å søke", + "search.all": "Vis alle {count} resultat", + "search.results.none": "Ingen resultater", + + "section.invalid": "The section is invalid", + "section.required": "Denne seksjonen er påkrevd", + + "security": "Sikkerhet", + "select": "Velg", + "server": "Server", + "settings": "Innstillinger", + "show": "Vis", + "site.blueprint": "Denne siden har ikke en blueprint enda. Du kan definere oppsettet i /site/blueprints/site.yml", + "size": "Størrelse", + "slug": "URL-appendiks", + "sort": "Sortere", + "sort.drag": "Drag to sort …", + "split": "Del", + + "stats.empty": "Ingen rapporter", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "content-mappen ser ut til å være eksponert", + "system.issues.eol.kirby": "Din installerte Kirby versjon har nådd sitt end-of-life, og vil ikke lenger motta sikkerhetsoppdateringer", + "system.issues.eol.plugin": "Din installerte plugin { plugin } har nådd sitt end-of-life og vil ikke lenger motta sikkerhetsoppdateringer", + "system.issues.eol.php": "Din installerte PHP versjon { release } har nådd sitt end-of-life og vil ikke lenger motta sikkerhetsoppdateringer", + "system.issues.debug": "Debugging må bli skrudd av i production", + "system.issues.git": ".git mappen ser ut til å være eksponert", + "system.issues.https": "Vi anbefaler HTTPS for alle dine sider", + "system.issues.kirby": "kirby-mappen ser ut til å være eksponert", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "site-mappen ser ut til å være eksponert", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Din installasjon er muligens påvirket av følgende sikkerhetshull ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Din installasjon er muligens påvirket av følgende sikkerhetshull i pluginen { plugin } ({ severity } severity): { description }", + "system.updateStatus": "Oppdater status", + "system.updateStatus.error": "Klarte ikke å lete etter oppdateringer", + "system.updateStatus.not-vulnerable": "Ingen kjente sikkerhetshull", + "system.updateStatus.security-update": "Gratis sikkerhetsoppdatering { version } tilgjengelig", + "system.updateStatus.security-upgrade": "Oppdatering { version } med sikkerhetsoppdateringer tilgjengelig", + "system.updateStatus.unreleased": "Ulansert versjon", + "system.updateStatus.up-to-date": "Oppdatert", + "system.updateStatus.update": "Gratis oppdatering { version } tilgjengelig", + "system.updateStatus.upgrade": "Oppdatering { version } tilgjengelig", + + "tel": "Telefon", + "tel.placeholder": "+49123456789", + "template": "Mal", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Tittel", + "today": "I dag", + + "toolbar.button.clear": "Fjern formattering", + "toolbar.button.code": "Kode", + "toolbar.button.bold": "Fet tekst", + "toolbar.button.email": "Epost", + "toolbar.button.headings": "Overskrifter", + "toolbar.button.heading.1": "Overskrift 1", + "toolbar.button.heading.2": "Overskrift 2", + "toolbar.button.heading.3": "Overskrift 3", + "toolbar.button.heading.4": "Overskrift 4", + "toolbar.button.heading.5": "Overskrift 5", + "toolbar.button.heading.6": "Overskrift 6", + "toolbar.button.italic": "Kursiv tekst", + "toolbar.button.file": "Fil", + "toolbar.button.file.select": "Velg en fil", + "toolbar.button.file.upload": "Last opp en fil", + "toolbar.button.link": "Adresse", + "toolbar.button.paragraph": "Avsnitt", + "toolbar.button.strike": "Gjennomstreking", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Ordnet liste", + "toolbar.button.underline": "Understrek", + "toolbar.button.ul": "Punktliste", + + "translation.author": "Kirby Team", + "translation.direction": "ltr", + "translation.name": "Norsk Bokm\u00e5l", + "translation.locale": "nb_NO", + + "type": "Type", + + "upload": "Last opp", + "upload.error.cantMove": "Den opplastede filen kunne ikke flyttes", + "upload.error.cantWrite": "Kunne ikke skrive fil til disk", + "upload.error.default": "Kunne ikke laste opp fil", + "upload.error.extension": "Filopplasting stoppet av en utvidelse", + "upload.error.formSize": "Den opplastede filen overskrider MAX_FILE_SIZE direktivet som er spesifisert i skjemaet", + "upload.error.iniPostSize": "Den opplastede filen overskrider post_max_size direktivet i php.ini", + "upload.error.iniSize": "Den opplastede filen overskrider upload_max_filesize direktivet i php.ini", + "upload.error.noFile": "Ingen fil ble lastet opp", + "upload.error.noFiles": "Ingen filer ble lastet opp", + "upload.error.partial": "Den opplastede filen ble bare delvis lastet opp", + "upload.error.tmpDir": "Mangler en midlertidig mappe", + "upload.errors": "Feil", + "upload.progress": "Laster opp…", + + "url": "Nettadresse", + "url.placeholder": "https://eksempel.no", + + "user": "Bruker", + "user.blueprint": "Du kan definere flere seksjoner og skjemafelter for denne brukerrollen i /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Endre e-post", + "user.changeLanguage": "Endre språk", + "user.changeName": "Angi nytt navn for denne brukeren", + "user.changePassword": "Bytt passord", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Nytt passord", + "user.changePassword.new.confirm": "Bekreft nytt passord…", + "user.changeRole": "Bytt rolle", + "user.changeRole.select": "Velg en ny rolle", + "user.create": "Legg til ny bruker", + "user.delete": "Slett denne brukeren", + "user.delete.confirm": "Vil du virkelig slette denne konten?", + + "users": "Brukere", + + "version": "Kirby versjon", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Nåværende versjon", + "version.latest": "Siste versjon", + "versionInformation": "Versjonsinformasjon", + + "view": "View", + "view.account": "Din konto", + "view.installation": "Installasjon", + "view.languages": "Språk", + "view.resetPassword": "Tilbakestill passord", + "view.site": "Side", + "view.system": "System", + "view.users": "Brukere", + + "welcome": "Velkommen", + "year": "År", + "yes": "ja" +} diff --git a/public/kirby/i18n/translations/nl.json b/public/kirby/i18n/translations/nl.json new file mode 100644 index 0000000..1a51a82 --- /dev/null +++ b/public/kirby/i18n/translations/nl.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Wijzig je naam", + "account.delete": "Verwijder je account", + "account.delete.confirm": "Wil je echt je account verwijderen? Je wordt direct uitgelogd. Je account kan niet worden hersteld.", + + "activate": "Activeren", + "add": "Voeg toe", + "alpha": "Alpha", + "author": "Auteur", + "avatar": "Avatar", + "back": "Terug", + "cancel": "Annuleren", + "change": "Wijzigen", + "close": "Sluiten", + "changes": "Wijzigingen", + "confirm": "Oke", + "collapse": "Sluit", + "collapse.all": "Sluit alles", + "color": "Kleur", + "coordinates": "Coördinaten ", + "copy": "Kopiëren", + "copy.all": "Kopieer alles", + "copy.success": "Gekopieerd", + "copy.success.multiple": "{count} gekopieerd!", + "copy.url": "Kopieer URL", + "create": "Aanmaken", + "custom": "Custom", + + "date": "Datum", + "date.select": "Selecteer een datum", + + "day": "Dag", + "days.fri": "Vr", + "days.mon": "Ma", + "days.sat": "Za", + "days.sun": "Zo", + "days.thu": "Do", + "days.tue": "Di", + "days.wed": "Wo", + + "debugging": "Foutopsporing", + + "delete": "Verwijderen", + "delete.all": "Verwijder alles", + + "dialog.fields.empty": "Dit venster heeft geen velden", + "dialog.files.empty": "Geen bestanden om te selecteren", + "dialog.pages.empty": "Geen pagina's om te selecteren", + "dialog.text.empty": "Dit venster bevat geen tekst", + "dialog.users.empty": "Geen gebruikers om te selecteren", + + "dimensions": "Dimensies", + "disable": "Uitschakelen", + "disabled": "Uitgeschakeld", + "discard": "Annuleren", + + "drawer.fields.empty": "Deze drawer heeft geen velden", + + "domain": "Domein", + "download": "Download", + "duplicate": "Dupliceren", + + "edit": "Wijzig", + + "email": "E-mailadres", + "email.placeholder": "mail@voorbeeld.nl", + + "enter": "Enter", + "entries": "Items", + "entry": "Item", + + "environment": "Omgeving", + + "error": "Foutmelding", + "error.access.code": "Ongeldige code", + "error.access.login": "Ongeldige login", + "error.access.panel": "Je hebt geen toegang tot het Panel", + "error.access.view": "Je hebt geen toegangsrechten voor dit gedeelte van het Panel", + + "error.avatar.create.fail": "De avatar kon niet worden geupload", + "error.avatar.delete.fail": "De avatar kan niet worden verwijderd", + "error.avatar.dimensions.invalid": "Houd de breedte en hoogte van de avatar onder 3000 pixels", + "error.avatar.mime.forbidden": "De avatar moet een JPEG of PNG bestand zijn", + + "error.blueprint.notFound": "De blueprint \"{name}\" kon niet geladen worden", + + "error.blocks.max.plural": "Je kunt niet meer dan {max} blokken toevoegen", + "error.blocks.max.singular": "Je kunt niet meer dan één blok toevoegen", + "error.blocks.min.plural": "Je moet ten minste {min} blok toevoegen", + "error.blocks.min.singular": "Je moet ten minste één blok toevoegen", + "error.blocks.validation": "Er is een fout opgetreden bij het \"{field}\" veld in blok {index} in het \"{fieldset}\" bloktype", + + "error.cache.type.invalid": "Ongeldig cache type \"{type}\"", + + "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": "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": "Ongeldige opties: {options}", + "error.field.type.missing": "Veld \"{ name }\": Het veldtype \"{ type }\" bestaat niet", + + "error.file.changeName.empty": "De naam mag niet leeg zijn", + "error.file.changeName.permission": "Je hebt geen rechten om de naam te wijzigen van \"{filename}\"", + "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": "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}", + "error.file.extension.missing": "Je kunt geen bestanden uploaden zonder bestandsextensie", + "error.file.maxheight": "De hoogte van de afbeelding mag niet groter zijn dan {height} pixels", + "error.file.maxsize": "Het bestand is te groot", + "error.file.maxwidth": "De breedte van de afbeelding mag niet groter zijn dan {width} pixels", + "error.file.mime.differs": "Het geüploade bestand moet van hetzelfde mime-type zijn: \"{mime}\"", + "error.file.mime.forbidden": "Het type \"{mime}\" is niet toegestaan", + "error.file.mime.invalid": "Ongeldig media type: {mine}", + "error.file.mime.missing": "Het mediatype voor \"{filename}\" kan niet worden gedecteerd", + "error.file.minheight": "De hoogte van de afbeelding moet minimaal {height} pixels zijn", + "error.file.minsize": "Het bestand is te klein", + "error.file.minwidth": "De breedte van de afbeelding moet minimaal {width} pixels zijn", + "error.file.name.unique": "De bestandsnaam moet uniek zijn", + "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": "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", + + "error.form.incomplete": "Verbeter alle fouten in het formulier", + "error.form.notSaved": "Het formulier kon niet worden opgeslagen", + + "error.language.code": "Vul een geldige code voor deze taal in", + "error.language.create.permission": "Je hebt geen rechten om een taal toe te voegen", + "error.language.delete.permission": "Je hebt geen rechten om een taal te verwijderen", + "error.language.duplicate": "De taal bestaat al", + "error.language.name": "Vul een geldige naam voor deze taal in", + "error.language.notFound": "De taal kan niet worden gevonden", + "error.language.update.permission": "Je hebt geen rechten om deze taal te updaten", + + "error.layout.validation.block": "Er is een fout opgetreden bij het \"{field}\" veld in blok {blockIndex} in het \"{fieldset}\" bloktype in layout {layoutIndex}", + "error.layout.validation.settings": "Er is een fout gevonden in de instellingen van ontwerp {index} ", + + "error.license.domain": "Het domein voor de licentie ontbreekt", + "error.license.email": "Gelieve een geldig emailadres in te voeren", + "error.license.format": "Vul een geldige licentie in", + "error.license.verification": "De licentie kon niet worden geverifieerd. ", + + "error.login.totp.confirm.invalid": "Ongeldige code", + "error.login.totp.confirm.missing": "Vul de code in", + + "error.object.validation": "Er is een fout opgetreden in het veld \"{label}\":\n{message}", + + "error.offline": "Het Panel is momenteel offline", + + "error.page.changeSlug.permission": "Je kunt de URL van deze pagina niet wijzigen", + "error.page.changeSlug.reserved": "Het pad van hoofdpagina's mogen niet beginnen met \"{path}\".", + "error.page.changeStatus.incomplete": "Deze pagina bevat fouten en kan niet worden gepubliceerd", + "error.page.changeStatus.permission": "De status van deze pagina kan niet worden gewijzigd", + "error.page.changeStatus.toDraft.invalid": "De pagina \"{slug}\" kan niet worden aangepast naar 'concept'", + "error.page.changeTemplate.invalid": "De template van deze pagina \"{slug}\" kan niet worden gewijzigd", + "error.page.changeTemplate.permission": "Je hebt geen rechten om het template te wijzigen van \"{slug}\"", + "error.page.changeTitle.empty": "De titel mag niet leeg zijn", + "error.page.changeTitle.permission": "Je hebt geen rechten om de titel te wijzigen van \"{slug}\"", + "error.page.create.permission": "Je hebt geen rechten om \"{slug}\" aan te maken", + "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": "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}\"", + "error.page.duplicate.permission": "Je bent niet gemachtigd om \"{slug}\" te dupliceren", + "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": "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}\"", + "error.page.notFound": "De pagina \"{slug}\" kan niet worden gevonden", + "error.page.num.invalid": "Vul een geldig sorteer-cijfer in. Het cijfer mag niet negatief zijn", + "error.page.slug.invalid": "Vul een geldig URL-achtervoegsel in", + "error.page.slug.maxlength": "Slug lengte moet minder dan \"{length}\" tekens bevatten", + "error.page.sort.permission": "De pagina \"{slug}\" kan niet worden gesorteerd", + "error.page.status.invalid": "Zorg voor een geldige paginastatus", + "error.page.undefined": "De pagina kan niet worden gevonden", + "error.page.update.permission": "Je hebt geen rechten om \"{slug}\" te updaten", + + "error.section.files.max.plural": "Voeg niet meer dan {max} bestanden toe aan de zone \"{section}\"", + "error.section.files.max.singular": "Je kunt niet meer dan 1 bestand toevoegen aan de zone \"{section}\"", + "error.section.files.min.plural": "De \"{section}\" sectie moet minimaal {min} bestanden bevatten.", + "error.section.files.min.singular": "De \"{section}\" sectie moet minimaal 1 bestand bevatten.", + + "error.section.pages.max.plural": "Je kunt niet meer dan {max} pagina's toevoegen aan de zone \"{section}\"", + "error.section.pages.max.singular": "Je kunt niet meer dan 1 pagina toevoegen aan de zone \"{section}\"", + "error.section.pages.min.plural": "De \"{section}\" sectie moet minimaal {min} pagina's bevatten.", + "error.section.pages.min.singular": "De \"{section}\" sectie moet minimaal 1 pagina bevatten.", + + "error.section.notLoaded": "De zone \"{name}\" kan niet worden geladen", + "error.section.type.invalid": "Zone-type \"{type}\" is niet geldig", + + "error.site.changeTitle.empty": "De titel mag niet leeg zijn", + "error.site.changeTitle.permission": "Je hebt geen rechten om de titel van de site te wijzigen", + "error.site.update.permission": "Je hebt geen rechten om de site te updaten", + + "error.structure.validation": "Er is een fout opgetreden in veld \"{field}\" in rij {index}", + + "error.template.default.notFound": "Het standaard template bestaat niet", + + "error.unexpected": "Een onverwacht fout heeft plaats gevonden! Schakel debug-modus in voor meer informatie: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Je hebt geen rechten om het e-mailadres van gebruiker \"{name}\" te wijzigen", + "error.user.changeLanguage.permission": "Je hebt geen rechten om de taal voor gebruiker \"{name}\" te wijzigen", + "error.user.changeName.permission": "Je hebt geen rechten om de naam van gebruiker \"{name}\" te wijzigen", + "error.user.changePassword.permission": "Je hebt geen rechten om het wachtwoord van gebruiker \"{name}\" te wijzigen", + "error.user.changeRole.lastAdmin": "De rol van de laatste beheerder kan niet worden gewijzigd", + "error.user.changeRole.permission": "Je hebt geen rechten om de rol van gebruiker \"{name}\" te wijzigen", + "error.user.changeRole.toAdmin": "Je hebt geen rechten om de rol van iemand te wijzigen naar admin", + "error.user.create.permission": "Je hebt geen rechten om deze gebruiker aan te maken", + "error.user.delete": "De gebruiker \"{name}\" kan niet worden verwijderd", + "error.user.delete.lastAdmin": "Je kan de laatste admin niet verwijderen", + "error.user.delete.lastUser": "De laatste gebruiker kan niet worden verwijderd", + "error.user.delete.permission": "Je hebt geen rechten om gebruiker \"{name}\" te verwijderen", + "error.user.duplicate": "Er bestaat al een gebruiker met e-mailadres \"{email}\"", + "error.user.email.invalid": "Vul een geldig e-mailadres in", + "error.user.language.invalid": "Vul een geldige taal in", + "error.user.notFound": "De gebruiker \"{name}\" kan niet worden gevonden", + "error.user.password.excessive": "Voer een geldig wachtwoord in. Wachtwoorden mogen niet langer zijn dan 1000 tekens.", + "error.user.password.invalid": "Voer een geldig wachtwoord in. Wachtwoorden moeten minstens 8 tekens lang zijn.", + "error.user.password.notSame": "De wachtwoorden komen niet overeen", + "error.user.password.undefined": "De gebruiker heeft geen wachtwoord", + "error.user.password.wrong": "Fout wachtwoord", + "error.user.role.invalid": "Vul een geldige rol in", + "error.user.undefined": "De gebruiker kan niet worden gevonden", + "error.user.update.permission": "Je hebt geen rechten om gebruiker \"{name}\" te updaten", + + "error.validation.accepted": "Ga akkoord", + "error.validation.alpha": "Vul alleen a-z karakters in", + "error.validation.alphanum": "Vul alleen tekens in tussen a-z of cijfers 0-9", + "error.validation.anchor": "Vul een juiste link in", + "error.validation.between": "Vul een waarde tussen \"{min}\" en \"{max}\"", + "error.validation.boolean": "Ga akkoord of weiger", + "error.validation.color": "Vul een geldige kleur in {format} in", + "error.validation.contains": "Vul een waarde in die \"{needle}\" bevat", + "error.validation.date": "Vul een geldige datum in", + "error.validation.date.after": "Vul een datum in na {date}", + "error.validation.date.before": "Vul een datum in voor {date}", + "error.validation.date.between": "Vul een datum in tussen {min} en {max}", + "error.validation.denied": "Weiger", + "error.validation.different": "De invoer mag niet \"{other}\" zijn", + "error.validation.email": "Vul een geldig e-mailadres in", + "error.validation.endswith": "De invoer moet eindigen met \"{end}\"", + "error.validation.filename": "Vul een geldige bestandsnaam in", + "error.validation.in": "Vul één van de volgende dingen in: ({in})", + "error.validation.integer": "Vul een geldig geheel getal in", + "error.validation.ip": "Vul een geldig IP-adres in", + "error.validation.less": "Vul een waarde in lager dan {max}", + "error.validation.linkType": "Het type link is niet toegestaan", + "error.validation.match": "De invoer klopt niet met het verwachte patroon", + "error.validation.max": "Vul een waarde in die gelijk is aan of lager dan {max}", + "error.validation.maxlength": "Gebruik minder karakters (maximaal {max} karakters)", + "error.validation.maxwords": "Vul minder dan {max} woord(en) in", + "error.validation.min": "Vul een waarde in die gelijk is aan of groter dan {min}", + "error.validation.minlength": "Gebruik meer karakters (minimaal {min} karakters)", + "error.validation.minwords": "Vul minimaal {min} woord(en) in", + "error.validation.more": "Vul een grotere waarde in dan {min}", + "error.validation.notcontains": "Zorg dat de invoer niet \"{needle}\" bevat", + "error.validation.notin": "Vul de volgende dingen niet in: ({notIn})", + "error.validation.option": "Selecteer een geldige optie", + "error.validation.num": "Vul een geldig cijfer in", + "error.validation.required": "Vul iets in", + "error.validation.same": "Vul \"{other}\" in", + "error.validation.size": "De lengte van de invoer moet \"{size}\" zijn", + "error.validation.startswith": "De invoer moet beginnen met \"{start}\"", + "error.validation.tel": "Vul een niet-geformatteerd telefoonnummer in", + "error.validation.time": "Vul een geldige tijd in", + "error.validation.time.after": "Vul een tijd in na {time}", + "error.validation.time.before": "Vul een tijd in voor {time}", + "error.validation.time.between": "Vul een tijd in tussen {min} en {max}", + "error.validation.uuid": "Vul een geldige UUID in", + "error.validation.url": "Vul een geldige URL in", + + "expand": "Open", + "expand.all": "Open alles", + + "field.invalid": "Dit veld is niet geldig", + "field.required": "Dit veld is verplicht", + "field.blocks.changeType": "Wijzig type", + "field.blocks.code.name": "Code", + "field.blocks.code.language": "Taal", + "field.blocks.code.placeholder": "Jouw code ...", + "field.blocks.delete.confirm": "Wil je echt dit blok wilt verwijderen?", + "field.blocks.delete.confirm.all": "Wil je echt alle blokken verwijderen?", + "field.blocks.delete.confirm.selected": "Wil je de geselecteerde blokken echt verwijderen?", + "field.blocks.empty": "Nog geen blokken", + "field.blocks.fieldsets.empty": "Nog geen veldsets", + "field.blocks.fieldsets.label": "Selecteer een bloktype ...", + "field.blocks.fieldsets.paste": "Druk op {{ shortcut }} om layouts/blokken van je klembord te importeren Alleen de toegestane layouts/blokken in het huidige veld worden ingevoegd.", + "field.blocks.gallery.name": "Galerij", + "field.blocks.gallery.images.empty": "Nog geen afbeeldingen", + "field.blocks.gallery.images.label": "Afbeeldingen", + "field.blocks.heading.level": "Niveau", + "field.blocks.heading.name": "Koptekst", + "field.blocks.heading.text": "Tekst", + "field.blocks.heading.placeholder": "Koptekst ...", + "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", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Locatie", + "field.blocks.image.location.internal": "Deze website", + "field.blocks.image.location.external": "Externe bron", + "field.blocks.image.name": "Afbeelding", + "field.blocks.image.placeholder": "Selecteer een afbeelding", + "field.blocks.image.ratio": "Verhouding", + "field.blocks.image.url": "Afbeeldings-URL", + "field.blocks.line.name": "Lijn", + "field.blocks.list.name": "Lijst", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Tekst", + "field.blocks.markdown.placeholder": "Markdown ...", + "field.blocks.quote.name": "Citaat", + "field.blocks.quote.text.label": "Tekst", + "field.blocks.quote.text.placeholder": "Citaat ...", + "field.blocks.quote.citation.label": "Bron", + "field.blocks.quote.citation.placeholder": "door ...", + "field.blocks.text.name": "Tekst", + "field.blocks.text.placeholder": "Tekst ...", + "field.blocks.video.autoplay": "Automatisch afspelen", + "field.blocks.video.caption": "Beschrijving", + "field.blocks.video.controls": "Besturingselementen", + "field.blocks.video.location": "Locatie", + "field.blocks.video.loop": "Herhalen", + "field.blocks.video.muted": "Gedempt", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Voer een video link in", + "field.blocks.video.poster": "Afbeelding", + "field.blocks.video.preload": "Vooral laden", + "field.blocks.video.url.label": "Video link", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Weet je zeker dat je alle items wil verwijderen?", + "field.entries.empty": "Nog geen items", + + "field.files.empty": "Nog geen bestanden geselecteerd", + "field.files.empty.single": "Nog geen bestand geselecteerd", + + "field.layout.change": "Verander layout", + "field.layout.delete": "Verwijder indeling", + "field.layout.delete.confirm": "Weet je zeker dat je deze layout wilt verwijderen?", + "field.layout.delete.confirm.all": "Weet je zeker dat je alle layouts wilt verwijderen?", + "field.layout.empty": "Er zijn nog geen rijen", + "field.layout.select": "Selecteer een indeling", + + "field.object.empty": "Nog geen informatie", + + "field.pages.empty": "Nog geen pagina's geselecteerd", + "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": "Nog geen gebruiker geselecteerd", + + "fields.empty": "Nog geen velden", + + "file": "Bestand", + "file.blueprint": "Dit bestand heeft nog geen blauwdruk. U kunt de instellingen definiëren in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Verander template", + "file.changeTemplate.notice": "Door het template van het bestand te wijzigen, wordt inhoud verwijderd voor velden waarvan het type niet overeenkomt. Als het nieuwe template bepaalde regels definieert, bv. afmetingen van afbeeldingen, dan worden die ook onomkeerbaar toegepast. Wees hier voorzichtig mee.", + "file.delete.confirm": "Wil je dit bestand
{filename} verwijderen?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Verander positie", + + "files": "Bestanden", + "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": "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", + "hue": "Hue", + "import": "Importeer", + "info": "Info", + "insert": "Toevoegen", + "insert.after": "Voeg toe na", + "insert.before": "Voeg toe voor", + "install": "Installeren", + + "installation": "Installatie", + "installation.completed": "Het Panel is geïnstalleerd", + "installation.disabled": "Je kan geen Panel installatie uitvoeren op een openbare server. Voer het installatieprogramma uit op een lokale computer of schakel het in met de panel.install optie.", + "installation.issues.accounts": "De map /site/accounts heeft geen schrijfrechten", + "installation.issues.content": "De map /content bestaat niet of heeft geen schrijfrechten", + "installation.issues.curl": "De CURL-extensie is vereist", + "installation.issues.headline": "Het Panel kan niet worden geïnstalleerd", + "installation.issues.mbstring": "De MB String extensie is verplicht", + "installation.issues.media": "De map /mediabestaat niet of heeft geen schrijfrechten", + "installation.issues.php": "Gebruik PHP8+", + "installation.issues.sessions": "De map /site/sessions bestaat niet of heeft geen schrijfrechten", + + "language": "Taal", + "language.code": "Code", + "language.convert": "Maak standaard", + "language.convert.confirm": "

Weet je zeker dat je {name} wilt aanpassen naar de standaard taal? Dit kan niet ongedaan worden gemaakt

Als {name} nog niet vertaalde content heeft, is er geen content meer om op terug te vallen en zouden delen van je site leeg kunnen zijn.

", + "language.create": "Nieuwe taal toevoegen", + "language.default": "Standaard taal", + "language.delete.confirm": "Weet je zeker dat je de taal {name} inclusief alle vertalingen wilt verwijderen? Je kunt dit niet ongedaan maken!", + "language.deleted": "De taal is verwijderd", + "language.direction": "Leesrichting", + "language.direction.ltr": "Links naar rechts", + "language.direction.rtl": "Rechts naar links", + "language.locale": "PHP-locale regel", + "language.locale.warning": "Je gebruikt een aangepaste landinstelling. Wijzig het het taalbestand in /site/languages", + "language.name": "Naam", + "language.secondary": "Tweede taal", + "language.settings": "Taal instellingen", + "language.updated": "De taal is geüpdatet", + "language.variables": "Taal variabelen", + "language.variables.empty": "Nog geen vertalingen", + + "language.variable.delete.confirm": "Weet je zeker dat je de variabele voor {key} wil verwijderen?", + "language.variable.entries": "Waardes", + "language.variable.entries.help": "Elke regel wordt gebruikt voor het bijbehorende aantal, bijvoorbeeld drie regels komen overeen met de aantallen 0, 1, 2 en meer. Gebruik de placeholder {count} om het werkelijke aantal in te voegen.", + "language.variable.key": "Key", + "language.variable.multiple": "Telbaar?", + "language.variable.multiple.text": "Gebruik verschillende vertalingen", + "language.variable.multiple.help": "Je kan verschillende waarden gebruiken, afhankelijk van een getal dat je samen met de taalvariabele doorgeeft, waardoor je dynamische vertalingen kan maken, bijvoorbeeld enkelvoud en meervoud.", + "language.variable.notFound": "De variabele kan niet gevonden worden", + "language.variable.value": "Waarde", + + "languages": "Talen", + "languages.default": "Standaard taal", + "languages.empty": "Er zijn nog geen talen", + "languages.secondary": "Andere talen", + "languages.secondary.empty": "Er zijn nog geen andere talen beschikbaar", + + "license": "Licentie", + "license.activate": "Activeer nu", + "license.activate.label": "Activeer je licentie", + "license.activate.domain": "Je licentie wordt geactiveerd voor {host}.", + "license.activate.local": "Je staat op het punt om je Kirby licentie voor je lokale domein {host} te activeren. Als deze site op een publiek domein geplaatst wordt, activeer deze licentie dan daar. Als het domein {host} wel degene is die je voor deze licentie wil gebruiken, ga dan door.", + "license.activated": "Geactiveerd", + "license.buy": "Koop een licentie", + "license.code": "Code", + "license.code.help": "Je hebt de licentiecode via e-mail gekregen nadat je de aankoop hebt gedaan. Kopieer en plak de licentiecode hier.", + "license.code.label": "Vul je licentie in", + "license.status.active.info": "Inclusief nieuwe major versies tot {date}", + "license.status.active.label": "Geldige licentie", + "license.status.demo.info": "Dit is een demo installatie", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Verleng licentie om bij te werken naar nieuwe versies", + "license.status.inactive.label": "Geen nieuwe major versies", + "license.status.legacy.bubble": "Klaar om je licentie te vernieuwen?", + "license.status.legacy.info": "Je licentie dekt deze versie niet", + "license.status.legacy.label": "Verleng je licentie", + "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": "De licentiestatus is onbekend", + "license.status.unknown.label": "Onbekend", + "license.manage": "Beheer je licenties", + "license.purchased": "Gekocht", + "license.success": "Bedankt dat je Kirby ondersteunt", + "license.unregistered.label": "Niet geregistreerd", + + "link": "Link", + "link.text": "Linktekst", + + "loading": "Laden", + + "lock.unsaved": "Niet opgeslagen wijzigingen", + "lock.unsaved.empty": "Er zijn geen niet opgeslagen wijzigingen meer", + "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}", + "lock.isUnlocked": "Is ontgrendeld door een andere gebruiker", + + "login": "Inloggen", + "login.code.label.login": "Log in code", + "login.code.label.password-reset": "Wachtwoord herstel code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Als je e-mailadres is geregistreerd, is de aangevraagde code per e-mail verzonden.", + "login.code.text.totp": "Vul de eenmalige code in vanuit je Authenticator-app. ", + "login.email.login.body": "Hallo {user.nameOrEmail},\n\nJe hebt onlangs een inlogcode aangevraagd voor het panel van {site}.\nDe volgende inlogcode is geldig voor {timeout} minuten:\n\n{code}\n\nAls je geen inlogcode hebt aangevraagd, negeer deze e-mail dan of neem contact op met de beheerder als je vragen hebt.\nStuur deze e-mail voor de zekerheid NIET door.", + "login.email.login.subject": "Jouw log in code", + "login.email.password-reset.body": "Hallo {user.nameOrEmail},\n\nJe hebt onlangs een wachtwoord reset code aangevraagd voor het panel van {site}.\nDe volgende wachtwoord reset code is geldig voor {timeout} minuten:\n\n{code}\n\nAls je geen wachtwoord reset code hebt aangevraagd, negeer dan deze e-mail of neem contact op met de beheerder als je vragen hebt.\nStuur deze e-mail voor de zekerheid NIET door.", + "login.email.password-reset.subject": "Jouw wachtwoord herstel code", + "login.remember": "Houd mij ingelogd", + "login.reset": "Wachtwoord herstellen", + "login.toggleText.code.email": "Log in via email", + "login.toggleText.code.email-password": "Log in met je wachtwoord", + "login.toggleText.password-reset.email": "Wachtwoord vergeten?", + "login.toggleText.password-reset.email-password": "← Terug naar log in", + "login.totp.enable.option": "Stel eenmalige codes in.", + "login.totp.enable.intro": "Authenticator-apps kunnen eenmalige codes genereren die dienen als een tweede factor als jij inlogt in je account.", + "login.totp.enable.qr.label": "1. Scan deze QR code", + "login.totp.enable.qr.help": "Problemen met scannen? Voeg de setup key {secret} handmatig toe aan je Authenticator-app.", + "login.totp.enable.confirm.headline": "2. Bevestig met een gegenereerde code", + "login.totp.enable.confirm.text": "De app genereert elke 30 seconden een nieuwe eenmalige code. Voer de huidige code in om de setup af te ronden:", + "login.totp.enable.confirm.label": "Huidige code", + "login.totp.enable.confirm.help": "Na het instellen zullen we elke keer om een eenmalige code vragen bij het inloggen.", + "login.totp.enable.success": "Eenmalige codes geactiveerd", + "login.totp.disable.option": "Schakel eenmalige codes uit", + "login.totp.disable.label": "Voer je wachtwoord in om eenmalige codes uit te schakelen", + "login.totp.disable.help": "In de toekomst zal een andere tweede factor, zoals een inlogcode die via e-mail wordt verzonden, worden gevraagd wanneer je inlogt. Je kunt later altijd weer eenmalige codes instellen.", + "login.totp.disable.admin": "

Dit schakelt eenmalige codes uit voor {user}.

In de toekomst zal bij het inloggen om een andere tweede factor worden gevraagd, zoals een inlogcode die via e-mail wordt verzonden. {user} kan na zijn volgende aanmelding opnieuw eenmalige codes instellen.

", + "login.totp.disable.success": "Eenmalige codes uitgeschakeld", + + "logout": "Uitloggen", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Mime-type", + "minutes": "Minuten", + + "month": "Maand", + "months.april": "april", + "months.august": "augustus", + "months.december": "december", + "months.february": "februari", + "months.january": "januari", + "months.july": "juli", + "months.june": "juni", + "months.march": "maart", + "months.may": "mei", + "months.november": "november", + "months.october": "oktober", + "months.september": "september", + + "more": "Meer", + "move": "Verplaatsen", + "name": "Naam", + "next": "Volgende", + "night": "Nacht", + "no": "nee", + "off": "uit", + "on": "aan", + "open": "Open", + "open.newWindow": "Openen in een nieuw scherm", + "option": "Option", + "options": "Opties", + "options.none": "Geen opties beschikbaar", + "options.all": "Laat alle {count} opties zien", + + "orientation": "Oriëntatie", + "orientation.landscape": "Liggend", + "orientation.portrait": "Staand", + "orientation.square": "Vierkant", + + "page": "Pagina", + "page.blueprint": "Deze pagina heeft nog geen blauwdruk. Je kan de instellingen definiëren in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Verander URL", + "page.changeSlug.fromTitle": "Aanmaken op basis van titel", + "page.changeStatus": "Wijzig status", + "page.changeStatus.position": "Selecteer een positie", + "page.changeStatus.select": "Selecteer een nieuwe status", + "page.changeTemplate": "Verander template", + "page.changeTemplate.notice": "Door de template te wijzigen, wordt inhoud verwijderd voor velden waarvan het type niet overeenkomt. Gebruik dit voorzichtig.", + "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": "Voer de paginatitel in om te bevestigen", + "page.duplicate.appendix": "Kopiëren", + "page.duplicate.files": "Kopieer bestanden", + "page.duplicate.pages": "Kopieer pagina's", + "page.move": "Move page", + "page.sort": "Verander positie", + "page.status": "Status", + "page.status.draft": "Concept", + "page.status.draft.description": "De pagina is in concept-modus en alleen zichtbaar voor ingelogde redacteuren of via een geheime link", + "page.status.listed": "Openbaar", + "page.status.listed.description": "Deze pagina is toegankelijk voor iedereen", + "page.status.unlisted": "Niet gepubliceerd", + "page.status.unlisted.description": "Deze pagina is alleen bereikbaar via URL", + + "pages": "Pagina’s", + "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", + "pages.status.unlisted": "Niet gepubliceerd", + + "pagination.page": "Pagina", + + "password": "Wachtwoord", + "paste": "Plak", + "paste.after": "Plak achter", + "paste.success": "{count} geplakt!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Vorige", + "preview": "Voorbeeld", + + "publish": "Publiceren", + "published": "Gepubliceerd", + + "remove": "Verwijder", + "rename": "Hernoem", + "renew": "Verlengen", + "replace": "Vervang", + "replace.with": "Vervangen met", + "retry": "Probeer opnieuw", + "revert": "Annuleren", + "revert.confirm": "Weet je zeker dat je alle niet-opgeslagen veranderingen wilt verwijderen?", + + "role": "Rol", + "role.admin.description": "De admin heeft alle rechten", + "role.admin.title": "Admin", + "role.all": "Alle", + "role.empty": "Er zijn geen gebruikers met deze rol", + "role.description.placeholder": "Geen beschrijving", + "role.nobody.description": "Dit is een fallback-rol zonder rechten", + "role.nobody.title": "Niemand", + + "save": "Opslaan", + "saved": "Opgeslagen", + "search": "Zoeken", + "searching": "Zoeken", + "search.min": "Voer {min} tekens in om te zoeken", + "search.all": "Laat alle {count} resultaten zien", + "search.results.none": "Geen resultaten", + + "section.invalid": "De sectie is ongeldig", + "section.required": "De sectie is verplicht", + + "security": "Beveiliging", + "select": "Selecteren", + "server": "Server", + "settings": "Opties", + "show": "Toon", + "site.blueprint": "Deze website heeft nog geen ontwerp. Je kan het ontwerp hier plaatsen/site/blueprints/site.yml", + "size": "Grootte", + "slug": "URL-toevoeging", + "sort": "Sorteren", + "sort.drag": "Sleep om te sorteren ...", + "split": "Splitsen", + + "stats.empty": "Geen rapporten", + "status": "Status", + + "system.info.copy": "Kopieer informatie", + "system.info.copied": "Systeem informatie gekopieerd", + "system.issues.content": "De content map lijkt zichtbaar te zijn", + "system.issues.eol.kirby": "De geïnstalleerde Kirby versie is niet meer actueel en zal geen verdere beveiligingsupdates meer ontvangen.", + "system.issues.eol.plugin": "De geïnstalleerde versie van plugin { plugin } is niet meer actueel en zal geen verdere beveiligingsupdates meer ontvangen.", + "system.issues.eol.php": "De geïnstalleerde PHP versie { release } is niet meer actueel en zal geen verdere beveiligingsupdates meer ontvangen.", + "system.issues.debug": "De debug modus moet uitgeschakeld zijn in productie", + "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": "De site draait lokaal met versoepelde beveiligingscontroles", + "system.issues.site": "De site map lijkt zichtbaar te zijn", + "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", + "system.updateStatus.error": "Kan niet checken voor updates", + "system.updateStatus.not-vulnerable": "Geen bekende kwetsbaarheden", + "system.updateStatus.security-update": "Gratis veiligheids update { version } beschikbaar", + "system.updateStatus.security-upgrade": "Upgrade { version } met veiligheid aanpassingen beschikbaar", + "system.updateStatus.unreleased": "Niet vrijgegeven versie", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Gratis update { version } beschikbaar", + "system.updateStatus.upgrade": "Upgrade { version } beschikbaar", + + "tel": "Telefoon", + "tel.placeholder": "+49123456789", + "template": "Template", + + "theme": "Thema", + "theme.light": "Lichten aan", + "theme.dark": "Lichten uit", + "theme.automatic": "Systeemstandaard gebruiken", + + "title": "Titel", + "today": "Vandaag", + + "toolbar.button.clear": "Verwijder formattering", + "toolbar.button.code": "Code", + "toolbar.button.bold": "Dikgedrukte tekst", + "toolbar.button.email": "E-mailadres", + "toolbar.button.headings": "Kopteksten", + "toolbar.button.heading.1": "Koptekst 1", + "toolbar.button.heading.2": "Koptekst 2", + "toolbar.button.heading.3": "Koptekst 3", + "toolbar.button.heading.4": "Hoofding 4", + "toolbar.button.heading.5": "Hoofding 5", + "toolbar.button.heading.6": "Hoofding 6", + "toolbar.button.italic": "Cursieve tekst", + "toolbar.button.file": "Bestand", + "toolbar.button.file.select": "Selecteer een bestand", + "toolbar.button.file.upload": "Upload bestand", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Paragraaf", + "toolbar.button.strike": "Doorstreept", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Genummerde lijst", + "toolbar.button.underline": "Onderlijn", + "toolbar.button.ul": "Opsomming", + + "translation.author": "Het team van Kirby", + "translation.direction": "ltr", + "translation.name": "Nederlands", + "translation.locale": "nl_NL", + + "type": "Type", + + "upload": "Upload", + "upload.error.cantMove": "Het geüploadde bestand kon niet worden verplaatst", + "upload.error.cantWrite": "Fout bij het schrijven van het bestand naar de schijf", + "upload.error.default": "Het bestand kan niet worden geüpload", + "upload.error.extension": "Kan bestand niet uploaden vanwege de extensie", + "upload.error.formSize": "Het geüploadde bestand is groter dan de MAX_FILE_SIZE die is aangegeven in het formulier", + "upload.error.iniPostSize": "Het geüploadde bestand is groter dan de post_max_size in php.ini", + "upload.error.iniSize": "Het geüploadde bestand is groter dan de upload_max_filesize in php.ini", + "upload.error.noFile": "Er is geen bestand geüpload", + "upload.error.noFiles": "Er zijn geen bestanden geüpload", + "upload.error.partial": "Het geüploadde bestand is slechts gedeeltelijk geüpload", + "upload.error.tmpDir": "Er mist een tijdelijke map", + "upload.errors": "Foutmelding", + "upload.progress": "Uploaden...", + + "url": "Url", + "url.placeholder": "https://voorbeeld.nl", + + "user": "Gebruiker", + "user.blueprint": "Je kan aanvullende secties en formuliervelden voor deze gebruikersrol definiëren in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Email veranderen", + "user.changeLanguage": "Taal veranderen", + "user.changeName": "Gebruiker hernoemen", + "user.changePassword": "Wachtwoord wijzigen", + "user.changePassword.current": "Je huidige wachtwoord", + "user.changePassword.new": "Nieuw wachtwoord", + "user.changePassword.new.confirm": "Bevestig het nieuwe wachtwoord...", + "user.changeRole": "Verander rol", + "user.changeRole.select": "Kies een nieuwe rol", + "user.create": "Voeg een nieuwe gebruiker toe", + "user.delete": "Verwijder deze gebruiker", + "user.delete.confirm": "Weet je zeker dat je
{email} wil verwijderen?", + + "users": "Gebruikers", + + "version": "Kirby-versie", + "version.changes": "Gewijzigde versie", + "version.compare": "Vergelijk versies", + "version.current": "Huidige versie", + "version.latest": "Laatste versie", + "versionInformation": "Versie informatie", + + "view": "Bekijk", + "view.account": "Jouw account", + "view.installation": "Installatie", + "view.languages": "Talen", + "view.resetPassword": "Wachtwoord herstellen", + "view.site": "Site", + "view.system": "Systeem", + "view.users": "Gebruikers", + + "welcome": "Welkom", + "year": "Jaar", + "yes": "ja" +} diff --git a/public/kirby/i18n/translations/pl.json b/public/kirby/i18n/translations/pl.json new file mode 100644 index 0000000..8583879 --- /dev/null +++ b/public/kirby/i18n/translations/pl.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Zmień swoje imię", + "account.delete": "Usuń swoje konto", + "account.delete.confirm": "Czy na pewno chcesz usunąć swoje konto? Zostaniesz natychmiast wylogowany. Twojego konta nie da się odzyskać.", + + "activate": "Aktywuj", + "add": "Dodaj", + "alpha": "Alfa", + "author": "Autor", + "avatar": "Zdj\u0119cie profilowe", + "back": "Wróć", + "cancel": "Anuluj", + "change": "Zmie\u0144", + "close": "Zamknij", + "changes": "Zmiany", + "confirm": "Ok", + "collapse": "Zwiń", + "collapse.all": "Zwiń wszystkie", + "color": "Kolor", + "coordinates": "Współrzędne", + "copy": "Kopiuj", + "copy.all": "Skopiuj wszystko", + "copy.success": "Skopiowane", + "copy.success.multiple": "{count} skopiowanych!", + "copy.url": "Skopiuj URL", + "create": "Utwórz", + "custom": "Niestandardowe", + + "date": "Data", + "date.select": "Wybierz datę", + + "day": "Dzień", + "days.fri": "Pt", + "days.mon": "Pn", + "days.sat": "Sb", + "days.sun": "Nd", + "days.thu": "Czw", + "days.tue": "Wt", + "days.wed": "\u015ar", + + "debugging": "Debugowanie", + + "delete": "Usu\u0144", + "delete.all": "Usuń wszystkie", + + "dialog.fields.empty": "To okno dialogowe nie zawiera żadnych pól", + "dialog.files.empty": "Brak plików do wyboru", + "dialog.pages.empty": "Brak stron do wyboru", + "dialog.text.empty": "To okno dialogowe nie definiuje żadnego tekstu", + "dialog.users.empty": "Brak użytkowników do wyboru", + + "dimensions": "Wymiary", + "disable": "Wyłącz", + "disabled": "Wyłączone", + "discard": "Odrzu\u0107", + + "drawer.fields.empty": "Ten panel nie zawiera żadnych pól", + + "domain": "Domena", + "download": "Pobierz", + "duplicate": "Zduplikuj", + + "edit": "Edytuj", + + "email": "Email", + "email.placeholder": "mail@example.com", + + "enter": "Wprowadź", + "entries": "Wpisy", + "entry": "Wpis", + + "environment": "Środowisko", + + "error": "Błąd", + "error.access.code": "Nieprawidłowy kod", + "error.access.login": "Nieprawidłowy login", + "error.access.panel": "Nie masz uprawnień by dostać się do panelu", + "error.access.view": "Nie masz uprawnień, by dostać się do tej części panelu", + + "error.avatar.create.fail": "Nie udało się załadować zdjęcia profilowego", + "error.avatar.delete.fail": "Nie udało się usunąć zdjęcia profilowego", + "error.avatar.dimensions.invalid": "Proszę zachować szerokość i wysokość zdjęcia profilowego poniżej 3000 pikseli", + "error.avatar.mime.forbidden": "Zdjęcie profilowe musi być plikiem JPEG lub PNG", + + "error.blueprint.notFound": "Nie udało się załadować wzorca \"{name}\"", + + "error.blocks.max.plural": "Możesz dodać nie więcej niż {max} bloki/-ów", + "error.blocks.max.singular": "Możesz dodać tylko jeden blok", + "error.blocks.min.plural": "Musisz dodać co najmniej {min} bloki/-ów", + "error.blocks.min.singular": "Musisz dodać co najmniej jeden blok", + "error.blocks.validation": "Wystąpił błąd w polu „{field}” w bloku {index} o typie bloku „{fieldset}”", + + "error.cache.type.invalid": "Nieprawidłowy typ pamięci podręcznej „{type}”", + + "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": "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}", + + "error.email.preset.notFound": "Nie udało się załadować wzorca wiadomości e-mail \"{name}\"", + + "error.field.converter.invalid": "Nieprawidłowy konwerter \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Pole „{ name }”: Typ pola „{ type }” nie istnieje", + + "error.file.changeName.empty": "Imię nie może być puste", + "error.file.changeName.permission": "Nie masz uprawnień, by zmienić nazwę \"{filename}\"", + "error.file.changeTemplate.invalid": "Szablonu pliku \"{id}\" nie można zmienić na \"{template}\" (poprawne: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Nie masz uprawnień, by zmieniać szablon pliku \"{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": "Istnieje już plik o nazwie \"{filename}\"", + "error.file.extension.forbidden": "Rozszerzenie \"{extension}\" jest niedozwolone", + "error.file.extension.invalid": "Nieprawidłowe rozszerzenie: {extension}", + "error.file.extension.missing": "Brak rozszerzenia pliku \"{filename}\"", + "error.file.maxheight": "Wysokość obrazka nie może być większa niż {height} pikseli", + "error.file.maxsize": "Plik jest za duży", + "error.file.maxwidth": "Szerokość obrazka nie może być większa niż {width} pikseli", + "error.file.mime.differs": "Przesłany plik musi być tego samego typu mime \"{mime}\"", + "error.file.mime.forbidden": "Typ multimediów \"{mime}\" jest niedozwolony", + "error.file.mime.invalid": "Nieprawidłowy typ MIME: {mime}", + "error.file.mime.missing": "Nie można wykryć typu multimediów dla \"{filename}\"", + "error.file.minheight": "Wysokość obrazka musi wynosić co najmniej {height} pikseli", + "error.file.minsize": "Plik jest za mały", + "error.file.minwidth": "Szerokość obrazka musi wynosić co najmniej {width} pikseli", + "error.file.name.unique": "Nazwa pliku musi być unikalna", + "error.file.name.missing": "Nazwa pliku nie może być pusta", + "error.file.notFound": "Nie można znaleźć pliku \"{filename}\"", + "error.file.orientation": "Orientacja obrazka musi być \"{orientation}\"", + "error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"", + "error.file.type.forbidden": "Nie możesz przesyłać plików {type}", + "error.file.type.invalid": "Nieprawidłowy typ pliku: {type}", + "error.file.undefined": "Nie można znaleźć pliku", + + "error.form.incomplete": "Popraw wszystkie błędy w formularzu…", + "error.form.notSaved": "Nie udało się zapisać formularza", + + "error.language.code": "Wprowadź poprawny kod języka.", + "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": "Język już istnieje.", + "error.language.name": "Wprowadź poprawną nazwę języka.", + "error.language.notFound": "Język nie został odnaleziony", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Wystąpił błąd w polu „{field}” w bloku {blockIndex} o typie bloku „{fieldset}” w układzie {layoutIndex}", + "error.layout.validation.settings": "W ustawieniach układu {index} jest błąd", + + "error.license.domain": "Brakuje domeny dla licencji", + "error.license.email": "Wprowadź poprawny adres email", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "Nie udało się zweryfikować licencji", + + "error.login.totp.confirm.invalid": "Nieprawidłowy kod", + "error.login.totp.confirm.missing": "Wpisz aktualny kod", + + "error.object.validation": "Wystąpił błąd w polu „{label}”:\n{message}", + + "error.offline": "Panel jest obecnie offline", + + "error.page.changeSlug.permission": "Nie możesz zmienić końcówki adresu URL w \"{slug}\"", + "error.page.changeSlug.reserved": "Ścieżka stron najwyższego poziomu nie może zaczynać się od \"{path}\"", + "error.page.changeStatus.incomplete": "Strona zawiera błędy i nie można jej opublikować", + "error.page.changeStatus.permission": "Status tej strony nie może zostać zmieniony", + "error.page.changeStatus.toDraft.invalid": "Strony \"{slug}\" nie można przekonwertować na szkic", + "error.page.changeTemplate.invalid": "Nie można zmienić szablonu strony \"{slug}\"", + "error.page.changeTemplate.permission": "Nie masz uprawnień, by zmienić szablon dla \"{slug}\"", + "error.page.changeTitle.empty": "Tytuł nie może być pusty", + "error.page.changeTitle.permission": "Nie masz uprawnień, by zmienić tytuł dla \"{slug}\"", + "error.page.create.permission": "Nie masz uprawnień, by utworzyć \"{slug}\"", + "error.page.delete": "Strony \"{slug}\" nie można usunąć", + "error.page.delete.confirm": "Wprowadź tytuł strony, aby potwierdzić", + "error.page.delete.hasChildren": "Strona zawiera podstrony i nie można jej usunąć", + "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": "Nie masz uprawnień, by usunąć \"{slug}\"", + "error.page.draft.duplicate": "Istnieje już szkic z końcówką URL \"{slug}\"", + "error.page.duplicate": "Istnieje już strona z końcówką URL \"{slug}\"", + "error.page.duplicate.permission": "Nie masz uprawnień, by zduplikować \"{slug}\"", + "error.page.move.ancestor": "Strony nie można przenieść do siebie samej", + "error.page.move.directory": "Nie można przenieść katalogu strony", + "error.page.move.duplicate": "Istnieje już podstrona z końcówką URL \"{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.notFound": "Przeniesiona strona nie została odnaleziona", + "error.page.move.permission": "Nie masz uprawnień, by przenieść \"{slug}\"", + "error.page.move.template": "Szablon \"{template}\" nie jest akceptowany jako podstrona \"{parent}\"", + "error.page.notFound": "Nie można znaleźć strony \"{slug}\"", + "error.page.num.invalid": "Wprowadź poprawny numer sortujący. Liczby nie mogą być ujemne.", + "error.page.slug.invalid": "Wprowadź poprawną końcówkę adresu URL", + "error.page.slug.maxlength": "Końcówka adresu musi być krótsza niż \"{length}\" znaków", + "error.page.sort.permission": "Nie można sortować strony \"{slug}\"", + "error.page.status.invalid": "Ustaw prawidłowy status strony", + "error.page.undefined": "Nie udało się znaleźć strony", + "error.page.update.permission": "Nie masz uprawnień, by zaktualizować \"{slug}\"", + + "error.section.files.max.plural": "Do sekcji \"{section}\" można dodać nie więcej niż {max} plików", + "error.section.files.max.singular": "Do sekcji \"{section}\" można dodać tylko jeden plik", + "error.section.files.min.plural": "W sekcji \"{section}\" musi być co najmniej {min} pliki/-ów", + "error.section.files.min.singular": "W sekcji \"{section}\" musi być co najmniej jeden plik", + + "error.section.pages.max.plural": "Do sekcji \"{section}\" można dodać nie więcej niż {max} stron", + "error.section.pages.max.singular": "Do sekcji \"{section}\" można dodać tylko jedną stronę", + "error.section.pages.min.plural": "W sekcji \"{section}\" musi być co najmniej {min} stron/-y", + "error.section.pages.min.singular": "W sekcji \"{section}\" musi być co najmniej jedna strona", + + "error.section.notLoaded": "Nie udało się załadować sekcji \"{name}\"", + "error.section.type.invalid": "Typ sekcji \"{type}\" jest nieprawidłowy", + + "error.site.changeTitle.empty": "Tytuł nie może być pusty", + "error.site.changeTitle.permission": "Nie masz uprawnień, by zmienić tytuł strony", + "error.site.update.permission": "Nie masz uprawnień, by zaktualizować stronę", + + "error.structure.validation": "Wystąpił błąd w polu \"{field}\" w wierszu {index}", + + "error.template.default.notFound": "Domyślny szablon nie istnieje", + + "error.unexpected": "Wystąpił nieoczekiwany błąd! Włącz tryb debugowania, aby uzyskać więcej informacji: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Nie masz uprawnień, by zmienić adres e-mail użytkownika \"{name}\"", + "error.user.changeLanguage.permission": "Nie masz uprawnień, by zmienić język użytkownika \"{name}\"", + "error.user.changeName.permission": "Nie masz uprawnień, by zmienić nazwę użytkownika \"{name}\"", + "error.user.changePassword.permission": "Nie masz uprawnień, by zmienić hasło użytkownika \"{name}\"", + "error.user.changeRole.lastAdmin": "Nie można zmienić roli ostatniego administratora", + "error.user.changeRole.permission": "Nie masz uprawnień, by zmienić rolę użytkownika \"{name}\"", + "error.user.changeRole.toAdmin": "Nie masz uprawnień, by awansować kogoś do roli daministratora", + "error.user.create.permission": "Nie masz uprawnień, by utworzyć tego użytkownika", + "error.user.delete": "Nie można usunąć użytkownika \"{name}\"", + "error.user.delete.lastAdmin": "Nie można usunąć ostatniego administratora", + "error.user.delete.lastUser": "Nie można usunąć ostatniego użytkownika", + "error.user.delete.permission": "Nie masz uprawnień, by usunąć użytkownika \"{name}\"", + "error.user.duplicate": "Istnieje już użytkownik z adresem email \"{email}\"", + "error.user.email.invalid": "Wprowadź poprawny adres email", + "error.user.language.invalid": "Proszę podać poprawny język", + "error.user.notFound": "Nie można znaleźć użytkownika \"{name}\"", + "error.user.password.excessive": "Wpisz prawidłowe hasło. Hasła nie mogą być dłuższe niż 1000 znaków.", + "error.user.password.invalid": "Wprowadź prawidłowe hasło. Hasła muszą mieć co najmniej 8 znaków.", + "error.user.password.notSame": "Hasła nie są takie same", + "error.user.password.undefined": "Użytkownik nie ma hasła", + "error.user.password.wrong": "Nieprawidłowe hasło", + "error.user.role.invalid": "Wprowadź poprawną rolę", + "error.user.undefined": "Nie można znaleźć użytkownika", + "error.user.update.permission": "Nie masz uprawnień, by zaktualizować użytkownika \"{name}\"", + + "error.validation.accepted": "Proszę potwierdzić", + "error.validation.alpha": "Wprowadź tylko znaki między a-z", + "error.validation.alphanum": "Wprowadź tylko znaki między a-z lub cyfry 0-9", + "error.validation.anchor": "Wprowadź poprawny odnośnik", + "error.validation.between": "Wprowadź wartość między \"{min}\" i \"{max}\"", + "error.validation.boolean": "Potwierdź lub odmów", + "error.validation.color": "Wprowadź poprawny kolor w formacie {format}", + "error.validation.contains": "Wprowadź wartość, która zawiera \"{needle}\"", + "error.validation.date": "Wprowadź poprawną datę", + "error.validation.date.after": "Wprowadź datę późniejszą niż {date}", + "error.validation.date.before": "Wprowadź datę wcześniejszą niż {date}", + "error.validation.date.between": "Wprowadź datę między {min} a {max}", + "error.validation.denied": "Proszę odmówić", + "error.validation.different": "Wartością nie może być \"{other}\"", + "error.validation.email": "Wprowadź poprawny adres email", + "error.validation.endswith": "Wartość musi kończyć się na \"{end}\"", + "error.validation.filename": "Wprowadź poprawną nazwę pliku", + "error.validation.in": "Wprowadź jedno z następujących: ({in})", + "error.validation.integer": "Wprowadź poprawną liczbę całkowitą", + "error.validation.ip": "Wprowadź poprawny adres IP", + "error.validation.less": "Wprowadź wartość mniejszą niż {max}", + "error.validation.linkType": "Typ łącza jest niedozwolony", + "error.validation.match": "Wartość nie jest zgodna z oczekiwanym wzorcem", + "error.validation.max": "Wprowadź wartość równą lub mniejszą niż {max}", + "error.validation.maxlength": "Wprowadź krótszą wartość. (maks. {max} znaków)", + "error.validation.maxwords": "Wprowadź nie więcej niż {max} słowa/słów", + "error.validation.min": "Wprowadź wartość równą lub większą niż {min}", + "error.validation.minlength": "Wprowadź dłuższą wartość. (min. {min} znaków)", + "error.validation.minwords": "Wprowadź co najmniej {min} słowa/słów", + "error.validation.more": "Wprowadź wartość większą niż {min}", + "error.validation.notcontains": "Wprowadź wartość, która nie zawiera \"{needle}\"", + "error.validation.notin": "Nie wprowadzaj żadnego z następujących ({notIn})", + "error.validation.option": "Wybierz poprawną opcję", + "error.validation.num": "Wprowadź poprawny numer", + "error.validation.required": "Wpisz coś", + "error.validation.same": "Wprowadź \"{other}\"", + "error.validation.size": "Rozmiar wartości musi wynosić \"{size}\"", + "error.validation.startswith": "Wartość musi zaczynać się od \"{start}\"", + "error.validation.tel": "Wprowadź niesformatowany numer telefonu", + "error.validation.time": "Wprowadź poprawny czas", + "error.validation.time.after": "Wprowadź czas późniejszy niż {time}", + "error.validation.time.before": "Wprowadź czas wcześniejszy niż {time}", + "error.validation.time.between": "Wprowadź czas między {min} a {max}", + "error.validation.uuid": "Wprowadź prawidłowy identyfikator UUID", + "error.validation.url": "Wprowadź poprawny adres URL", + + "expand": "Rozwiń", + "expand.all": "Rozwiń wszystkie", + + "field.invalid": "Pole jest nieprawidłowe", + "field.required": "Pole jest wymagane", + "field.blocks.changeType": "Zmień typ", + "field.blocks.code.name": "Kod", + "field.blocks.code.language": "Język", + "field.blocks.code.placeholder": "Twój kod …", + "field.blocks.delete.confirm": "Czy na pewno chcesz usunąć ten blok?", + "field.blocks.delete.confirm.all": "Czy na pewno chcesz usunąć wszystkie bloki?", + "field.blocks.delete.confirm.selected": "Czy na pewno chcesz usunąć wszystkie wybrane bloki?", + "field.blocks.empty": "Nie ma jeszcze żadnych bloków", + "field.blocks.fieldsets.empty": "Nie ma jeszcze zestawów pól", + "field.blocks.fieldsets.label": "Wybierz typ bloku …", + "field.blocks.fieldsets.paste": "Naciśnij {{ shortcut }}, aby zaimportować układy/bloki ze schowka. Zostaną wstawione tylko te, które są dozwolone w bieżącym polu.", + "field.blocks.gallery.name": "Galeria", + "field.blocks.gallery.images.empty": "Nie ma jeszcze żadnych obrazków", + "field.blocks.gallery.images.label": "Obrazki", + "field.blocks.heading.level": "Poziom", + "field.blocks.heading.name": "Nagłówek", + "field.blocks.heading.text": "Tekst", + "field.blocks.heading.placeholder": "Nagłówek …", + "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": "Tekst alternatywny", + "field.blocks.image.caption": "Podpis", + "field.blocks.image.crop": "Przytnij", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Lokalizacja", + "field.blocks.image.location.internal": "Ta witryna", + "field.blocks.image.location.external": "Zewnętrzne źródło", + "field.blocks.image.name": "Obrazek", + "field.blocks.image.placeholder": "Wybierz obrazek", + "field.blocks.image.ratio": "Proporcje", + "field.blocks.image.url": "URL obrazka", + "field.blocks.line.name": "Linia", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Tekst", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Cytat", + "field.blocks.quote.text.label": "Tekst", + "field.blocks.quote.text.placeholder": "Cytat …", + "field.blocks.quote.citation.label": "Źródło", + "field.blocks.quote.citation.placeholder": "autorstwa …", + "field.blocks.text.name": "Tekst", + "field.blocks.text.placeholder": "Tekst …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Podpis", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Lokalizacja", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Wprowadź URL video", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preload", + "field.blocks.video.url.label": "URL video", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Czy na pewno chcesz usunąć wszystkie wpisy?", + "field.entries.empty": "Nie ma jeszcze żadnych wpisów.", + + "field.files.empty": "Nie wybrano jeszcze żadnych plików", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Zmień układ", + "field.layout.delete": "Usuń układ", + "field.layout.delete.confirm": "Czy na pewno chcesz usunąć ten układ?", + "field.layout.delete.confirm.all": "Czy na pewno chcesz usunąć wszystkie układy?", + "field.layout.empty": "Nie ma jeszcze żadnych rzędów", + "field.layout.select": "Wybierz układ", + + "field.object.empty": "Brak informacji", + + "field.pages.empty": "Nie wybrano jeszcze żadnych stron", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Czy na pewno chcesz usunąć ten wiersz?", + "field.structure.delete.confirm.all": "Czy na pewno chcesz usunąć wszystkie wpisy?", + "field.structure.empty": "Nie ma jeszcze \u017cadnych wpis\u00f3w.", + + "field.users.empty": "Nie wybrano jeszcze żadnych użytkowników", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "Nie ma jeszcze żadnych pól", + + "file": "Plik", + "file.blueprint": "Ten plik nie ma jeszcze wzorca. Możesz go zdefiniować w /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Zmień szablon", + "file.changeTemplate.notice": "Zmiana szablonu pliku spowoduje usunięcie zawartości pól, które nie pasują pod względem typu. Jeżeli nowy szablon określa pewne zasady, np. wymiarów obrazu, one również zostaną zastosowane nieodwracalnie. Używaj ostrożnie.", + "file.delete.confirm": "Czy na pewno chcesz usunąć
{filename}?", + "file.focus.placeholder": "Ustaw punkt centralny", + "file.focus.reset": "Usuń punkt centralny", + "file.focus.title": "Punkt centralny", + "file.sort": "Zmień pozycję", + + "files": "Pliki", + "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": "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": "Bieżące zmiany nie zostały jeszcze zapisane", + "form.preview": "Podejrzyj zmiany", + "form.preview.draft": "Podejrzyj szkic", + + "hide": "Ukryj", + "hour": "Godzina", + "hue": "Odcień", + "import": "Importuj", + "info": "Informacje", + "insert": "Wstaw", + "insert.after": "Wstaw po", + "insert.before": "Wstaw przed", + "install": "Zainstaluj", + + "installation": "Instalacja", + "installation.completed": "Panel został zainstalowany", + "installation.disabled": "Instalator panelu jest domyślnie wyłączony na serwerach publicznych. Uruchom instalator na komputerze lokalnym lub włącz go za pomocą opcji panel.install.", + "installation.issues.accounts": "Folder /site/accounts nie istnieje lub nie ma uprawnień do zapisu", + "installation.issues.content": "Folder /content nie istnieje lub nie ma uprawnień do zapisu", + "installation.issues.curl": "Wymagane jest rozszerzenie CURL", + "installation.issues.headline": "Nie można zainstalować panelu", + "installation.issues.mbstring": "Wymagane jest rozszerzenie MB String", + "installation.issues.media": "Folder /media nie istnieje lub nie ma uprawnień do zapisu", + "installation.issues.php": "Upewnij się, że używasz PHP 8+", + "installation.issues.sessions": "Folder /site/sessions nie istnieje lub nie ma uprawnień do zapisu", + + "language": "J\u0119zyk", + "language.code": "Kod", + "language.convert": "Ustaw jako domyślny", + "language.convert.confirm": "

Czy na pewno chcesz zmienić domyślny język na {name}? Nie można tego cofnąć.

Jeżeli brakuje tłumaczenia jakichś treści na {name}, nie będzie ich czym zastąpić i części witryny mogą być puste.

", + "language.create": "Dodaj nowy język", + "language.default": "Domyślny język", + "language.delete.confirm": "Czy na pewno chcesz usunąć język {name} i wszystkie tłumaczenia? Tego nie da się cofnąć!", + "language.deleted": "Język został usunięty", + "language.direction": "Kierunek czytania", + "language.direction.ltr": "Od lewej do prawej", + "language.direction.rtl": "Od prawej do lewej", + "language.locale": "PHP locale string", + "language.locale.warning": "Używasz niestandardowej konfiguracji ustawień regionalnych. Zmodyfikuj to w pliku języka w /site/langugaes", + "language.name": "Nazwa", + "language.secondary": "Drugorzędny język", + "language.settings": "Ustawienia języków", + "language.updated": "Język został zaktualizowany", + "language.variables": "Zmienne językowe", + "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ść", + + "languages": "Języki", + "languages.default": "Domyślny język", + "languages.empty": "Nie ma jeszcze żadnych języków", + "languages.secondary": "Dodatkowe języki", + "languages.secondary.empty": "Nie ma jeszcze dodatkowych języków", + + "license": "Licencja", + "license.activate": "Aktywuj teraz", + "license.activate.label": "Aktywuj swoją licencję", + "license.activate.domain": "Twoja licencja zostanie aktywowana na {host}.", + "license.activate.local": "Zamierzasz aktywować licencję Kirby dla domeny lokalnej {host}. Jeżeli ta strona będzie uruchamiana w publicznie dostępnej domenie, należy ją aktywować tam. Jeśli {host} jest domeną, dla której chcesz używać licencji, kontynuuj.", + "license.activated": "Aktywowana", + "license.buy": "Kup licencję", + "license.code": "Kod", + "license.code.help": "Po zakupie otrzymałeś emailem kod licencyjny. Skopiuj go i wklej tutaj.", + "license.code.label": "Wprowadź swój kod licencji", + "license.status.active.info": "Zawiera nowe główne wersje do {date}", + "license.status.active.label": "Ważna licencja", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Odnów licencję, by zaktualizować do nowych wersji głównych", + "license.status.inactive.label": "Brak nowych wersji głównych", + "license.status.legacy.bubble": "Gotowy/-a do odnowienia swojej licencji?", + "license.status.legacy.info": "Twoja licencja nie obejmuje tej wersji", + "license.status.legacy.label": "Odnów swoją licencję", + "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": "Status licencji jest nieznany", + "license.status.unknown.label": "Unknown", + "license.manage": "Zarządzaj swoimi licencjami", + "license.purchased": "Zakupiona", + "license.success": "Dziękujemy za wspieranie Kirby", + "license.unregistered.label": "Niezarejestrowane", + + "link": "Link", + "link.text": "Tekst linku", + + "loading": "Ładuję", + + "lock.unsaved": "Niezapisane zmiany", + "lock.unsaved.empty": "Nie ma już żadnych niezapisanych zmian", + "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}", + "lock.isUnlocked": "Zostało odblokowane przez innego użytkownika", + + "login": "Zaloguj się", + "login.code.label.login": "Kod logowania się", + "login.code.label.password-reset": "Kod resetowania hasła", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Jeśli Twój adres email jest zarejestrowany, żądany kod został wysłany na Twoją skrzynkę.", + "login.code.text.totp": "Wprowadź jednorazowy kod z aplikacji uwierzytelniającej.", + "login.email.login.body": "Cześć {user.nameOrEmail},\n\nNiedawno poprosiłaś/-eś o kod do zalogowania się do panelu strony {site}.\nPoniższy kod do zalogowania się będzie ważny przez {timeout} minut:\n\n{code}\n\nJeżeli nie zażądałaś/-eś kodu do logowania się, zignoruj tę wiadomość e-mail lub skontaktuj się z administratorem, jeśli masz pytania.\nZe względów bezpieczeństwa NIE przesyłaj dalej tego e-maila.", + "login.email.login.subject": "Twój kod logowania się", + "login.email.password-reset.body": "Cześć {user.nameOrEmail},\n\nNiedawno poprosiłaś/-eś o kod resetowania hasła do panelu strony {site}.\nPoniższy kod resetowania hasła będzie ważny przez {timeout} minut:\n\n{code}\n\nJeżeli nie zażądałaś/-eś kodu resetowania hasła, zignoruj tę wiadomość e-mail lub skontaktuj się z administratorem, jeśli masz pytania. \nZe względów bezpieczeństwa NIE przesyłaj dalej tego e-maila. ", + "login.email.password-reset.subject": "Twój kod resetujący hasło", + "login.remember": "Nie wylogowuj mnie", + "login.reset": "Zresetuj hasło", + "login.toggleText.code.email": "Zaloguj się za pomocą adresu email", + "login.toggleText.code.email-password": "Zaloguj się za pomocą hasła", + "login.toggleText.password-reset.email": "Zapomniałeś/-aś hasła?", + "login.toggleText.password-reset.email-password": "← Powrót do logowania", + "login.totp.enable.option": "Ustaw kody jednorazowe", + "login.totp.enable.intro": "Aplikacje uwierzytelniające mogą generować jednorazowe kody, które są używane jako drugi czynnik podczas logowania się na konto.", + "login.totp.enable.qr.label": "1. Zeskanuj ten kod QR", + "login.totp.enable.qr.help": "Nie możesz zeskanować? Dodaj ręcznie klucz instalacyjny {secret} do aplikacji uwierzytelniającej.", + "login.totp.enable.confirm.headline": "2. Potwierdź wygenerowanym kodem", + "login.totp.enable.confirm.text": "Aplikacja generuje nowy kod jednorazowy co 30 sekund. Wprowadź aktualny kod, aby dokończyć konfigurację:", + "login.totp.enable.confirm.label": "Aktualny kod", + "login.totp.enable.confirm.help": "Po tej konfiguracji będziemy prosić o jednorazowy kod przy każdym logowaniu.", + "login.totp.enable.success": "Kody jednorazowe włączone", + "login.totp.disable.option": "Wyłącz kody jednorazowe", + "login.totp.disable.label": "Wprowadź swoje hasło, aby wyłączyć kody jednorazowe", + "login.totp.disable.help": "W przyszłości podczas logowania wymagany będzie inny drugi czynnik, taki jak kod logowania wysłany emailem. Kody jednorazowe możesz zawsze skonfigurować później.", + "login.totp.disable.admin": "

Spowoduje to wyłączenie kodów jednorazowych dla użytkownika {user}.

W przyszłości podczas logowania wymagany będzie inny drugi czynnik, taki jak kod logowania wysłany emailem. {user} może ponownie skonfigurować kody jednorazowe po następnym zalogowaniu.

", + "login.totp.disable.success": "Kody jednorazowe wyłączone", + + "logout": "Wyloguj się", + + "merge": "Połącz", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Typ multimediów", + "minutes": "Minuty", + + "month": "Miesiąc", + "months.april": "Kwiecie\u0144", + "months.august": "Sierpie\u0144", + "months.december": "Grudzie\u0144", + "months.february": "Luty", + "months.january": "Stycze\u0144", + "months.july": "Lipiec", + "months.june": "Czerwiec", + "months.march": "Marzec", + "months.may": "Maj", + "months.november": "Listopad", + "months.october": "Pa\u017adziernik", + "months.september": "Wrzesie\u0144", + + "more": "Więcej", + "move": "Przenieś", + "name": "Nazwa", + "next": "Następne", + "night": "Noc", + "no": "nie", + "off": "wyłączone", + "on": "włączone", + "open": "Otwórz", + "open.newWindow": "Otwórz w nowym oknie", + "option": "Opcja", + "options": "Opcje", + "options.none": "Brak opcji", + "options.all": "Pokaż wszystkie {count} opcje/-i", + + "orientation": "Orientacja", + "orientation.landscape": "Pozioma", + "orientation.portrait": "Pionowa", + "orientation.square": "Kwadrat", + + "page": "Strona", + "page.blueprint": "Ta strona nie ma jeszcze wzorca. Możesz go zdefiniować w /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Zmie\u0144 URL", + "page.changeSlug.fromTitle": "Utw\u00f3rz na podstawie tytu\u0142u", + "page.changeStatus": "Zmień status", + "page.changeStatus.position": "Wybierz pozycję", + "page.changeStatus.select": "Wybierz nowy status", + "page.changeTemplate": "Zmień szablon", + "page.changeTemplate.notice": "Zmiana szablonu strony spowoduje usunięcie treści z pól, które nie pasują pod względem typu. Używaj ostrożnie.", + "page.create": "Utwórz jako {status}", + "page.delete.confirm": "Czy na pewno chcesz usunąć {title}?", + "page.delete.confirm.subpages": "Ta strona zawiera podstrony.
Wszystkie podstrony również zostaną usunięte.", + "page.delete.confirm.title": "Wprowadź tytuł strony, aby potwierdzić", + "page.duplicate.appendix": "Kopiuj", + "page.duplicate.files": "Kopiuj pliki", + "page.duplicate.pages": "Kopiuj strony", + "page.move": "Przenieś stronę", + "page.sort": "Zmień pozycję", + "page.status": "Status", + "page.status.draft": "Szkic", + "page.status.draft.description": "Strona jest w trybie roboczym i widoczna tylko dla zalogowanych redaktorów lub pod sekretnym linkiem", + "page.status.listed": "Opublikowana", + "page.status.listed.description": "Strona jest opublikowana i widoczna dla każdego", + "page.status.unlisted": "Nie katalogowana", + "page.status.unlisted.description": "Strona jest dostępna tylko za pośrednictwem adresu URL", + + "pages": "Strony", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Nie ma jeszcze żadnych stron", + "pages.status.draft": "Szkice", + "pages.status.listed": "Opublikowane", + "pages.status.unlisted": "Nie katalogowana", + + "pagination.page": "Strona", + + "password": "Has\u0142o", + "paste": "Wklej", + "paste.after": "Wklej po", + "paste.success": "{count} wklejonych!", + "pixel": "Piksel", + "plugin": "Wtyczka", + "plugins": "Wtyczki", + "prev": "Poprzednie", + "preview": "Podgląd", + + "publish": "Opublikuj", + "published": "Opublikowane", + + "remove": "Usuń", + "rename": "Zmień nazwę", + "renew": "Odnów", + "replace": "Zamie\u0144", + "replace.with": "Zamień z", + "retry": "Pon\u00f3w pr\u00f3b\u0119", + "revert": "Odrzu\u0107", + "revert.confirm": "Czy na pewno chcesz usunąć wszystkie niezapisane zmiany?", + + "role": "Rola", + "role.admin.description": "Administrator posiada wszystkie uprawnienia", + "role.admin.title": "Administrator", + "role.all": "Wszystkie", + "role.empty": "Nie ma użytkowników z tą rolą", + "role.description.placeholder": "Brak opisu", + "role.nobody.description": "To jest rola zastępcza bez żadnych uprawnień", + "role.nobody.title": "Nikt", + + "save": "Zapisz", + "saved": "Zapisane", + "search": "Szukaj", + "searching": "Searching", + "search.min": "Aby wyszukać, wprowadź co najmniej {min} znaków", + "search.all": "Pokaż wszystkie {count} wyniki/-ów", + "search.results.none": "Brak wyników", + + "section.invalid": "Sekcja jest nieprawidłowa", + "section.required": "Sekcja jest wymagana", + + "security": "Bezpieczeństwo", + "select": "Wybierz", + "server": "Serwer", + "settings": "Ustawienia", + "show": "Pokaż", + "site.blueprint": "Ta strona nie ma jeszcze wzorca. Możesz go zdefiniować w /site/blueprints/site.yml", + "size": "Rozmiar", + "slug": "Końcówka URL", + "sort": "Sortuj", + "sort.drag": "Przeciągnij, aby posortować…", + "split": "Podziel", + + "stats.empty": "Brak raportów", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "Zdaje się, że folder „content” jest wystawiony na publiczny dostęp", + "system.issues.eol.kirby": "Twoja zainstalowana wersja Kirby osiągnęła koniec okresu wsparcia i nie będzie otrzymywać dalszych aktualizacji zabezpieczeń", + "system.issues.eol.plugin": "Twoja zainstalowana wersja wtyczki { plugin } osiągnęła koniec okresu wsparcia i nie będzie otrzymywać dalszych aktualizacji zabezpieczeń", + "system.issues.eol.php": "Zainstalowana wersja PHP { release } osiągnęła koniec okresu eksploatacji i nie będzie otrzymywać dalszych aktualizacji zabezpieczeń.", + "system.issues.debug": "Debugowanie musi być wyłączone w środowisku produkcyjnym", + "system.issues.git": "Zdaje się, że folder „.git” jest wystawiony na publiczny dostęp", + "system.issues.https": "Zalecamy HTTPS dla wszystkich Twoich witryn", + "system.issues.kirby": "Zdaje się, że folder „kirby” jest wystawiony na publiczny dostęp", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "Zdaje się, że folder „site” jest wystawiony na publiczny dostęp", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Twojej instalacji może zagrażać następująca luka w zabezpieczeniach ({ severity } stopień): { description }", + "system.issues.vulnerability.plugin": "Twojej instalacji może zagrażać następująca luka w zabezpieczeniach we wtyczce { plugin } ({ severity } poziom): { description }", + "system.updateStatus": "Stan aktualizacji", + "system.updateStatus.error": "Nie udało się sprawdzić dostępności aktualizacji", + "system.updateStatus.not-vulnerable": "Brak znanych luk bezpieczeństwa", + "system.updateStatus.security-update": "Dostępna darmowa aktualizacja { version } z poprawkami bezpieczeństwa", + "system.updateStatus.security-upgrade": "Dostępna aktualizacja { version } z poprawkami bezpieczeństwa", + "system.updateStatus.unreleased": "Niepublikowana wersja", + "system.updateStatus.up-to-date": "Aktualna", + "system.updateStatus.update": "Dostępna darmowa aktualizacja { version }", + "system.updateStatus.upgrade": "Dostępna aktualizacja { version }", + + "tel": "Telefon", + "tel.placeholder": "+48123456789", + "template": "Szablon", + + "theme": "Wygląd", + "theme.light": "Jasny", + "theme.dark": "Ciemny", + "theme.automatic": "Zgodny z ustawieniami systemu", + + "title": "Tytuł", + "today": "Dzisiaj", + + "toolbar.button.clear": "Wyczyść formatowanie", + "toolbar.button.code": "Kod", + "toolbar.button.bold": "Pogrubienie", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Nagłówki", + "toolbar.button.heading.1": "Nagłówek 1", + "toolbar.button.heading.2": "Nagłówek 2", + "toolbar.button.heading.3": "Nagłówek 3", + "toolbar.button.heading.4": "Nagłówek 4", + "toolbar.button.heading.5": "Nagłówek 5", + "toolbar.button.heading.6": "Nagłówek 6", + "toolbar.button.italic": "Kursywa", + "toolbar.button.file": "Plik", + "toolbar.button.file.select": "Wybierz plik", + "toolbar.button.file.upload": "Prześlij plik", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Akapit", + "toolbar.button.strike": "Przekreślenie", + "toolbar.button.sub": "Indeks dolny", + "toolbar.button.sup": "Indeks górny", + "toolbar.button.ol": "Lista numerowana", + "toolbar.button.underline": "Podkreślenie", + "toolbar.button.ul": "Lista wypunktowana", + + "translation.author": "Zespół Kirby", + "translation.direction": "ltr", + "translation.name": "Polski", + "translation.locale": "pl_PL", + + "type": "Typ", + + "upload": "Prześlij", + "upload.error.cantMove": "Przesłany plik nie mógł być przeniesiony", + "upload.error.cantWrite": "Nie udało się zapisać pliku na dysku", + "upload.error.default": "Nie udało się przesłać pliku", + "upload.error.extension": "Przesyłanie pliku zostało zastopowane przez rozszerzenie", + "upload.error.formSize": "Przesłany plik przekracza dyrektywę MAX_FILE_SIZE określoną w formularzu", + "upload.error.iniPostSize": "Przesłany plik przekracza dyrektywę post_max_size określoną w php.ini", + "upload.error.iniSize": "Przesłany plik przekracza dyrektywę upload_max_filesize określoną w php.ini", + "upload.error.noFile": "Nie został przesłany żaden plik", + "upload.error.noFiles": "Nie zostały przesłane żadne pliki", + "upload.error.partial": "Została przesłana tylko część przesyłanego pliku", + "upload.error.tmpDir": "Brak tymczasowego folderu", + "upload.errors": "Błąd", + "upload.progress": "Przesyłanie…", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Użytkownik", + "user.blueprint": "Możesz zdefiniować dodatkowe sekcje i pola dla użytkownika o takiej roli w /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Zmień email", + "user.changeLanguage": "Zmień język", + "user.changeName": "Zmień nazwę tego użytkownika", + "user.changePassword": "Zmień hasło", + "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ę", + "user.changeRole.select": "Wybierz nową rolę", + "user.create": "Dodaj nowego użytkownika", + "user.delete": "Usuń tego użytkownika", + "user.delete.confirm": "Czy na pewno chcesz usunąć
{email}?", + + "users": "Użytkownicy", + + "version": "Wersja", + "version.changes": "Zmieniona wersja", + "version.compare": "Porównaj wersje", + "version.current": "Obecna wersja", + "version.latest": "Ostatnia wersja", + "versionInformation": "Informacje o wersji", + + "view": "Pokaż", + "view.account": "Twoje konto", + "view.installation": "Instalacja", + "view.languages": "Języki", + "view.resetPassword": "Zresetuj hasło", + "view.site": "Strona", + "view.system": "System", + "view.users": "U\u017cytkownicy", + + "welcome": "Witaj", + "year": "Rok", + "yes": "tak" +} diff --git a/public/kirby/i18n/translations/pt_BR.json b/public/kirby/i18n/translations/pt_BR.json new file mode 100644 index 0000000..36fae5f --- /dev/null +++ b/public/kirby/i18n/translations/pt_BR.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Mudar seu nome", + "account.delete": "Deletar sua conta", + "account.delete.confirm": "Deseja realmente deletar sua conta? Você sairá do site imediatamente. Sua conta não poderá ser recuperada. ", + + "activate": "Ativar", + "add": "Adicionar", + "alpha": "Alpha", + "author": "Autor", + "avatar": "Foto do perfil", + "back": "Voltar", + "cancel": "Cancelar", + "change": "Alterar", + "close": "Fechar", + "changes": "Alterações", + "confirm": "Salvar", + "collapse": "Colapsar", + "collapse.all": "Colapsar todos", + "color": "Cor", + "coordinates": "Coordenadas", + "copy": "Copiar", + "copy.all": "Copiar todos", + "copy.success": "{count} copiados!", + "copy.success.multiple": "{count} copiados!", + "copy.url": "Copiar URL", + "create": "Criar", + "custom": "Personalizado", + + "date": "Data", + "date.select": "Selecione uma data", + + "day": "Dia", + "days.fri": "Sex", + "days.mon": "Seg", + "days.sat": "S\u00e1b", + "days.sun": "Dom", + "days.thu": "Qui", + "days.tue": "Ter", + "days.wed": "Qua", + + "debugging": "Depuração ", + + "delete": "Deletar", + "delete.all": "Deletar todos", + + "dialog.fields.empty": "Esta caixa de diálogo não tem campos", + "dialog.files.empty": "Nenhum arquivo para selecionar", + "dialog.pages.empty": "Nenhuma página para selecionar", + "dialog.text.empty": "Esta caixa de diálogo não define nenhum texto", + "dialog.users.empty": "Nenhum usuário para selecionar", + + "dimensions": "Dimensões", + "disable": "Desativar", + "disabled": "Desativado", + "discard": "Descartar", + + "drawer.fields.empty": "Esta janela não tem campos", + + "domain": "Domínio", + "download": "Baixar", + "duplicate": "Duplicar", + + "edit": "Editar", + + "email": "Email", + "email.placeholder": "mail@exemplo.com", + + "enter": "Insira", + "entries": "Registos", + "entry": "Registo", + + "environment": "Ambiente", + + "error": "Erro", + "error.access.code": "Código inválido", + "error.access.login": "Código de acesso inválido", + "error.access.panel": "Você não tem permissão para acessar o painel", + "error.access.view": "Você não tem permissão para acessar esta parte do painel", + + "error.avatar.create.fail": "A foto de perfil não pôde ser enviada", + "error.avatar.delete.fail": "A foto de perfil não pôde ser deletada", + "error.avatar.dimensions.invalid": "Por favor, use uma foto de perfil com largura e altura menores que 3000 pixels", + "error.avatar.mime.forbidden": "A foto de perfil deve ser um arquivo JPEG ou PNG", + + "error.blueprint.notFound": "A planta \"{name}\" não pôde ser carregada", + + "error.blocks.max.plural": "Você não deve adicionar mais do que {max} blocos", + "error.blocks.max.singular": "Você não deve adicionar mais do que um bloco", + "error.blocks.min.plural": "Você deve adicionar pelo menos {min} blocos", + "error.blocks.min.singular": "Você deve adicionar pelo menos um bloco", + "error.blocks.validation": "Há um erro no campo \"{field}\" no bloco {index} a usar o tipo de bloco \"{fieldset}\"", + + "error.cache.type.invalid": "Tipo de cache \"{type}\" inválido", + + "error.content.lock.delete": "A versão está bloqueada e não pode ser eliminada", + "error.content.lock.move": "A versão está bloqueada e não pode ser movida", + "error.content.lock.publish": "Esta versão já se encontra publicada", + "error.content.lock.replace": "A versão está bloqueada e não pode ser substituída", + "error.content.lock.update": "A versão está bloqueada e não pode ser atualizada", + + "error.entries.max.plural": "Não deve adicionar mais do que {max} entradas", + "error.entries.max.singular": "Não deve adicionar mais do que uma entrada", + "error.entries.min.plural": "Deve adicionar pelo menos {min} entradas", + "error.entries.min.singular": "Deve adicionar pelo menos uma entrada", + "error.entries.supports": "O tipo de campo \"{type}\" não é compatível com o campo entries", + "error.entries.validation": "Existe um erro no campo \"{field}\" na linha {index}", + + "error.email.preset.notFound": "Pré-configuração de email \"{name}\" não foi encontrada", + + "error.field.converter.invalid": "Conversor \"{converter}\" inválido", + "error.field.link.options": "Opções inválidas: {options}", + "error.field.type.missing": "Campo \"{name}\": O tipo de campo \"{type}\" não existe", + + "error.file.changeName.empty": "O nome não deve ficar em branco", + "error.file.changeName.permission": "Você não tem permissão para alterar o nome de \"{filename}\"", + "error.file.changeTemplate.invalid": "O template para o ficheiro \"{id}\" não pode ser alterado para \"{template}\" (válido: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Não tem permissão para alterar o template do ficheiro \"{id}\"", + + "error.file.delete.multiple": "Nem todos os ficheiros puderam ser eliminados. Experimente cada ficheiro restante individualmente para ver o erro específico que impede a sua eliminação.", + "error.file.duplicate": "Um arquivo com o nome \"{filename}\" já existe", + "error.file.extension.forbidden": "Extensão \"{extension}\" não permitida", + "error.file.extension.invalid": "Extensão inválida: {extension}", + "error.file.extension.missing": "Extensão de \"{filename}\" em falta", + "error.file.maxheight": "A altura da imagem não pode exceder {height} pixels", + "error.file.maxsize": "O arquivo é grande demais", + "error.file.maxwidth": "A largura da imagem não pode exceder {width} pixels", + "error.file.mime.differs": "O arquivo enviado precisa ser do tipo \"{mime}\"", + "error.file.mime.forbidden": "Tipo de mídia \"{mime}\" não permitido", + "error.file.mime.invalid": "Tipo mime inválido: {mime}", + "error.file.mime.missing": "Tipo de mídia de \"{filename}\" não detectado", + "error.file.minheight": "A altura da imagem deve ser pelo menos {height} pixels", + "error.file.minsize": "O arquivo é pequeno demais", + "error.file.minwidth": "A largura da imagem deve ser pelo menos {width} pixels", + "error.file.name.unique": "O nome do ficheiro deve ser único", + "error.file.name.missing": "O nome do arquivo não pode ficar em branco", + "error.file.notFound": "Arquivo \"{filename}\" não encontrado", + "error.file.orientation": "A orientação da imagem deve ser “{orientation}”", + "error.file.sort.permission": "Não tem permissão para alterar a ordem de \"{filename}\"", + "error.file.type.forbidden": "Você não tem permissão para enviar arquivos {type}", + "error.file.type.invalid": "Tipo inválido de arquivo: {type}", + "error.file.undefined": "Arquivo n\u00e3o encontrado", + + "error.form.incomplete": "Por favor, corrija os erros do formulário…", + "error.form.notSaved": "O formulário não pôde ser salvo", + + "error.language.code": "Por favor entre um código válido para o idioma", + "error.language.create.permission": "Não tem permissões para criar um idioma", + "error.language.delete.permission": "Não tem permissões para eliminar o idioma", + "error.language.duplicate": "O idioma já existe", + "error.language.name": "Por favor entre um nome válido para o idioma", + "error.language.notFound": "O idioma não foi encontrado", + "error.language.update.permission": "Não tem permissões para atualizar o idioma", + + "error.layout.validation.block": "Há um erro no campo \"{field}\" no bloco {blockIndex} a usar o tipo de bloco \"{fieldset}\" no layout {layoutIndex}", + "error.layout.validation.settings": "Há um erro na configuração do layout {index}", + + "error.license.domain": "O domínio da licença está em falta", + "error.license.email": "Digite um endereço de email válido", + "error.license.format": "Por favor insira um código de licença válido", + "error.license.verification": "A licensa não pôde ser verificada", + + "error.login.totp.confirm.invalid": "Código inválido", + "error.login.totp.confirm.missing": "Por favor insira o código atual", + + "error.object.validation": "Há um erro no campo \"{label}\":\n{message}", + + "error.offline": "O painel está offline no momento", + + "error.page.changeSlug.permission": "Você não tem permissão para alterar o anexo de URL de \"{slug}\"", + "error.page.changeSlug.reserved": "O caminho das páginas de nível superior não deve começar com \"{path}\"", + "error.page.changeStatus.incomplete": "A página possui erros e não pode ser salva", + "error.page.changeStatus.permission": "O estado desta página não pode ser alterado", + "error.page.changeStatus.toDraft.invalid": "A página \"{slug}\" não pode ser convertida para rascunho", + "error.page.changeTemplate.invalid": "O tema da página \"{slug}\" não pode ser alterado", + "error.page.changeTemplate.permission": "Você não tem permissão para alterar o tema de \"{slug}\"", + "error.page.changeTitle.empty": "O título não pode ficar em branco", + "error.page.changeTitle.permission": "Você não tem permissão para alterar o título de \"{slug}\"", + "error.page.create.permission": "Você não tem permissão para criar \"{slug}\"", + "error.page.delete": "A página \"{slug}\" não pode ser deletada", + "error.page.delete.confirm": "Por favor, digite o título da página para confirmar", + "error.page.delete.hasChildren": "A página possui subpáginas e não pode ser deletada", + "error.page.delete.multiple": "Nem todas as páginas puderam ser eliminadas. Experimente cada página restante individualmente para ver o erro específico que impede a sua eliminação.", + "error.page.delete.permission": "Você não tem permissão para deletar \"{slug}\"", + "error.page.draft.duplicate": "Uma página rascunho com um anexo de URL \"{slug}\" já existe", + "error.page.duplicate": "Uma página com o anexo de URL \"{slug}\" já existe", + "error.page.duplicate.permission": "Você não tem permissão para duplicar “{slug}”", + "error.page.move.ancestor": "A página não pode ser movida para dentro dela mesma", + "error.page.move.directory": "A pasta da página não pode ser movida", + "error.page.move.duplicate": "Uma subpágina com o segmento de URL \"{slug}\" já existe", + "error.page.move.noSections": "A página \"{parent}\" não pode ser pai de nenhuma página porque não tem secções de páginas na sua blueprint", + "error.page.move.notFound": "A página movida não foi encontrada", + "error.page.move.permission": "Não tem permissão para mover \"{slug}\"", + "error.page.move.template": "O template \"{template}\" não é aceite como subpágina de \"{parent}\"", + "error.page.notFound": "Página \"{slug}\" não encontrada", + "error.page.num.invalid": "Digite um número de ordenação válido. Este número não pode ser negativo.", + "error.page.slug.invalid": "Por favor entre um anexo de URL válido ", + "error.page.slug.maxlength": "O slug deve ter menos de “{length}” caracteres", + "error.page.sort.permission": "A página \"{slug}\" não pode ser ordenada", + "error.page.status.invalid": "Por favor, defina um estado de página válido", + "error.page.undefined": "P\u00e1gina n\u00e3o encontrada", + "error.page.update.permission": "Você não tem permissão para atualizar \"{slug}\"", + + "error.section.files.max.plural": "Você não pode adicionar mais do que {max} arquivos à seção \"{section}\"", + "error.section.files.max.singular": "Você não pode adicionar mais do que um arquivo à seção \"{section}\"", + "error.section.files.min.plural": "A seção “{section}” precisa ter pelo menos {min} arquivos", + "error.section.files.min.singular": "A seção “{section}” precisa ter pelo menos um arquivo", + + "error.section.pages.max.plural": "Você não pode adicionar mais do que {max} páginas à seção \"{section}\"", + "error.section.pages.max.singular": "Você não pode adicionar mais do que uma página à seção \"{section}\"", + "error.section.pages.min.plural": "A seção “{section}” precisa ter pelo menos {min} páginas ", + "error.section.pages.min.singular": "A seção “{section}” precisa ter pelo menos uma página ", + + "error.section.notLoaded": "A seção \"{name}\" não pôde ser carregada", + "error.section.type.invalid": "O tipo da seção \"{type}\" não é válido", + + "error.site.changeTitle.empty": "O título não pode ficar em branco", + "error.site.changeTitle.permission": "Você não tem permissão para alterar o título do site", + "error.site.update.permission": "Você não tem permissão para atualizar o site", + + "error.structure.validation": "Existe um erro no campo \"{field}\" na linha {index}", + + "error.template.default.notFound": "O tema padrão não existe", + + "error.unexpected": "Ocorreu um erro inesperado! Ative o modo de debug para obter mais informações: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Você não tem permissão para alterar o email do usuário \"{name}\"", + "error.user.changeLanguage.permission": "Você não tem permissão para alterar o idioma do usuário \"{name}\"", + "error.user.changeName.permission": "Você não tem permissão para alterar o nome do usuário \"{name}\"", + "error.user.changePassword.permission": "Você não tem permissão para alterar a senha do usuário \"{name}\"", + "error.user.changeRole.lastAdmin": "O papel do último administrador não pode ser alterado", + "error.user.changeRole.permission": "Você não tem permissão para alterar o papel do usuário \"{name}\"", + "error.user.changeRole.toAdmin": "Você não tem permissão para promover usuários ao papel de administrador ", + "error.user.create.permission": "Você não tem permissão para criar este usuário", + "error.user.delete": "O usuário \"{name}\" não pode ser deletado", + "error.user.delete.lastAdmin": "O último administrador não pode ser deletado", + "error.user.delete.lastUser": "O último usuário não pode ser deletado", + "error.user.delete.permission": "Você não tem permissão para deletar o usuário \"{name}\"", + "error.user.duplicate": "Um usuário com o email \"{email}\" já existe", + "error.user.email.invalid": "Digite um endereço de email válido", + "error.user.language.invalid": "Digite um idioma válido", + "error.user.notFound": "Usuário \"{name}\" não encontrado", + "error.user.password.excessive": "Por favor insira uma palavra-passe válida. As palavras-passe não devem ter mais do que 1000 caracteres.", + "error.user.password.invalid": "Digite uma senha válida. Sua senha deve ter pelo menos 8 caracteres.", + "error.user.password.notSame": "As senhas não combinam", + "error.user.password.undefined": "O usuário não possui uma senha", + "error.user.password.wrong": "Senha errada", + "error.user.role.invalid": "Digite um papel válido", + "error.user.undefined": "Usuário não encontrado", + "error.user.update.permission": "Você não tem permissão para atualizar o usuário \"{name}\"", + + "error.validation.accepted": "Por favor, confirme", + "error.validation.alpha": "Por favor, use apenas caracteres entre a-z", + "error.validation.alphanum": "Por favor, use apenas caracteres entre a-z ou 0-9", + "error.validation.anchor": "Por favor insira uma âncora de link correta", + "error.validation.between": "Digite um valor entre \"{min}\" e \"{max}\"", + "error.validation.boolean": "Por favor, confirme ou rejeite", + "error.validation.color": "Por favor, insira uma cor válida no formato {format}", + "error.validation.contains": "Digite um valor que contenha \"{needle}\"", + "error.validation.date": "Escolha uma data válida", + "error.validation.date.after": "Por favor entre uma data depois de {date}", + "error.validation.date.before": "Por favor entre uma data antes de {date}", + "error.validation.date.between": "Por favor entre uma data entre {min} e {max}", + "error.validation.denied": "Por favor, cancele", + "error.validation.different": "O valor deve ser diferente de \"{other}\"", + "error.validation.email": "Digite um endereço de email válido", + "error.validation.endswith": "O valor deve terminar com \"{end}\"", + "error.validation.filename": "Digite um nome de arquivo válido", + "error.validation.in": "Digite um destes valores: ({in})", + "error.validation.integer": "Digite um número inteiro válido", + "error.validation.ip": "Digite um endereço de IP válido", + "error.validation.less": "Digite um valor menor que {max}", + "error.validation.linkType": "O tipo de link não é permitido", + "error.validation.match": "O valor não combina com o padrão esperado", + "error.validation.max": "Digite um valor igual ou menor que {max}", + "error.validation.maxlength": "Digite um valor curto. (no máximo {max} caracteres)", + "error.validation.maxwords": "Digite menos que {max} palavra(s)", + "error.validation.min": "Digite um valor igual ou maior que {min}", + "error.validation.minlength": "Digite um valor maior. (no mínimo {min} caracteres)", + "error.validation.minwords": "Digite ao menos {min} palavra(s)", + "error.validation.more": "Digite um valor maior que {min}", + "error.validation.notcontains": "Digite um valor que não contenha \"{needle}\"", + "error.validation.notin": "Não digite nenhum destes valores: ({notIn})", + "error.validation.option": "Escolha uma opção válida", + "error.validation.num": "Digite um número válido", + "error.validation.required": "Digite algo", + "error.validation.same": "Por favor, digite \"{other}\"", + "error.validation.size": "O tamanho do valor deve ser \"{size}\"", + "error.validation.startswith": "O valor deve começar com \"{start}\"", + "error.validation.tel": "Por favor, insira um número de telefone não formatado", + "error.validation.time": "Digite um horário válido", + "error.validation.time.after": "Por favor entre um horário depois de {time}", + "error.validation.time.before": "Por favor entre um horário antes de {time}", + "error.validation.time.between": "Por favor entre um horário entre {min} e {max}", + "error.validation.uuid": "Por favor, insira um UUID válido", + "error.validation.url": "Digite uma URL válida", + + "expand": "Expandir", + "expand.all": "Expandir todos", + + "field.invalid": "O campo é inválido", + "field.required": "Este campo é obrigatório ", + "field.blocks.changeType": "Mudar tipo", + "field.blocks.code.name": "Código", + "field.blocks.code.language": "Idioma", + "field.blocks.code.placeholder": "Seu código …", + "field.blocks.delete.confirm": "Deseja realmente deletar este bloco?", + "field.blocks.delete.confirm.all": "Deseja realmente deletar todos os blocos?", + "field.blocks.delete.confirm.selected": "Deseja realmente deletar os blocos selecionados?", + "field.blocks.empty": "Nenhum bloco", + "field.blocks.fieldsets.empty": "Ainda não há tipos de blocos", + "field.blocks.fieldsets.label": "Por favor selecione um tipo de bloco …", + "field.blocks.fieldsets.paste": "Pressione {{ shortcut }} para importar layouts/blocks da sua área de transferência Só serão inseridos aqueles permitidos no campo atual.", + "field.blocks.gallery.name": "Galeria", + "field.blocks.gallery.images.empty": "Nenhuma imagem", + "field.blocks.gallery.images.label": "Imagens", + "field.blocks.heading.level": "Nível ", + "field.blocks.heading.name": "Título ", + "field.blocks.heading.text": "Texto", + "field.blocks.heading.placeholder": "Título …", + "field.blocks.figure.back.plain": "Simples", + "field.blocks.figure.back.pattern.light": "Padrão (claro)", + "field.blocks.figure.back.pattern.dark": "Padrão (escuro)", + "field.blocks.image.alt": "Texto alternativo", + "field.blocks.image.caption": "Legenda", + "field.blocks.image.crop": "Cortar", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Localização ", + "field.blocks.image.location.internal": "Este website", + "field.blocks.image.location.external": "Fonte externa", + "field.blocks.image.name": "Imagem", + "field.blocks.image.placeholder": "Selecionar uma imagem", + "field.blocks.image.ratio": "Proporção ", + "field.blocks.image.url": "URL da imagem", + "field.blocks.line.name": "Linha", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Texto", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Citação ", + "field.blocks.quote.text.label": "Texto", + "field.blocks.quote.text.placeholder": "Citação …", + "field.blocks.quote.citation.label": "Citação ", + "field.blocks.quote.citation.placeholder": "de …", + "field.blocks.text.name": "Texto", + "field.blocks.text.placeholder": "Texto …", + "field.blocks.video.autoplay": "Reprodução automática", + "field.blocks.video.caption": "Legenda", + "field.blocks.video.controls": "Controlos", + "field.blocks.video.location": "Localização ", + "field.blocks.video.loop": "Repetir", + "field.blocks.video.muted": "Sem som", + "field.blocks.video.name": "Vídeo ", + "field.blocks.video.placeholder": "Entre uma URL de vídeo ", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Pré-carregamento", + "field.blocks.video.url.label": "URL-Vídeo", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Tem a certeza que pretende eliminar todos os registos?", + "field.entries.empty": "Nenhum registro", + + "field.files.empty": "Nenhum arquivo selecionado", + "field.files.empty.single": "Nenhum ficheiro selecionado ainda", + + "field.layout.change": "Alterar layout", + "field.layout.delete": "Deletar layout", + "field.layout.delete.confirm": "Deseja realmente deletar este layout?", + "field.layout.delete.confirm.all": "Tem a certeza que pretende remover todos os layouts?", + "field.layout.empty": "Nenhuma linha", + "field.layout.select": "Selecionar um layout", + + "field.object.empty": "Nenhuma informação ainda", + + "field.pages.empty": "Nenhuma página selecionada", + "field.pages.empty.single": "Nenhuma página selecionada ainda", + + "field.structure.delete.confirm": "Deseja realmente deletar esta linha?", + "field.structure.delete.confirm.all": "Tem a certeza que pretende eliminar todos os registos?", + "field.structure.empty": "Nenhum registro", + + "field.users.empty": "Nenhum usuário selecionado", + "field.users.empty.single": "Nenhum utilizador selecionado ainda", + + "fields.empty": "Nenhum campo ainda", + + "file": "Ficheiro", + "file.blueprint": "Este arquivo não tem planta. Você pode definir sua planta em /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Alterar tema", + "file.changeTemplate.notice": "Alterar o template do ficheiro irá remover o conteúdo dos campos que não correspondem ao mesmo tipo. Se o novo template definir certas regras, por exemplo dimensões de imagem, estas também serão aplicadas irreversivelmente. Use com cuidado.", + "file.delete.confirm": "Deseja realmente deletar
{filename}?", + "file.focus.placeholder": "Definir ponto de foco", + "file.focus.reset": "Remover ponto de foco", + "file.focus.title": "Foco", + "file.sort": "Mudar posição", + + "files": "Arquivos", + "files.delete.confirm.selected": "Tem a certeza que pretende eliminar os ficheiros selecionados? Esta ação não pode ser revertida.", + "files.empty": "Nenhum arquivo", + + "filter": "Filtro", + + "form.discard": "Reverter alterações", + "form.discard.confirm": "Tem a certeza que pretende reverter todas as suas alterações?", + "form.locked": "Este conteúdo está desativado para si porque encontra-se a ser editado por outro utilizador", + "form.unsaved": "As alterações atuais ainda não foram guardadas", + "form.preview": "Pré-visualizar alterações", + "form.preview.draft": "Pré-visualizar rascunho", + + "hide": "Ocultar", + "hour": "Hora", + "hue": "Tonalidade", + "import": "Importar", + "info": "Info", + "insert": "Inserir", + "insert.after": "Inserir após", + "insert.before": "Inserir antes", + "install": "Instalar", + + "installation": "Instalação", + "installation.completed": "Painel instalado com sucesso", + "installation.disabled": "O instalador do painel está desabilitado em servidores públicos por padrão. Por favor, execute o instalador em uma máquina local ou habilite a opção panel.install.", + "installation.issues.accounts": "A pasta /site/accounts não existe ou não possui permissão de escrita", + "installation.issues.content": "A pasta /content não existe ou não possui permissão de escrita", + "installation.issues.curl": "A extensão CURL é necessária", + "installation.issues.headline": "O painel não pôde ser instalado", + "installation.issues.mbstring": "A extensão MB String é necessária", + "installation.issues.media": "A pasta /media não existe ou não possui permissão de escrita", + "installation.issues.php": "Certifique-se que você está usando o PHP 8+", + "installation.issues.sessions": "A pasta /site/sessions não existe ou não possui permissão de escrita", + + "language": "Idioma", + "language.code": "Código", + "language.convert": "Tornar padrão", + "language.convert.confirm": "

Deseja realmente converter {name} para o idioma padrão? Esta ação não poderá ser revertida.

Se {name} tiver conteúdo não traduzido, partes do seu site poderão ficar sem conteúdo.

", + "language.create": "Adicionar novo idioma", + "language.default": "Idioma padrão", + "language.delete.confirm": "Deseja realmente deletar o idioma {name} incluíndo todas as traduções. Esta ação não poderá ser revertida!", + "language.deleted": "Idioma deletado", + "language.direction": "Direção de leitura", + "language.direction.ltr": "Esquerda para direita", + "language.direction.rtl": "Direita para esquerda", + "language.locale": "String de localização do PHP", + "language.locale.warning": "Você está usando uma configuração de local customizada. Por favor modifique a configuração no arquivo do idioma em /site/languages", + "language.name": "Nome", + "language.secondary": "Idioma secundário", + "language.settings": "Configurações de idioma", + "language.updated": "Idioma atualizado", + "language.variables": "Variáveis de idioma", + "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", + + "languages": "Idiomas", + "languages.default": "Idioma padrão", + "languages.empty": "Nenhum idioma", + "languages.secondary": "Idiomas secundários", + "languages.secondary.empty": "Nenhum idioma secundário", + + "license": "Licen\u00e7a do Kirby ", + "license.activate": "Ativar agora", + "license.activate.label": "Por favor, ative a sua licença", + "license.activate.domain": "A sua licença será irá ser ativada para {host}.", + "license.activate.local": "Está prestes a ativar a sua licença Kirby no domínio local {host}. Se este site vai ser alojado num domínio público, por favor ative-o lá. Se o domínio {host} é o o que deseja para usar a sua licença, por favor continue.", + "license.activated": "Ativado", + "license.buy": "Comprar licença", + "license.code": "Código", + "license.code.help": "Recebeu o seu código de licença por e-mail após a compra. Por favor, copie e cole aqui.", + "license.code.label": "Por favor, digite o código da sua licença", + "license.status.active.info": "Inclui novas versões principais até {date}", + "license.status.active.label": "Licença válida", + "license.status.demo.info": "Esta é uma instalação de demonstração", + "license.status.demo.label": "Demonstração", + "license.status.inactive.info": "Renove a licença para atualizar para novas versões principais", + "license.status.inactive.label": "Nenhuma versão principal nova", + "license.status.legacy.bubble": "Pronto para renovar a sua licença?", + "license.status.legacy.info": "A sua licença não abrange esta versão", + "license.status.legacy.label": "Por favor, renove a sua licença", + "license.status.missing.bubble": "Pronto para lançar o seu site?", + "license.status.missing.info": "Nenhuma licença válida", + "license.status.missing.label": "Por favor, ative a sua licença", + "license.status.unknown.info": "O estado da licença é desconhecido", + "license.status.unknown.label": "Desconhecido", + "license.manage": "Gerir as suas licenças", + "license.purchased": "Compradas", + "license.success": "Obrigado por apoiar o Kirby", + "license.unregistered.label": "Não registadas", + + "link": "Link", + "link.text": "Texto do link", + + "loading": "Carregando", + + "lock.unsaved": "Mudanças não salvas", + "lock.unsaved.empty": "Não há mais mudanças não salvas", + "lock.unsaved.files": "Ficheiros não guardados", + "lock.unsaved.pages": "Páginas não guardadas", + "lock.unsaved.users": "Contas não guardadas", + "lock.isLocked": "Alterações não guardadas de {email}", + "lock.unlock": "Destrancar", + "lock.unlock.submit": "Desbloqueie e substitua alterações não guardadas de {email}", + "lock.isUnlocked": "Foi desbloqueado por outro utilizador", + + "login": "Entrar", + "login.code.label.login": "Código de acesso", + "login.code.label.password-reset": "Código de redefinição de senha", + "login.code.placeholder.email": "000 0000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Se seu endereço de email está registrado, o código requisitado será mandado por email.", + "login.code.text.totp": "Por favor, insira o código único da sua aplicação de autenticação.", + "login.email.login.body": "Oi, {user.nameOrEmail},\n\nVocê recentemente pediu um código de acesso ao painel administrativo do site {site}.\nO seguinte código será válido por {timeout} minutos:\n\n{code}\n\nSe você não pediu este código de acesso, por favor ignore esta mensagem, ou contate seu Administrador de Sistemas se você tiver dúvidas.\nPor questões de segurança, por favor NÃO compartilhe esta mensagem.", + "login.email.login.subject": "Seu código de acesso", + "login.email.password-reset.body": "Oi, {user.nameOrEmail},\n\nVocê recentemente pediu um código de redefinição de senha, para o painel administrativo do site {site}.\nO seguinte código de redefinição de senha será válido por {timeout} minutos:\n\n{code}\n\nSe você não pediu este código, por favor ignore esta mensagem, ou contate seu Administrador de Sistemas se você tiver dúvidas.\nPor questões de segurança, por favor NÃO compartilhe esta mensagem.", + "login.email.password-reset.subject": "Seu código de redefinição de senha", + "login.remember": "Manter-me conectado", + "login.reset": "Redefinir senha", + "login.toggleText.code.email": "Entrar com email", + "login.toggleText.code.email-password": "Entrar com senha", + "login.toggleText.password-reset.email": "Esqueceu sua senha?", + "login.toggleText.password-reset.email-password": "← Voltar à entrada", + "login.totp.enable.option": "Configurar códigos únicos", + "login.totp.enable.intro": "As aplicações de autenticação podem gerar códigos únicos que são utilizados como um segundo fator ao iniciar a sessão na sua conta.", + "login.totp.enable.qr.label": "1. Leia este código QR", + "login.totp.enable.qr.help": "Não consegue ler o código? Adicione a chave de configuração {secret} manualmente à sua aplicação de autenticação.", + "login.totp.enable.confirm.headline": "2. Confirme com o código gerado", + "login.totp.enable.confirm.text": "A sua aplicação gera um novo código único a cada 30 segundos. Insira o código atual para concluir a configuração:", + "login.totp.enable.confirm.label": "Código atual", + "login.totp.enable.confirm.help": "Após esta configuração, iremos solicitar um código único sempre que iniciar a sessão.", + "login.totp.enable.success": "Códigos únicos ativados", + "login.totp.disable.option": "Desativar códigos únicos", + "login.totp.disable.label": "Insira a sua palavra-passe para desativar códigos únicos", + "login.totp.disable.help": "No futuro, um segundo fator diferente, como um código de início de sessão enviado por e-mail, será solicitado quando iniciar a sessão. Poderá configurar códigos únicos novamente mais tarde.", + "login.totp.disable.admin": "Isto irá desactivar os códigos únicos para {user}. No futuro, um segundo fator diferente, como um código de início de sessão enviado por e-mail, será solicitado quando eles iniciarem a sessão. {user} poderá configurar códigos únicos novamente após o próximo início de sessão.", + "login.totp.disable.success": "Códigos únicos desativados", + + "logout": "Sair", + + "merge": "Unir", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipo de mídia", + "minutes": "Minutos", + + "month": "Mês", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Dezembro", + "months.february": "Fevereiro", + "months.january": "Janeiro", + "months.july": "Julho", + "months.june": "Junho", + "months.march": "Mar\u00e7o", + "months.may": "Maio", + "months.november": "Novembro", + "months.october": "Outubro", + "months.september": "Setembro", + + "more": "Mais", + "move": "Mover", + "name": "Nome", + "next": "Próximo", + "night": "Noite", + "no": "não", + "off": "não", + "on": "sim", + "open": "Abrir", + "open.newWindow": "Abrir em nova janela", + "option": "Opção", + "options": "Opções", + "options.none": "Nenhuma opção", + "options.all": "Mostrar todas as {count} opções", + + "orientation": "Orientação", + "orientation.landscape": "Paisagem", + "orientation.portrait": "Retrato", + "orientation.square": "Quadrado", + + "page": "Página", + "page.blueprint": "Esta página não tem planta. Você pode definir sua planta em /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Alterar URL", + "page.changeSlug.fromTitle": "Criar a partir do t\u00edtulo", + "page.changeStatus": "Alterar estado", + "page.changeStatus.position": "Selecione uma posição", + "page.changeStatus.select": "Selecione um novo estado", + "page.changeTemplate": "Alterar tema", + "page.changeTemplate.notice": "Alterar o template da página irá remover o conteúdo dos campos que não correspondem ao mesmo tipo. Use com cuidado.", + "page.create": "Criar como {status}", + "page.delete.confirm": "Deseja realmente deletar {title}?", + "page.delete.confirm.subpages": "Esta página possui subpáginas.
Todas as subpáginas serão excluídas também.", + "page.delete.confirm.title": "Digite o título da página para confirmar", + "page.duplicate.appendix": "Copiar", + "page.duplicate.files": "Copiar arquivos", + "page.duplicate.pages": "Copiar páginas", + "page.move": "Mover página", + "page.sort": "Mudar posição", + "page.status": "Estado", + "page.status.draft": "Rascunho", + "page.status.draft.description": "A página é um rascunho, e visível somente por editores logados, ou através de um link secreto.", + "page.status.listed": "Pública", + "page.status.listed.description": "A página pública é visível para todos", + "page.status.unlisted": "Não listadas", + "page.status.unlisted.description": "Esta página é acessível somente através da URL", + + "pages": "Páginas", + "pages.delete.confirm.selected": "Tem a certeza que pretende eliminar as páginas selecionadas? Esta ação não pode ser revertida.", + "pages.empty": "Nenhuma página", + "pages.status.draft": "Rascunhos", + "pages.status.listed": "Publicadas", + "pages.status.unlisted": "Não listadas", + + "pagination.page": "Página", + + "password": "Senha", + "paste": "Colar", + "paste.after": "Colar após", + "paste.success": "{count} colados!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Anterior", + "preview": "Visualizar", + + "publish": "Publicar", + "published": "Publicadas", + + "remove": "Remover", + "rename": "Renomear", + "renew": "Renovar", + "replace": "Substituir", + "replace.with": "Substituir por", + "retry": "Tentar novamente", + "revert": "Descartar", + "revert.confirm": "Deseja realmente deletar todas as mudanças não salvas?", + + "role": "Papel", + "role.admin.description": "O administrador tem todos os direitos", + "role.admin.title": "Administrador", + "role.all": "Todos", + "role.empty": "Não há usuários com este papel", + "role.description.placeholder": "Sem descrição", + "role.nobody.description": "Este é um papel atribuído por padrão, sem nenhuma permissão", + "role.nobody.title": "Ninguém", + + "save": "Salvar", + "saved": "Guardado", + "search": "Buscar", + "searching": "À procura", + "search.min": "Digite {min} caracteres para fazer uma busca", + "search.all": "Mostrar todos os {count} resultados", + "search.results.none": "Nenhum resultado", + + "section.invalid": "A secção é inválida", + "section.required": "Esta seção é obrigatória", + + "security": "Segurança", + "select": "Selecionar", + "server": "Servidor", + "settings": "Configurações", + "show": "Mostrar", + "site.blueprint": "Este site não tem planta. Você pode definir sua planta em /site/blueprints/site.yml", + "size": "Tamanho", + "slug": "Anexo de URL", + "sort": "Ordenar", + "sort.drag": "Arraste para ordenar ...", + "split": "Dividir", + + "stats.empty": "Nenhum relatório", + "status": "Estado", + + "system.info.copy": "Copiar informação", + "system.info.copied": "Informação de sistema copiada", + "system.issues.content": "A pasta \"content\" parece não estar protegida", + "system.issues.eol.kirby": "A versão instalada do Kirby chegou ao fim da sua vida útil e não irá receber mais atualizações de segurança", + "system.issues.eol.plugin": "A versão instalada do plugin {plugin} chegou ao fim da sua vida útil e não irá receber mais atualizações de segurança", + "system.issues.eol.php": "A versão instalada {release} de PHP chegou ao fim da sua vida útil e não irá receber mais atualizações de segurança", + "system.issues.debug": "O modo debug deve ser desativado em produção", + "system.issues.git": "A pasta \".git\" parece não estar protegida", + "system.issues.https": "Nós recomendamos HTTPS para todos os seus sites", + "system.issues.kirby": "A pasta \"kirby\" parece não estar protegida", + "system.issues.local": "O site está a correr localmente com verificações de segurança relaxadas", + "system.issues.site": "A pasta \"site\" parece não estar protegida", + "system.issues.vue.compiler": "O compilador de templates Vue está ativado", + "system.issues.vulnerability.kirby": "A sua instalação poderá ser afetada pela seguinte vulnerabilidade ({ severity } gravidade): { description }", + "system.issues.vulnerability.plugin": "A sua instalação poderá ser afetada pela seguinte vulnerabilidade no plugin { plugin } ({ severity } gravidade): { description }", + "system.updateStatus": "Atualizar estado", + "system.updateStatus.error": "Não foi possível verificar se havia atualizações", + "system.updateStatus.not-vulnerable": "Nenhuma vulnerabilidade conhecida", + "system.updateStatus.security-update": "Atualização de segurança gratuita { version } disponível", + "system.updateStatus.security-upgrade": "Atualização { version } com correções de segurança disponível", + "system.updateStatus.unreleased": "Versão não lançada", + "system.updateStatus.up-to-date": "Atualizado", + "system.updateStatus.update": "Atualização gratuita { version } disponível", + "system.updateStatus.upgrade": "Atualização { version } disponível", + + "tel": "Telefone", + "tel.placeholder": "+351 123456789", + "template": "Tema", + + "theme": "Tema", + "theme.light": "Luzes ligadas", + "theme.dark": "Luzes desligadas", + "theme.automatic": "Ajustar ao tema do sistema", + + "title": "Título", + "today": "Hoje", + + "toolbar.button.clear": "Limpar formatação", + "toolbar.button.code": "Código", + "toolbar.button.bold": "Negrito", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Títulos", + "toolbar.button.heading.1": "Título 1", + "toolbar.button.heading.2": "Título 2", + "toolbar.button.heading.3": "Título 3", + "toolbar.button.heading.4": "Título 4", + "toolbar.button.heading.5": "Título 5", + "toolbar.button.heading.6": "Título 6", + "toolbar.button.italic": "Itálico", + "toolbar.button.file": "Arquivo", + "toolbar.button.file.select": "Selecionar arquivo", + "toolbar.button.file.upload": "Carregar arquivo", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Parágrafo", + "toolbar.button.strike": "Riscado", + "toolbar.button.sub": "Subscrito", + "toolbar.button.sup": "Sobrescrito", + "toolbar.button.ol": "Lista ordenada", + "toolbar.button.underline": "Sublinhado", + "toolbar.button.ul": "Lista não-ordenada", + + "translation.author": "Time Kirby", + "translation.direction": "ltr", + "translation.name": "Português do Brasil", + "translation.locale": "pt_BR", + + "type": "Tipo", + + "upload": "Enviar", + "upload.error.cantMove": "O arquivo carregado não pôde ser movido", + "upload.error.cantWrite": "Falha ao escrever o arquivo no disco", + "upload.error.default": "O arquivo não pode ser carregado", + "upload.error.extension": "O carregamento do arquivo foi interrompido por causa da extensão", + "upload.error.formSize": "O arquivo carregado excede a diretiva de MAX_FILE_SIZE especificada no formulário", + "upload.error.iniPostSize": "O arquivo carregado excede a diretiva post_max_size do php.ini", + "upload.error.iniSize": "O arquivo carregado excede a diretiva upload_max_size do php.ini", + "upload.error.noFile": "Nenhum arquivo foi carregado", + "upload.error.noFiles": "Nenhum arquivo foi carregado", + "upload.error.partial": "O arquivo foi só parcialmente carregado", + "upload.error.tmpDir": "Falta uma pasta temporária", + "upload.errors": "Erro", + "upload.progress": "Enviando…", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Usuário", + "user.blueprint": "Você pode definir seções e campos de formulário adicionais para este papel de usuário em /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Alterar email", + "user.changeLanguage": "Alterar idioma", + "user.changeName": "Renomear usuário", + "user.changePassword": "Alterar senha", + "user.changePassword.current": "A sua palavra-passe atual", + "user.changePassword.new": "Nova senha", + "user.changePassword.new.confirm": "Confirme a nova senha…", + "user.changeRole": "Alterar papel", + "user.changeRole.select": "Selecione um novo papel", + "user.create": "Adicionar novo usuário", + "user.delete": "Deletar este usuário", + "user.delete.confirm": "Deseja realmente deletar
{email}?", + + "users": "Usuários", + + "version": "Vers\u00e3o do Kirby", + "version.changes": "Versão alterada", + "version.compare": "Comparar versões", + "version.current": "Versão atual", + "version.latest": "Versão mais recente", + "versionInformation": "Informação da versão", + + "view": "Visualizar", + "view.account": "Sua conta", + "view.installation": "Instala\u00e7\u00e3o", + "view.languages": "Idiomas", + "view.resetPassword": "Redefinir senha", + "view.site": "Site", + "view.system": "Sistema", + "view.users": "Usu\u00e1rios", + + "welcome": "Bem-vindo", + "year": "Ano", + "yes": "sim" +} diff --git a/public/kirby/i18n/translations/pt_PT.json b/public/kirby/i18n/translations/pt_PT.json new file mode 100644 index 0000000..2e3a8c4 --- /dev/null +++ b/public/kirby/i18n/translations/pt_PT.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Altere o seu nome", + "account.delete": "Elimine a sua conta", + "account.delete.confirm": "Tem a certeza que pretende eliminar a sua conta? A sessão será terminada imediatamente. A sua conta não poderá ser recuperada. ", + + "activate": "Ativar", + "add": "Adicionar", + "alpha": "Alpha", + "author": "Autor", + "avatar": "Foto de perfil", + "back": "Voltar", + "cancel": "Cancelar", + "change": "Alterar", + "close": "Fechar", + "changes": "Alterações", + "confirm": "Ok", + "collapse": "Colapsar", + "collapse.all": "Colapsar todos", + "color": "Cor", + "coordinates": "Coordenadas", + "copy": "Copiar", + "copy.all": "Copiar todos", + "copy.success": "Copiado", + "copy.success.multiple": "{count} copiados!", + "copy.url": "Copiar URL", + "create": "Criar", + "custom": "Personalizado", + + "date": "Data", + "date.select": "Selecione uma data", + + "day": "Dia", + "days.fri": "Sex", + "days.mon": "Seg", + "days.sat": "S\u00e1b", + "days.sun": "Dom", + "days.thu": "Qui", + "days.tue": "Ter", + "days.wed": "Qua", + + "debugging": "Debugging ", + + "delete": "Eliminar", + "delete.all": "Eliminar todos", + + "dialog.fields.empty": "Esta caixa de diálogo não tem campos", + "dialog.files.empty": "Sem ficheiros para selecionar", + "dialog.pages.empty": "Sem páginas para selecionar", + "dialog.text.empty": "Esta caixa de diálogo não define nenhum texto", + "dialog.users.empty": "Sem utilizadores para selecionar", + + "dimensions": "Dimensões", + "disable": "Desativar", + "disabled": "Desativado", + "discard": "Descartar", + + "drawer.fields.empty": "Esta janela não tem campos", + + "domain": "Domínio", + "download": "Descarregar", + "duplicate": "Duplicar", + + "edit": "Editar", + + "email": "Email", + "email.placeholder": "mail@exemplo.pt", + + "enter": "Insira", + "entries": "Entradas", + "entry": "Entrada", + + "environment": "Ambiente", + + "error": "Erro", + "error.access.code": "Código inválido", + "error.access.login": "Dados de acesso inválidos", + "error.access.panel": "Não tem permissões para aceder ao painel", + "error.access.view": "Não tem permissões para aceder a esta área do painel", + + "error.avatar.create.fail": "Não foi possível enviar a foto de perfil", + "error.avatar.delete.fail": "Não foi possível eliminar a foto de perfil", + "error.avatar.dimensions.invalid": "Por favor, use uma foto de perfil com largura e altura menores que 3000 pixels", + "error.avatar.mime.forbidden": "A foto de perfil deve ser um ficheiro JPEG ou PNG", + + "error.blueprint.notFound": "Não foi possível carregar o blueprint \"{name}\"", + + "error.blocks.max.plural": "Não pode adicionar mais do que {max} blocos", + "error.blocks.max.singular": "Não pode adicionar mais do que um bloco", + "error.blocks.min.plural": "Tem de adicionar pelo menos {min} blocos", + "error.blocks.min.singular": "Tem de adicionar pelo menos um bloco", + "error.blocks.validation": "Há um erro no campo \"{field}\" no bloco {index} a usar o tipo de bloco \"{fieldset}\"", + + "error.cache.type.invalid": "Tipo de cache \"{type}\" inválido", + + "error.content.lock.delete": "A versão está bloqueada e não pode ser eliminada", + "error.content.lock.move": "A versão está bloqueada e não pode ser movida", + "error.content.lock.publish": "Esta versão já se encontra publicada", + "error.content.lock.replace": "A versão está bloqueada e não pode ser substituída", + "error.content.lock.update": "A versão está bloqueada e não pode ser atualizada", + + "error.entries.max.plural": "Não deve adicionar mais do que {max} entradas", + "error.entries.max.singular": "Não deve adicionar mais do que uma entrada", + "error.entries.min.plural": "Deve adicionar pelo menos {min} entradas", + "error.entries.min.singular": "Deve adicionar pelo menos uma entrada", + "error.entries.supports": "O tipo de campo \"{type}\" não é compatível com o campo entries", + "error.entries.validation": "Existe um erro no campo \"{field}\" na linha {index}", + + "error.email.preset.notFound": "A predefinição de email \"{name}\" não foi encontrada", + + "error.field.converter.invalid": "Conversor \"{converter}\" inválido", + "error.field.link.options": "Opções inválidas: {options}", + "error.field.type.missing": "Campo \"{name}\": O tipo de campo \"{type}\" não existe", + + "error.file.changeName.empty": "O nome não pode ficar em branco", + "error.file.changeName.permission": "Não tem permissões para alterar o nome de \"{filename}\"", + "error.file.changeTemplate.invalid": "O template para o ficheiro \"{id}\" não pode ser alterado para \"{template}\" (válido: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Não tem permissão para alterar o template do ficheiro \"{id}\"", + + "error.file.delete.multiple": "Nem todos os ficheiros puderam ser eliminados. Experimente cada ficheiro restante individualmente para ver o erro específico que impede a sua eliminação.", + "error.file.duplicate": "Um ficheiro com o nome \"{filename}\" já existe", + "error.file.extension.forbidden": "A extensão \"{extension}\" não é permitida", + "error.file.extension.invalid": "Extensão inválida: {extension}", + "error.file.extension.missing": "As extensões de \"{filename}\" estão em falta", + "error.file.maxheight": "A altura da imagem não deve exceder {height} píxeis", + "error.file.maxsize": "O ficheiro é demasiado grande", + "error.file.maxwidth": "A largura da imagem não deve exceder {width} píxeis", + "error.file.mime.differs": "O ficheiro enviado precisa de ser do tipo \"{mime}\"", + "error.file.mime.forbidden": "O tipo de mídia \"{mime}\" não é permitido", + "error.file.mime.invalid": "Tipo de mídia inválido: {mime}", + "error.file.mime.missing": "Não foi possível detectar o tipo de mídia de \"{filename}\"", + "error.file.minheight": "A altura da imagem deve ter pelo menos {height} píxeis", + "error.file.minsize": "O ficheiro é demasiado pequeno", + "error.file.minwidth": "A largura da imagem deve ter pelo menos {width} píxeis", + "error.file.name.unique": "O nome do ficheiro deve ser único", + "error.file.name.missing": "O nome do ficheiro não pode ficar em branco", + "error.file.notFound": "Não foi possível encontrar o ficheiro \"{filename}\"", + "error.file.orientation": "A orientação da imagem deve ser \"{orientation}\"", + "error.file.sort.permission": "Não tem permissão para alterar a ordem de \"{filename}\"", + "error.file.type.forbidden": "Não tem permissões para enviar ficheiros {type}", + "error.file.type.invalid": "Tipo de ficheiro inválido: {type}", + "error.file.undefined": "Não foi possível encontrar o ficheiro", + + "error.form.incomplete": "Por favor, corrija todos os erros do formulário…", + "error.form.notSaved": "Não foi possível guardar o formulário", + + "error.language.code": "Por favor, insira um código válido para o idioma", + "error.language.create.permission": "Não tem permissões para criar um idioma", + "error.language.delete.permission": "Não tem permissões para eliminar o idioma", + "error.language.duplicate": "O idioma já existe", + "error.language.name": "Por favor, insira um nome válido para o idioma", + "error.language.notFound": "Não foi possível encontrar o idioma", + "error.language.update.permission": "Não tem permissões para atualizar o idioma", + + "error.layout.validation.block": "Há um erro no campo \"{field}\" no bloco {blockIndex} a usar o tipo de bloco \"{fieldset}\" no layout {layoutIndex}", + "error.layout.validation.settings": "Há um erro na configuração do layout {index}", + + "error.license.domain": "O domínio da licença está em falta", + "error.license.email": "Por favor, insira um endereço de email válido", + "error.license.format": "Por favor, insira um código de licença válido", + "error.license.verification": "Não foi possível verificar a licença", + + "error.login.totp.confirm.invalid": "Código inválido", + "error.login.totp.confirm.missing": "Por favor, insira o código atual", + + "error.object.validation": "Há um erro no campo \"{label}\":\n{message}", + + "error.offline": "O painel encontra-se offline de momento", + + "error.page.changeSlug.permission": "Não tem permissões para alterar o URL de \"{slug}\"", + "error.page.changeSlug.reserved": "O caminho das páginas de nível superior não deve começar com \"{path}\"", + "error.page.changeStatus.incomplete": "A página tem erros e não pode ser publicada", + "error.page.changeStatus.permission": "O estado desta página não pode ser alterado", + "error.page.changeStatus.toDraft.invalid": "A página \"{slug}\" não pode ser convertida para rascunho", + "error.page.changeTemplate.invalid": "O template da página \"{slug}\" não pode ser alterado", + "error.page.changeTemplate.permission": "Não tem permissões para alterar o template de \"{slug}\"", + "error.page.changeTitle.empty": "O título não pode ficar em branco", + "error.page.changeTitle.permission": "Não tem permissões para alterar o título de \"{slug}\"", + "error.page.create.permission": "Não tem permissões para criar \"{slug}\"", + "error.page.delete": "A página \"{slug}\" não pode ser eliminada", + "error.page.delete.confirm": "Por favor, insira o título da página para confirmar", + "error.page.delete.hasChildren": "A página tem subpáginas e não pode ser eliminada", + "error.page.delete.multiple": "Nem todas as páginas puderam ser eliminadas. Experimente cada página restante individualmente para ver o erro específico que impede a sua eliminação.", + "error.page.delete.permission": "Não tem permissões para eliminar \"{slug}\"", + "error.page.draft.duplicate": "Uma página de rascunho com o URL \"{slug}\" já existe", + "error.page.duplicate": "Uma página com o URL \"{slug}\" já existe", + "error.page.duplicate.permission": "Não tem permissões para duplicar \"{slug}\"", + "error.page.move.ancestor": "A página não pode ser movida para dentro dela mesma", + "error.page.move.directory": "A pasta da página não pode ser movida", + "error.page.move.duplicate": "Já existe uma subpágina com o URL \"{slug}\"", + "error.page.move.noSections": "A página \"{parent}\" não pode ser pai de nenhuma página porque não tem secções de páginas na sua blueprint", + "error.page.move.notFound": "A página movida não foi encontrada", + "error.page.move.permission": "Não tem permissões para mover \"{slug}\"", + "error.page.move.template": "O template \"{template}\" não é aceite como subpágina de \"{parent}\"", + "error.page.notFound": "Não foi possível encontrar a página \"{slug}\"", + "error.page.num.invalid": "Por favor, insira um número de ordenação válido. Este número não pode ser negativo.", + "error.page.slug.invalid": "Por favor, insira um caminho de URL válido ", + "error.page.slug.maxlength": "O URL não pode conter mais do que \"{length}\" caracteres", + "error.page.sort.permission": "Não é possível ordenar a página \"{slug}\"", + "error.page.status.invalid": "Por favor, defina um estado de página válido", + "error.page.undefined": "Não foi possível encontrar a página", + "error.page.update.permission": "Não tem permissões para atualizar \"{slug}\"", + + "error.section.files.max.plural": "Não pode adicionar mais do que {max} ficheiros à secção \"{section}\"", + "error.section.files.max.singular": "Não pode adicionar mais do que um ficheiro à secção \"{section}\"", + "error.section.files.min.plural": "A secção \"{section}\" requer no mínimo {min} ficheiros", + "error.section.files.min.singular": "A secção \"{section}\" requer no mínimo um ficheiro", + + "error.section.pages.max.plural": "Não pode adicionar mais do que {max} páginas à secção \"{section}\"", + "error.section.pages.max.singular": "Não pode adicionar mais do que uma página à secção \"{section}\"", + "error.section.pages.min.plural": "A secção \"{section}\" requer no mínimo {min} páginas", + "error.section.pages.min.singular": "A secção \"{section}\" requer no mínimo uma página", + + "error.section.notLoaded": "Não foi possível carregar a secção \"{name}\"", + "error.section.type.invalid": "O tipo de secção \"{type}\" não é válido", + + "error.site.changeTitle.empty": "O título não pode ficar em branco", + "error.site.changeTitle.permission": "Não tem permissões para alterar o título do site", + "error.site.update.permission": "Não tem permissões para atualizar o site", + + "error.structure.validation": "Existe um erro no campo \"{field}\" na linha {index}", + + "error.template.default.notFound": "O template \"default\" não existe", + + "error.unexpected": "Ocorreu um erro inesperado! Ative o modo de debug para obter mais informações: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Não tem permissões para alterar o email do utilizador \"{name}\"", + "error.user.changeLanguage.permission": "Não tem permissões para alterar o idioma do utilizador \"{name}\"", + "error.user.changeName.permission": "Não tem permissões para alterar o nome do utilizador \"{name}\"", + "error.user.changePassword.permission": "Não tem permissões para alterar a palavra-passe do utilizador \"{name}\"", + "error.user.changeRole.lastAdmin": "A função do último administrador não pode ser alterada", + "error.user.changeRole.permission": "Não tem permissões para alterar a função do utilizador \"{name}\"", + "error.user.changeRole.toAdmin": "Não tem permissões para promover utilizadores à função de administrador", + "error.user.create.permission": "Não tem permissões para criar este utilizador", + "error.user.delete": "Não é possível eliminar o utilizador \"{name}\"", + "error.user.delete.lastAdmin": "Não é possível eliminar o último administrador", + "error.user.delete.lastUser": "Não é possível eliminar o último utilizador", + "error.user.delete.permission": "Não tem permissões para eliminar o utilizador \"{name}\"", + "error.user.duplicate": "Já existe um utilizador com o email \"{email}\"", + "error.user.email.invalid": "Por favor, insira um endereço de email válido", + "error.user.language.invalid": "Por favor, insira um idioma válido", + "error.user.notFound": "Não foi possível encontrar o utilizador \"{name}\"", + "error.user.password.excessive": "Por favor, insira uma palavra-passe válida. As palavras-passe não devem ter mais do que 1000 caracteres.", + "error.user.password.invalid": "Por favor, insira uma palavra-passe válida. As palavras-passe devem ter pelo menos 8 caracteres.", + "error.user.password.notSame": "As palavras-passe não coincidem", + "error.user.password.undefined": "O utilizador não tem uma palavra-passe", + "error.user.password.wrong": "Palavra-passe errada", + "error.user.role.invalid": "Por favor, insira uma função válida", + "error.user.undefined": "Não foi possível encontrar o utilizador", + "error.user.update.permission": "Não tem permissões para atualizar o utilizador \"{name}\"", + + "error.validation.accepted": "Por favor, confirme", + "error.validation.alpha": "Por favor, insira apenas caracteres entre a-z", + "error.validation.alphanum": "Por favor, insira apenas caracteres entre a-z ou 0-9", + "error.validation.anchor": "Por favor, insira uma âncora de link correta", + "error.validation.between": "Por favor, insira um valor entre \"{min}\" e \"{max}\"", + "error.validation.boolean": "Por favor, confirme ou rejeite", + "error.validation.color": "Por favor, insira uma cor válida no formato {format}", + "error.validation.contains": "Por favor, insira um valor que contenha \"{needle}\"", + "error.validation.date": "Por favor, insira uma data válida", + "error.validation.date.after": "Por favor, insira uma data posterior a {date}", + "error.validation.date.before": "Por favor, insira uma data anterior a {date}", + "error.validation.date.between": "Por favor, insira uma data entre {min} e {max}", + "error.validation.denied": "Por favor, rejeite", + "error.validation.different": "O valor tem de ser diferente de \"{other}\"", + "error.validation.email": "Por favor, insira um endereço de email válido", + "error.validation.endswith": "O valor tem de terminar com \"{end}\"", + "error.validation.filename": "Por favor, insira um nome de ficheiro válido", + "error.validation.in": "Por favor, insira um dos seguintes valores: ({in})", + "error.validation.integer": "Por favor, insira um número inteiro válido", + "error.validation.ip": "Por favor, insira um endereço de IP válido", + "error.validation.less": "Por favor, insira um valor menor que {max}", + "error.validation.linkType": "O tipo de link não é permitido", + "error.validation.match": "O valor não corresponde ao padrão esperado", + "error.validation.max": "Por favor, insira um valor igual ou menor que {max}", + "error.validation.maxlength": "Por favor, insira um valor mais curto. (máximo {max} caracteres)", + "error.validation.maxwords": "Por favor, não insira mais que {max} palavra(s)", + "error.validation.min": "Por favor, insira um valor igual ou maior que {min}", + "error.validation.minlength": "Por favor, insira um valor mais longo. (mínimo {min} caracteres)", + "error.validation.minwords": "Por favor, insira pelo menos {min} palavra(s)", + "error.validation.more": "Por favor, insira um valor maior que {min}", + "error.validation.notcontains": "Por favor, insira um valor que não contenha \"{needle}\"", + "error.validation.notin": "Por favor, não insira nenhum destes valores: ({notIn})", + "error.validation.option": "Por favor, selecione uma opção válida", + "error.validation.num": "Por favor, insira um número válido", + "error.validation.required": "Por favor, insira algo", + "error.validation.same": "Por favor, insira \"{other}\"", + "error.validation.size": "O tamanho do valor tem de ser \"{size}\"", + "error.validation.startswith": "O valor tem de começar com \"{start}\"", + "error.validation.tel": "Por favor, insira um número de telefone não formatado", + "error.validation.time": "Por favor, insira uma hora válida", + "error.validation.time.after": "Por favor, insira uma hora posterior a {time}", + "error.validation.time.before": "Por favor, insira uma hora anterior a {time}", + "error.validation.time.between": "Por favor, insira uma hora entre {min} e {max}", + "error.validation.uuid": "Por favor, insira um UUID válido", + "error.validation.url": "Por favor, insira um URL válido", + + "expand": "Expandir", + "expand.all": "Expandir todos", + + "field.invalid": "O campo é inválido", + "field.required": "O campo é obrigatório", + "field.blocks.changeType": "Alterar tipo", + "field.blocks.code.name": "Código", + "field.blocks.code.language": "Idioma", + "field.blocks.code.placeholder": "O seu código …", + "field.blocks.delete.confirm": "Tem a certeza que pretende eliminar este bloco?", + "field.blocks.delete.confirm.all": "Tem a certeza que pretende eliminar todos os blocos?", + "field.blocks.delete.confirm.selected": "Tem a certeza que pretende eliminar os blocos selecionados?", + "field.blocks.empty": "Nenhum bloco ainda", + "field.blocks.fieldsets.empty": "Nenhum tipo de bloco ainda", + "field.blocks.fieldsets.label": "Por favor, selecione um tipo de bloco …", + "field.blocks.fieldsets.paste": "Pressione {{ shortcut }} para importar layouts/blocks da sua área de transferência Só serão inseridos aqueles permitidos no campo atual.", + "field.blocks.gallery.name": "Galeria", + "field.blocks.gallery.images.empty": "Nenhuma imagem ainda", + "field.blocks.gallery.images.label": "Imagens", + "field.blocks.heading.level": "Nível ", + "field.blocks.heading.name": "Título ", + "field.blocks.heading.text": "Texto", + "field.blocks.heading.placeholder": "Título …", + "field.blocks.figure.back.plain": "Simples", + "field.blocks.figure.back.pattern.light": "Padrão (claro)", + "field.blocks.figure.back.pattern.dark": "Padrão (escuro)", + "field.blocks.image.alt": "Texto alternativo", + "field.blocks.image.caption": "Legenda", + "field.blocks.image.crop": "Cortar", + "field.blocks.image.link": "Link", + "field.blocks.image.location": "Localização ", + "field.blocks.image.location.internal": "Este website", + "field.blocks.image.location.external": "Fonte externa", + "field.blocks.image.name": "Imagem", + "field.blocks.image.placeholder": "Selecionar uma imagem", + "field.blocks.image.ratio": "Proporção ", + "field.blocks.image.url": "URL da imagem", + "field.blocks.line.name": "Linha", + "field.blocks.list.name": "Lista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Texto", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Citação ", + "field.blocks.quote.text.label": "Texto", + "field.blocks.quote.text.placeholder": "Citação …", + "field.blocks.quote.citation.label": "Citação ", + "field.blocks.quote.citation.placeholder": "de …", + "field.blocks.text.name": "Texto", + "field.blocks.text.placeholder": "Texto …", + "field.blocks.video.autoplay": "Reprodução automática", + "field.blocks.video.caption": "Legenda", + "field.blocks.video.controls": "Controlos", + "field.blocks.video.location": "Localização ", + "field.blocks.video.loop": "Repetir", + "field.blocks.video.muted": "Sem som", + "field.blocks.video.name": "Vídeo ", + "field.blocks.video.placeholder": "Insira um URL de vídeo ", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Pré-carregamento", + "field.blocks.video.url.label": "URL-Vídeo", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Tem a certeza que pretende eliminar todas as entradas?", + "field.entries.empty": "Nenhuma entrada ainda", + + "field.files.empty": "Nenhum ficheiro selecionado ainda", + "field.files.empty.single": "Nenhum ficheiro selecionado ainda", + + "field.layout.change": "Alterar layout", + "field.layout.delete": "Eliminar layout", + "field.layout.delete.confirm": "Tem a certeza que pretende eliminar este layout?", + "field.layout.delete.confirm.all": "Tem a certeza que pretende eliminar todos os layouts?", + "field.layout.empty": "Nenhuma linha ainda", + "field.layout.select": "Selecionar um layout", + + "field.object.empty": "Nenhuma informação ainda", + + "field.pages.empty": "Nenhuma página selecionada ainda", + "field.pages.empty.single": "Nenhuma página selecionada ainda", + + "field.structure.delete.confirm": "Tem a certeza que pretende eliminar esta linha?", + "field.structure.delete.confirm.all": "Tem a certeza que pretende eliminar todas as entradas?", + "field.structure.empty": "Nenhuma entrada ainda", + + "field.users.empty": "Nenhum utilizador selecionado ainda", + "field.users.empty.single": "Nenhum utilizador selecionado ainda", + + "fields.empty": "Nenhum campo ainda", + + "file": "Ficheiro", + "file.blueprint": "Este ficheiro ainda não tem blueprint. Pode configurar o blueprint em /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Alterar template", + "file.changeTemplate.notice": "Alterar o template do ficheiro irá remover o conteúdo dos campos que não correspondem ao mesmo tipo. Se o novo template definir certas regras, por exemplo dimensões de imagem, estas também serão aplicadas irreversivelmente. Use com cuidado.", + "file.delete.confirm": "Tem a certeza que pretende eliminar
{filename}?", + "file.focus.placeholder": "Definir ponto de foco", + "file.focus.reset": "Remover ponto de foco", + "file.focus.title": "Foco", + "file.sort": "Alterar posição", + + "files": "Ficheiros", + "files.delete.confirm.selected": "Tem a certeza que pretende eliminar os ficheiros selecionados? Esta ação não pode ser revertida.", + "files.empty": "Nenhum ficheiro ainda", + + "filter": "Filtro", + + "form.discard": "Reverter alterações", + "form.discard.confirm": "Tem a certeza que pretende reverter todas as suas alterações?", + "form.locked": "Este conteúdo está desativado para si porque encontra-se a ser editado por outro utilizador", + "form.unsaved": "As alterações atuais ainda não foram guardadas", + "form.preview": "Pré-visualizar alterações", + "form.preview.draft": "Pré-visualizar rascunho", + + "hide": "Ocultar", + "hour": "Hora", + "hue": "Tonalidade", + "import": "Importar", + "info": "Info", + "insert": "Inserir", + "insert.after": "Inserir após", + "insert.before": "Inserir antes", + "install": "Instalar", + + "installation": "Instalação", + "installation.completed": "O painel foi instalado com sucesso", + "installation.disabled": "A instalação do painel está desativada em servidores públicos por defeito. Execute a instalação numa máquina local ou ative-a com a opção panel.install.", + "installation.issues.accounts": "A pasta /site/accounts não existe ou não tem permissão de escrita", + "installation.issues.content": "A pasta /content não existe ou não tem permissão de escrita", + "installation.issues.curl": "A extensão CURL é necessária", + "installation.issues.headline": "Não foi possível instalar o painel", + "installation.issues.mbstring": "A extensão MB String é necessária", + "installation.issues.media": "A pasta /media não existe ou não tem permissão de escrita", + "installation.issues.php": "Certifique-se que está a usar o PHP 8+", + "installation.issues.sessions": "A pasta /site/sessions não existe ou não tem permissão de escrita", + + "language": "Idioma", + "language.code": "Código", + "language.convert": "Definir como por defeito", + "language.convert.confirm": "

Tem a certeza que pretende converter {name} para o idioma por defeito? Esta ação não pode ser revertida.

Se {name} tiver conteúdo não traduzido, partes do site podem ficar sem conteúdo.

", + "language.create": "Adicionar um novo idioma", + "language.default": "Idioma por defeito", + "language.delete.confirm": "Tem a certeza que pretende eliminar o idioma {name} incluindo todas as traduções? Esta ação não pode ser revertida!", + "language.deleted": "O idioma foi eliminado", + "language.direction": "Direção de leitura", + "language.direction.ltr": "Esquerda para direita", + "language.direction.rtl": "Direita para esquerda", + "language.locale": "String de localização do PHP", + "language.locale.warning": "Está a usar configurações de localização personalizadas. Corrija as mesmas no ficheiro /site/languages", + "language.name": "Nome", + "language.secondary": "Idioma secundário", + "language.settings": "Configurações de idioma", + "language.updated": "O idioma foi atualizado", + "language.variables": "Variáveis de idioma", + "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", + + "languages": "Idiomas", + "languages.default": "Idioma por defeito", + "languages.empty": "Nenhum idioma ainda", + "languages.secondary": "Idiomas secundários", + "languages.secondary.empty": "Nenhum idioma secundário ainda", + + "license": "Licença ", + "license.activate": "Ative-a agora", + "license.activate.label": "Por favor, ative a sua licença", + "license.activate.domain": "A sua licença será ativada para {host}.", + "license.activate.local": "Está prestes a ativar a sua licença Kirby no domínio local {host}. Se este site vai ser alojado num domínio público, por favor ative-o lá. Se o domínio {host} é o que deseja para usar a sua licença, por favor continue.", + "license.activated": "Ativada", + "license.buy": "Compre uma licença", + "license.code": "Código", + "license.code.help": "Recebeu o seu código de licença por email após a compra. Por favor, copie e cole aqui.", + "license.code.label": "Por favor, insira o código da sua licença", + "license.status.active.info": "Inclui novas versões principais até {date}", + "license.status.active.label": "Licença válida", + "license.status.demo.info": "Esta é uma instalação de demonstração", + "license.status.demo.label": "Demonstração", + "license.status.inactive.info": "Renove a licença para atualizar para novas versões principais", + "license.status.inactive.label": "Nenhuma versão principal nova", + "license.status.legacy.bubble": "Pronto para renovar a sua licença?", + "license.status.legacy.info": "A sua licença não abrange esta versão", + "license.status.legacy.label": "Por favor, renove a sua licença", + "license.status.missing.bubble": "Pronto para lançar o seu site?", + "license.status.missing.info": "Sem licença válida", + "license.status.missing.label": "Por favor, ative a sua licença", + "license.status.unknown.info": "O estado da licença é desconhecido", + "license.status.unknown.label": "Desconhecido", + "license.manage": "Gerir as suas licenças", + "license.purchased": "Comprada", + "license.success": "Obrigado por apoiar o Kirby", + "license.unregistered.label": "Não registada", + + "link": "Link", + "link.text": "Texto do link", + + "loading": "A carregar", + + "lock.unsaved": "Alterações não guardadas", + "lock.unsaved.empty": "Não existem mais alterações não guardadas", + "lock.unsaved.files": "Ficheiros não guardados", + "lock.unsaved.pages": "Páginas não guardadas", + "lock.unsaved.users": "Contas não guardadas", + "lock.isLocked": "Alterações não guardadas de {email}", + "lock.unlock": "Desbloquear", + "lock.unlock.submit": "Desbloqueie e substitua alterações não guardadas de {email}", + "lock.isUnlocked": "Foi desbloqueado por outro utilizador", + + "login": "Entrar", + "login.code.label.login": "Código de início de sessão", + "login.code.label.password-reset": "Código de redefinição de palavra-passe", + "login.code.placeholder.email": "000 0000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Se o seu endereço de email está registado, o código solicitado foi enviado por email.", + "login.code.text.totp": "Por favor, insira o código de segurança da sua aplicação de autenticação.", + "login.email.login.body": "Olá {user.nameOrEmail},\n\nRecentemente solicitou um código de início de sessão para o painel de {site}.\nO seguinte código de início de sessão será válido por {timeout} minutos:\n\n{code}\n\nSe não solicitou um código de início de sessão, por favor ignore este e-mail ou entre em contacto com o administrador se tiver dúvidas.\nPor motivos de segurança, por favor NÃO reencaminhe este e-mail.", + "login.email.login.subject": "O seu código de início de sessão", + "login.email.password-reset.body": "Olá {user.nameOrEmail},\n\nRecentemente solicitou um código de redefinição de palavra-passe para o painel de {site}.\nO seguinte código de redefinição de palavra-passe será válido por {timeout} minutos:\n\n{code}\n\nSe não solicitou um código de redefinição de palavra-passe, por favor ignore este e-mail ou entre em contacto com o administrador se tiver dúvidas.\nPor motivos de segurança, por favor NÃO reencaminhe este e-mail.", + "login.email.password-reset.subject": "O seu código de redefinição de palavra-passe", + "login.remember": "Manter sessão iniciada", + "login.reset": "Redefinir palavra-passe", + "login.toggleText.code.email": "Iniciar sessão com email", + "login.toggleText.code.email-password": "Iniciar sessão com palavra-passe", + "login.toggleText.password-reset.email": "Esqueceu a sua palavra-passe?", + "login.toggleText.password-reset.email-password": "← Voltar ao início de sessão", + "login.totp.enable.option": "Configurar códigos de segurança", + "login.totp.enable.intro": "As aplicações de autenticação podem gerar códigos de segurança que são usados como um segundo fator ao iniciar a sessão na sua conta.", + "login.totp.enable.qr.label": "1. Leia este código QR", + "login.totp.enable.qr.help": "Não consegue ler o código? Adicione a chave de configuração {secret} manualmente à sua aplicação de autenticação.", + "login.totp.enable.confirm.headline": "2. Confirme com o código gerado", + "login.totp.enable.confirm.text": "A sua aplicação gera um novo código de segurança a cada 30 segundos. Insira o código atual para concluir a configuração:", + "login.totp.enable.confirm.label": "Código atual", + "login.totp.enable.confirm.help": "Após esta configuração, iremos solicitar um código de segurança sempre que iniciar a sessão.", + "login.totp.enable.success": "Códigos de segurança ativados", + "login.totp.disable.option": "Desativar códigos de segurança", + "login.totp.disable.label": "Insira a sua palavra-passe para desativar códigos de segurança", + "login.totp.disable.help": "No futuro, um segundo fator diferente, como um código de início de sessão enviado por e-mail, será solicitado quando iniciar a sessão. Poderá configurar códigos de segurança novamente mais tarde.", + "login.totp.disable.admin": "

Isto irá desativar os códigos de segurança para {user}.

No futuro, um segundo fator diferente, como um código de início de sessão enviado por e-mail, será solicitado quando eles iniciarem a sessão. {user} poderá configurar códigos de segurança novamente após o próximo início de sessão.

", + "login.totp.disable.success": "Códigos de segurança desativados", + + "logout": "Sair", + + "merge": "Unir", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipo de Mídia", + "minutes": "Minutos", + + "month": "Mês", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Dezembro", + "months.february": "Fevereiro", + "months.january": "Janeiro", + "months.july": "Julho", + "months.june": "Junho", + "months.march": "Mar\u00e7o", + "months.may": "Maio", + "months.november": "Novembro", + "months.october": "Outubro", + "months.september": "Setembro", + + "more": "Mais", + "move": "Mover", + "name": "Nome", + "next": "Próximo", + "night": "Noite", + "no": "não", + "off": "off", + "on": "on", + "open": "Abrir", + "open.newWindow": "Abrir numa nova janela", + "option": "Opção", + "options": "Opções", + "options.none": "Sem opções", + "options.all": "Mostrar todas as {count} opções", + + "orientation": "Orientação", + "orientation.landscape": "Paisagem", + "orientation.portrait": "Retrato", + "orientation.square": "Quadrado", + + "page": "Página", + "page.blueprint": "Esta página não tem blueprint ainda. Pode configurar o blueprint em /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Alterar URL", + "page.changeSlug.fromTitle": "Criar a partir do t\u00edtulo", + "page.changeStatus": "Alterar estado", + "page.changeStatus.position": "Selecione uma posição", + "page.changeStatus.select": "Selecione um novo estado", + "page.changeTemplate": "Alterar template", + "page.changeTemplate.notice": "Alterar o template da página irá remover o conteúdo dos campos que não correspondem ao mesmo tipo. Use com cuidado.", + "page.create": "Criar como {status}", + "page.delete.confirm": "Tem a certeza que pretende eliminar {title}?", + "page.delete.confirm.subpages": "Esta página tem subpáginas.
Todas as subpáginas serão eliminadas também.", + "page.delete.confirm.title": "Por favor, insira o título da página para confirmar", + "page.duplicate.appendix": "Copiar", + "page.duplicate.files": "Copiar ficheiros", + "page.duplicate.pages": "Copiar páginas", + "page.move": "Mover página", + "page.sort": "Alterar posição", + "page.status": "Estado", + "page.status.draft": "Rascunho", + "page.status.draft.description": "A página está em modo de rascunho e é visível apenas para editores com sessão iniciada ou através de um link secreto", + "page.status.listed": "Pública", + "page.status.listed.description": "A página é pública para todos", + "page.status.unlisted": "Não listada", + "page.status.unlisted.description": "Esta página é acessível apenas através de URL", + + "pages": "Páginas", + "pages.delete.confirm.selected": "Tem a certeza que pretende eliminar as páginas selecionadas? Esta ação não pode ser revertida.", + "pages.empty": "Nenhuma página ainda", + "pages.status.draft": "Rascunhos", + "pages.status.listed": "Publicadas", + "pages.status.unlisted": "Não listadas", + + "pagination.page": "Página", + + "password": "Palavra-passe", + "paste": "Colar", + "paste.after": "Colar após", + "paste.success": "{count} colados!", + "pixel": "Píxel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Anterior", + "preview": "Pré-visualizar", + + "publish": "Publicar", + "published": "Publicadas", + + "remove": "Remover", + "rename": "Alterar nome", + "renew": "Renovar", + "replace": "Substituir", + "replace.with": "Substituir por", + "retry": "Tentar novamente", + "revert": "Reverter", + "revert.confirm": "Tem a certeza que pretende eliminar todas as alterações não guardadas?", + + "role": "Função", + "role.admin.description": "O administrador tem todas as permissões", + "role.admin.title": "Administrador", + "role.all": "Todos", + "role.empty": "Não há utilizadores com esta função", + "role.description.placeholder": "Sem descrição", + "role.nobody.description": "Esta é uma função de recurso sem permissões", + "role.nobody.title": "Ninguém", + + "save": "Guardar", + "saved": "Guardado", + "search": "Pesquisar", + "searching": "À procura", + "search.min": "Insira {min} caracteres para pesquisar", + "search.all": "Mostrar todos os {count} resultados", + "search.results.none": "Sem resultados", + + "section.invalid": "A secção é inválida", + "section.required": "A secção é obrigatória", + + "security": "Segurança", + "select": "Selecionar", + "server": "Servidor", + "settings": "Configurações", + "show": "Mostrar", + "site.blueprint": "O site não tem blueprint ainda. Pode configurar o blueprint em /site/blueprints/site.yml", + "size": "Tamanho", + "slug": "URL", + "sort": "Ordenar", + "sort.drag": "Arraste para ordenar ...", + "split": "Dividir", + + "stats.empty": "Sem relatórios", + "status": "Estado", + + "system.info.copy": "Copiar informação", + "system.info.copied": "Informação de sistema copiada", + "system.issues.content": "A pasta content parece não estar protegida", + "system.issues.eol.kirby": "A versão instalada do Kirby chegou ao fim da sua vida útil e não irá receber mais atualizações de segurança", + "system.issues.eol.plugin": "A versão instalada do plugin { plugin } chegou ao fim da sua vida útil e não irá receber mais atualizações de segurança", + "system.issues.eol.php": "A versão instalada { release } de PHP chegou ao fim da sua vida útil e não irá receber mais atualizações de segurança", + "system.issues.debug": "O modo debug deve ser desativado em produção", + "system.issues.git": "A pasta .git parece não estar protegida", + "system.issues.https": "Nós recomendamos HTTPS para todos os seus sites", + "system.issues.kirby": "A pasta kirby parece não estar protegida", + "system.issues.local": "O site está a correr localmente com verificações de segurança relaxadas", + "system.issues.site": "A pasta site parece não estar protegida", + "system.issues.vue.compiler": "O compilador de templates Vue está ativado", + "system.issues.vulnerability.kirby": "A sua instalação poderá ser afetada pela seguinte vulnerabilidade ({ severity } gravidade): { description }", + "system.issues.vulnerability.plugin": "A sua instalação poderá ser afetada pela seguinte vulnerabilidade no plugin { plugin } ({ severity } gravidade): { description }", + "system.updateStatus": "Atualizar estado", + "system.updateStatus.error": "Não foi possível verificar se havia atualizações", + "system.updateStatus.not-vulnerable": "Nenhuma vulnerabilidade conhecida", + "system.updateStatus.security-update": "Atualização de segurança gratuita { version } disponível", + "system.updateStatus.security-upgrade": "Atualização { version } com correções de segurança disponível", + "system.updateStatus.unreleased": "Versão não lançada", + "system.updateStatus.up-to-date": "Atualizado", + "system.updateStatus.update": "Atualização gratuita { version } disponível", + "system.updateStatus.upgrade": "Atualização { version } disponível", + + "tel": "Telefone", + "tel.placeholder": "+351912345678", + "template": "Template", + + "theme": "Tema", + "theme.light": "Luzes ligadas", + "theme.dark": "Luzes desligadas", + "theme.automatic": "Ajustar ao tema do sistema", + + "title": "Título", + "today": "Hoje", + + "toolbar.button.clear": "Limpar formatação", + "toolbar.button.code": "Código", + "toolbar.button.bold": "Negrito", + "toolbar.button.email": "Email", + "toolbar.button.headings": "Títulos", + "toolbar.button.heading.1": "Título 1", + "toolbar.button.heading.2": "Título 2", + "toolbar.button.heading.3": "Título 3", + "toolbar.button.heading.4": "Título 4", + "toolbar.button.heading.5": "Título 5", + "toolbar.button.heading.6": "Título 6", + "toolbar.button.italic": "Itálico", + "toolbar.button.file": "Ficheiro", + "toolbar.button.file.select": "Selecione um ficheiro", + "toolbar.button.file.upload": "Envie um ficheiro", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Parágrafo", + "toolbar.button.strike": "Rasurado", + "toolbar.button.sub": "Subscrito", + "toolbar.button.sup": "Sobrescrito", + "toolbar.button.ol": "Lista ordenada", + "toolbar.button.underline": "Sublinhado", + "toolbar.button.ul": "Lista não-ordenada", + + "translation.author": "Equipa Kirby", + "translation.direction": "ltr", + "translation.name": "Português (Portugal)", + "translation.locale": "pt_PT", + + "type": "Tipo", + + "upload": "Enviar", + "upload.error.cantMove": "Não foi possível mover o ficheiro enviado", + "upload.error.cantWrite": "Não foi possível guardar o ficheiro em disco", + "upload.error.default": "Não foi possível enviar o ficheiro", + "upload.error.extension": "O envio do ficheiro foi interrompido devido à extensão", + "upload.error.formSize": "O ficheiro enviado excede a diretiva MAX_FILE_SIZE especificada no formulário", + "upload.error.iniPostSize": "O ficheiro enviado excede a diretiva post_max_size do php.ini", + "upload.error.iniSize": "O ficheiro enviado excede a diretiva upload_max_filesize do php.ini", + "upload.error.noFile": "Nenhum ficheiro foi enviado", + "upload.error.noFiles": "Nenhum ficheiro foi enviado", + "upload.error.partial": "O ficheiro foi enviado apenas parcialmente", + "upload.error.tmpDir": "Pasta temporária em falta", + "upload.errors": "Erro", + "upload.progress": "A enviar…", + + "url": "URL", + "url.placeholder": "https://exemplo.pt", + + "user": "Utilizador", + "user.blueprint": "Pode definir secções adicionais e campos de formulário para esta função de utilizador em /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Alterar email", + "user.changeLanguage": "Alterar idioma", + "user.changeName": "Alterar o nome deste utilizador", + "user.changePassword": "Alterar palavra-passe", + "user.changePassword.current": "A sua palavra-passe atual", + "user.changePassword.new": "Nova palavra-passe", + "user.changePassword.new.confirm": "Confirme a nova palavra-passe…", + "user.changeRole": "Alterar função", + "user.changeRole.select": "Selecione uma nova função", + "user.create": "Adicionar um novo utilizador", + "user.delete": "Eliminar este utilizador", + "user.delete.confirm": "Tem a certeza que pretende eliminar
{email}?", + + "users": "Utilizadores", + + "version": "Versão", + "version.changes": "Versão alterada", + "version.compare": "Comparar versões", + "version.current": "Versão atual", + "version.latest": "Versão mais recente", + "versionInformation": "Informação da versão", + + "view": "Visualizar", + "view.account": "A sua conta", + "view.installation": "Instala\u00e7\u00e3o", + "view.languages": "Idiomas", + "view.resetPassword": "Redefinir palavra-passe", + "view.site": "Site", + "view.system": "Sistema", + "view.users": "Utilizadores", + + "welcome": "Bem-vindo", + "year": "Ano", + "yes": "sim" +} diff --git a/public/kirby/i18n/translations/ro.json b/public/kirby/i18n/translations/ro.json new file mode 100644 index 0000000..af099b1 --- /dev/null +++ b/public/kirby/i18n/translations/ro.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Schimbă-ți numele", + "account.delete": "Șterge-ți contul", + "account.delete.confirm": "Chiar vrei să îți ștergi contul? Vei fi deconectat imediat. Contul nu poate fi recuperat.", + + "activate": "Activează", + "add": "Adaug\u0103", + "alpha": "Alfa", + "author": "Autor", + "avatar": "Imagine de profil", + "back": "Înapoi", + "cancel": "Anulează", + "change": "Modific\u0103", + "close": "\u00cenchide", + "changes": "Schimbări", + "confirm": "Ok", + "collapse": "Pliază", + "collapse.all": "Pliază toate", + "color": "Culoare", + "coordinates": "Coordonate", + "copy": "Copiază", + "copy.all": "Copiază toate", + "copy.success": "Copiat", + "copy.success.multiple": "Copiat {count}!", + "copy.url": "Copiază URL", + "create": "Creează", + "custom": "Personalizat", + + "date": "Data", + "date.select": "Alege o dată", + + "day": "Ziua", + "days.fri": "Vin", + "days.mon": "Lun", + "days.sat": "S\u00e2m", + "days.sun": "Dum", + "days.thu": "Joi", + "days.tue": "Mar", + "days.wed": "Mie", + + "debugging": "Depanare", + + "delete": "\u0218terge", + "delete.all": "Șterge toate", + + "dialog.fields.empty": "Acest dialog nu are niciun câmp", + "dialog.files.empty": "Nu există fișiere de selectat", + "dialog.pages.empty": "Nu există pagini de selectat", + "dialog.text.empty": "Acest dialog nu definește niciun text", + "dialog.users.empty": "Nu există utilizatori de selectat", + + "dimensions": "Dimensiuni", + "disable": "Dezactivați", + "disabled": "Dezactivat", + "discard": "Renun\u021b\u0103", + + "drawer.fields.empty": "Acest sertar nu are niciun câmp", + + "domain": "Domeniu", + "download": "Descarcă", + "duplicate": "Duplică", + + "edit": "Editeaz\u0103", + + "email": "E-mail", + "email.placeholder": "email@exemplu.com", + + "enter": "Introdu", + "entries": "Întregistrări", + "entry": "Înregistrare", + + "environment": "Mediu", + + "error": "Eroare", + "error.access.code": "Cod nevalid", + "error.access.login": "Conectare nevalidă", + "error.access.panel": "Nu ai voie să accesezi panoul", + "error.access.view": "Nu ai voie să accesezi această parte a panoului", + + "error.avatar.create.fail": "Imaginea de profil nu a putut fi încărcată", + "error.avatar.delete.fail": "Imaginea de profil nu a putut fi ștearsă", + "error.avatar.dimensions.invalid": "Păstrează te rog lățimea și înălțimea imaginii de profil sub 3000 de pixeli", + "error.avatar.mime.forbidden": "Imaginea de profil trebuie să fie un fișier JPEG sau PNG", + + "error.blueprint.notFound": "Blueprint-ul \"{name}\" nu a putut fi încărcat", + + "error.blocks.max.plural": "Nu poți adăuga mai mult de {max} blocuri", + "error.blocks.max.singular": "Nu poți adăuga mai mult de un bloc", + "error.blocks.min.plural": "Trebuie să adaugi cel puțin {min} blocuri", + "error.blocks.min.singular": "Trebuie să adaugi cel puțin un bloc", + "error.blocks.validation": "Există o eroare la câmpul \"{field}\" în blocul {index} care folosește tipul de bloc \"{fieldset}\"", + + "error.cache.type.invalid": "Tipul de cache \"{type}\" este nevalid", + + "error.content.lock.delete": "Versiunea este blocată și nu poate fi ștearsă", + "error.content.lock.move": "Versiunea sursă este blocată și nu poate fi mutată", + "error.content.lock.publish": "Această versiune este deja publicată", + "error.content.lock.replace": "Versiunea este blocată și nu poate fi înlocuită", + "error.content.lock.update": "Versiunea este blocată și nu poate fi actualizată", + + "error.entries.max.plural": "Nu sunt permise mai mult de {max} înregistrări", + "error.entries.max.singular": "Nu este permisă mai mult de o înregistrare", + "error.entries.min.plural": "Sunt necesare cel puțin {min} înregistrări", + "error.entries.min.singular": "Este necesară cel puțin o înregistrare", + "error.entries.supports": "Tipul de câmp \"{type}\" nu este disponibil în cadrul câmpului Înregistrări", + "error.entries.validation": "Există o eroare la câmpul \"{field}\" pe rândul {index}", + + "error.email.preset.notFound": "Preset-ul de e-mail \"{name}\" nu a fost găsit", + + "error.field.converter.invalid": "Convertorul \"{converter}\" nu este valid", + "error.field.link.options": "Opțiuni invalide: {options}", + "error.field.type.missing": "Câmpul \"{ name }\": Tipul de câmp \"{ type }\" nu există", + + "error.file.changeName.empty": "Numele nu trebuie să fie gol", + "error.file.changeName.permission": "Nu ai voie să schimbi numele fișierului \"{filename}\"", + "error.file.changeTemplate.invalid": "Șablonul pentru fișierul \"{id}\" nu poate fi schimbat la \"{template}\" (valide: \"{blueprints}\")", + "error.file.changeTemplate.permission": "Nu ai voie să schimbi șablonul pentru fișierul \"{id}\"", + + "error.file.delete.multiple": "Nu toate fișierele s-au putut șterge. Încearcă, pe rând, fiecare fișier rămas pentru a vedea eroarea specifică ce-i împiedică ștergerea.", + "error.file.duplicate": "Există deja un fișier cu numele \"{filename}\"", + "error.file.extension.forbidden": "Extensia de fișier \"{extension}\" nu este permisă", + "error.file.extension.invalid": "Extensie de fișier nevalidă: {extension}", + "error.file.extension.missing": "Extensia de fișier pentru \"{filename}\" lipsește", + "error.file.maxheight": "Înălțimea imaginii nu poate depăși {height} pixeli", + "error.file.maxsize": "Fișierul este prea mare", + "error.file.maxwidth": "Lățimea imaginii nu poate depăși {width} pixeli", + "error.file.mime.differs": "Fișierul încărcat trebuie să aibă același tip mime \"{mime}\"", + "error.file.mime.forbidden": "Tipul media \"{mime}\" nu este permis", + "error.file.mime.invalid": "Tip mime nevalid: {mime}", + "error.file.mime.missing": "Tipul media pentru \"{filename}\" nu poate fi detectat", + "error.file.minheight": "Imaginea trebuie să aibă înălțimea de minim {height} pixeli", + "error.file.minsize": "Fișierul este prea mic", + "error.file.minwidth": "Imaginea trebuie să aibă lățimea de minim {width} pixeli", + "error.file.name.unique": "Numele fișierului trebuie să fie unic", + "error.file.name.missing": "Numele fișierului nu poate fi gol", + "error.file.notFound": "Fișierul \"{filename}\" nu a fost găsit", + "error.file.orientation": "Orientarea imaginii trebuie să fie \"{orientation}\"", + "error.file.sort.permission": "Nu ai voie să schimbi ordinea la \"{filename}\"", + "error.file.type.forbidden": "Nu ai voie să încarci fișiere {type}", + "error.file.type.invalid": "Tip nevalid de fișier: {type}", + "error.file.undefined": "Fișierul nu a fost găsit", + + "error.form.incomplete": "Te rog repară toate erorile din formular…", + "error.form.notSaved": "Formularul nu a putut fi salvat", + + "error.language.code": "Te rog introdu un cod valid pentru limbă", + "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": "Limba există deja", + "error.language.name": "Te rog introdu un nume valid pentru limbă", + "error.language.notFound": "Limba nu a fost găsită", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Există o eroare la câmpul \"{field}\" în blocul {blockIndex} care utilizează tipul de bloc \"{fieldset}\" în aranjamentul {layoutIndex}", + "error.layout.validation.settings": "Există o eroare la setările aranjamentului {index}", + + "error.license.domain": "Domeniul pentru licență lipsește", + "error.license.email": "Te rog introdu o adresă de e-mail validă", + "error.license.format": "Te rog introdu un cod de licență valid", + "error.license.verification": "Licența nu a putut fi verificată", + + "error.login.totp.confirm.invalid": "Cod nevalid", + "error.login.totp.confirm.missing": "Vă rugăm să introduceți codul curent", + + "error.object.validation": "Există o eroare la câmpul \"{label}\":\n{message}", + + "error.offline": "Panoul este momentan offline", + + "error.page.changeSlug.permission": "Nu ai voie să schimbi apendicele URL pentru \"{slug}\"", + "error.page.changeSlug.reserved": "Calea paginilor de la primul nivel nu poate să înceapă cu \"{path}\"", + "error.page.changeStatus.incomplete": "Pagina are erori și nu poate fi publicată", + "error.page.changeStatus.permission": "Starea acestei pagini nu poate fi schimbată", + "error.page.changeStatus.toDraft.invalid": "Pagina \"{slug}\" nu poate fi schimbată în ciornă", + "error.page.changeTemplate.invalid": "Șablonul paginii \"{slug}\" nu poate fi schimbat", + "error.page.changeTemplate.permission": "Nu ai voie să schimbi șablonul pentru \"{slug}\"", + "error.page.changeTitle.empty": "Titlul nu poate rămâne gol", + "error.page.changeTitle.permission": "Nu ai voie să schimbi titlul pentru \"{slug}\"", + "error.page.create.permission": "Nu ai voie să creezi \"{slug}\"", + "error.page.delete": "Pagina \"{slug}\" nu poate fi ștearsă", + "error.page.delete.confirm": "Te rog introdu titlul paginii pentru a confirma", + "error.page.delete.hasChildren": "Pagina are subpagini și nu poate fi ștearsă", + "error.page.delete.multiple": "Nu toate paginile au putut fi șterse. Încearcă, pe rând, fiecare pagină rămasă pentru a vedea eroarea specifică ce-i împiedică ștergerea.", + "error.page.delete.permission": "Nu ai voie să ștergi \"{slug}\"", + "error.page.draft.duplicate": "Există deja o ciornă cu apendicele URL \"{slug}\"", + "error.page.duplicate": "Există deja o pagină cu apendicele URL \"{slug}\"", + "error.page.duplicate.permission": "Nu ai voie să duplici \"{slug}\"", + "error.page.move.ancestor": "Pagina nu poate fi mutată în ea însăși", + "error.page.move.directory": "Directorul de pagini nu poate fi mutat", + "error.page.move.duplicate": "Există deja o sub-pagină cu apendicele URL \"{slug}\"", + "error.page.move.noSections": "Pagina \"{parent}\" nu poate fi părinte niciunei pagini fiindcă nu are în șablon vreo secțiune de pagini.", + "error.page.move.notFound": "Pagina mutată nu a fost găsită", + "error.page.move.permission": "Nu ai voie să muți \"{slug}\"", + "error.page.move.template": "Șablonul \"{template}\" nu este acceptat ca sub-pagină a \"{parent}\"", + "error.page.notFound": "Pagina \"{slug}\" nu a fost găsită", + "error.page.num.invalid": "Te rog introdu un număr de sortare valid. Numerele nu pot fi negative.", + "error.page.slug.invalid": "Te rog introdu un apendice URL valid", + "error.page.slug.maxlength": "Lungimea slug-ului nu poate depăși \"{length}\"", + "error.page.sort.permission": "Pagina \"{slug}\" nu poate fi sortată", + "error.page.status.invalid": "Te rog stabilește o stare validă pentru pagină", + "error.page.undefined": "Pagina nu a fost găsită", + "error.page.update.permission": "Nu ai voie să actualizezi \"{slug}\"", + + "error.section.files.max.plural": "Nu poți avea mai mult de {max} fișiere în secțiunea \"{section}\"", + "error.section.files.max.singular": "Nu poți avea mai mult de un fișier în secțiunea \"{section}\"", + "error.section.files.min.plural": "Secțiunea \"{section}\" are nevoie de cel puțin {min} fișiere", + "error.section.files.min.singular": "Secțiunea \"{section}\" are nevoie de cel puțin un fișier", + + "error.section.pages.max.plural": "Nu poți avea mai mult de {max} pagini în secțiunea \"{section}\"", + "error.section.pages.max.singular": "Nu poți avea mai mult de o pagină în secțiunea \"{section}\"", + "error.section.pages.min.plural": "Secțiunea \"{section}\" are nevoie de cel puțin {min} pagini", + "error.section.pages.min.singular": "Secțiunea \"{section}\" are nevoie de cel puțin o pagină", + + "error.section.notLoaded": "Secțiunea \"{name}\" nu a putut fi încărcată", + "error.section.type.invalid": "Tipul de secțiune \"{type}\" nu este valid", + + "error.site.changeTitle.empty": "Titlul nu poate să rămână gol", + "error.site.changeTitle.permission": "Nu ai voie să schimbi titlul site-ului", + "error.site.update.permission": "Nu ai voie să actualizezi site-ul", + + "error.structure.validation": "Există o eroare la câmpul \"{field}\" pe rândul {index}", + + "error.template.default.notFound": "Șablonul implicit nu există", + + "error.unexpected": "S-a produs o eroare neașteptată! Activează modul depanare pentru mai multe informații: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Nu ai voie să schimbi adresa de e-mail a utilizatorului \"{name}\"", + "error.user.changeLanguage.permission": "Nu ai voie să schimbi limba utilizatorului \"{name}\"", + "error.user.changeName.permission": "Nu ai voie să schimbi numele utilizatorului \"{name}\"", + "error.user.changePassword.permission": "Nu ai voie să schimbi parola utilizatorului \"{name}\"", + "error.user.changeRole.lastAdmin": "Rolul ultimului administrator nu poate fi schimbat", + "error.user.changeRole.permission": "Nu ai voie să schimbi rolul utilizatorului \"{name}\"", + "error.user.changeRole.toAdmin": "Nu ai voie să promovezi un utilizator la rolul de administrator", + "error.user.create.permission": "Nu ai voie să creezi acest utilizator", + "error.user.delete": "Utilizatorul \"{name}\" nu poate fi șters", + "error.user.delete.lastAdmin": "Ultimul administrator nu poate fi șters", + "error.user.delete.lastUser": "Ultimul utilizator nu poate fi șters", + "error.user.delete.permission": "Nu ai voie să ștergi utilizatorul \"{name}\"", + "error.user.duplicate": "Există deja un utilizator cu adresa e-mail \"{email}\"", + "error.user.email.invalid": "Te rog introdu o adresă de e-mail validă", + "error.user.language.invalid": "Te rog introdu o limbă validă", + "error.user.notFound": "Utilizatorul \"{name}\" nu a fost găsit", + "error.user.password.excessive": "Te rog introdu o parolă validă. Parolele nu pot fi mai lungi de 1000 de caractere.", + "error.user.password.invalid": "Te rog introdu o parolă validă. Parola trebuie să aibă cel puțin 8 caractere.", + "error.user.password.notSame": "Parolele nu se potrivesc", + "error.user.password.undefined": "Utilizatorul nu are parolă", + "error.user.password.wrong": "Parolă greșită", + "error.user.role.invalid": "Te rog introdu un rol valid", + "error.user.undefined": "Utilizatorul nu a fost găsit", + "error.user.update.permission": "Nu ai voie să actualizezi utilizatorul \"{name}\"", + + "error.validation.accepted": "Te rog confirmă", + "error.validation.alpha": "Te rog introdu doar caractere din intervalul a-z", + "error.validation.alphanum": "Te rog introdu doar caractere din intervalul a-z sau cifre 0-9", + "error.validation.anchor": "Te rog introdu o ancoră corectă pentru legătură", + "error.validation.between": "Te rog introdu o valoare între \"{min}\" și \"{max}\"", + "error.validation.boolean": "Te rog confirmă sau refuză", + "error.validation.color": "Te rog introdu o culoare validă în formatul {format}", + "error.validation.contains": "Te rog introdu o valoare care conține \"{needle}\"", + "error.validation.date": "Te rog introdu o dată validă", + "error.validation.date.after": "Te rog introdu o dată după {date}", + "error.validation.date.before": "Te rog introdu o dată dinainte de {date}", + "error.validation.date.between": "Te rog introdu o dată între {min} și {max}", + "error.validation.denied": "Te rog refuză", + "error.validation.different": "Valoarea nu poate fi \"{other}\"", + "error.validation.email": "Te rog introdu o adresă de e-mail validă", + "error.validation.endswith": "Valoarea nu se poate termina cu \"{end}\"", + "error.validation.filename": "Te rog introdu un nume valid de fișier", + "error.validation.in": "Te rog introdu una dintre următoarele: ({in})", + "error.validation.integer": "Te rog introdu un număr întreg valid", + "error.validation.ip": "Te rog introdu o adresă IP validă", + "error.validation.less": "Te rog introdu o valoare mai mică decât {max}", + "error.validation.linkType": "Tipul de legătură nu este permis", + "error.validation.match": "Valoarea nu se potrivește cu forma așteptată", + "error.validation.max": "Te rog introdu o valoare mai mică sau egală cu {max}", + "error.validation.maxlength": "Te rog introdu o valoare mai scurtă. (max. {max} caractere)", + "error.validation.maxwords": "Te rog nu introduce mai mult de {max} cuvinte.", + "error.validation.min": "Te rog introdu o valoare mai mare sau egală cu {min}", + "error.validation.minlength": "Te rog introdu o valoare mai lungă. (min. {min} caractere)", + "error.validation.minwords": "Te rog introdu cel puțin {min} cuvinte", + "error.validation.more": "Te rog introdu o valoare mai mare decât {min}", + "error.validation.notcontains": "Te rog introdu o valoare care să nu conțină \"{needle}\"", + "error.validation.notin": "Te rog nu introduce niciuna dintre următoarele: ({notIn})", + "error.validation.option": "Te rog alege o opțiune validă", + "error.validation.num": "Te rog introdu un număr valid", + "error.validation.required": "Te rog introdu ceva", + "error.validation.same": "Te rog introdu \"{other}\"", + "error.validation.size": "Dimensiunea valorii trebuie să fie \"{size}\"", + "error.validation.startswith": "Valoarea trebuie să înceapă cu \"{start}\"", + "error.validation.tel": "Te rog introdu un număr de telefon neformatat", + "error.validation.time": "Te rog introdu un timp valid", + "error.validation.time.after": "Te rog introdu un timp după {time}", + "error.validation.time.before": "Te rog introdu un timp înainte de {time}", + "error.validation.time.between": "Te rog introdu un timp între {min} și {max}", + "error.validation.uuid": "Te rog introdu un UUID valid", + "error.validation.url": "Te rog introdu un URL valid", + + "expand": "Extinde", + "expand.all": "Extinde toate", + + "field.invalid": "Câmpul este nevalid", + "field.required": "Acest câmp este necesar", + "field.blocks.changeType": "Schimbă tipul", + "field.blocks.code.name": "Cod", + "field.blocks.code.language": "Limba", + "field.blocks.code.placeholder": "Codul tău …", + "field.blocks.delete.confirm": "Chiar vrei să ștergi acest bloc?", + "field.blocks.delete.confirm.all": "Chiar vrei să ștergi toate blocurile?", + "field.blocks.delete.confirm.selected": "Chiar vrei să ștergi blocurile selectate?", + "field.blocks.empty": "Niciun bloc deocamdată", + "field.blocks.fieldsets.empty": "Niciun set de câmpuri încă", + "field.blocks.fieldsets.label": "Te rog alege un tip de bloc …", + "field.blocks.fieldsets.paste": "Apasă {{ shortcut }} pentru a importa aranjamente/blocuri din clipboard Doar cele permise pentru câmpul curent vor fi inserate.", + "field.blocks.gallery.name": "Galerie", + "field.blocks.gallery.images.empty": "Nicio imagine deocamdată", + "field.blocks.gallery.images.label": "Imagini", + "field.blocks.heading.level": "Nivel", + "field.blocks.heading.name": "Subtitlu", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Subtitlu …", + "field.blocks.figure.back.plain": "Simplu", + "field.blocks.figure.back.pattern.light": "Model (deschis)", + "field.blocks.figure.back.pattern.dark": "Model (închis)", + "field.blocks.image.alt": "Text alternativ", + "field.blocks.image.caption": "Etichetă", + "field.blocks.image.crop": "Decupaj", + "field.blocks.image.link": "Legătură", + "field.blocks.image.location": "Localizare", + "field.blocks.image.location.internal": "Acest website", + "field.blocks.image.location.external": "Sursă externă", + "field.blocks.image.name": "Imagine", + "field.blocks.image.placeholder": "Alege o imagine", + "field.blocks.image.ratio": "Raport", + "field.blocks.image.url": "URL-ul imaginii", + "field.blocks.line.name": "Linie", + "field.blocks.list.name": "Listă", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Citat", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Citat …", + "field.blocks.quote.citation.label": "Citare", + "field.blocks.quote.citation.placeholder": "de …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoredare", + "field.blocks.video.caption": "Etichetă", + "field.blocks.video.controls": "Controale", + "field.blocks.video.location": "Localizare", + "field.blocks.video.loop": "În buclă", + "field.blocks.video.muted": "Fără sonor", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Introdu URL-ul video-ului", + "field.blocks.video.poster": "Poster", + "field.blocks.video.preload": "Preîncarcă", + "field.blocks.video.url.label": "URL-ul video-ului", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Chiar vrei să ștergi toate înregistrările?", + "field.entries.empty": "Nicio înregistrare deocamdată", + + "field.files.empty": "Niciun fișier selectat deocamdată", + "field.files.empty.single": "Niciun fișier selectat încă", + + "field.layout.change": "Schimbă aranjament", + "field.layout.delete": "Șterge aranjamentul", + "field.layout.delete.confirm": "Chiar vrei să ștergi acest aranjament?", + "field.layout.delete.confirm.all": "Chiar vrei să ștergi toate aranjamentele?", + "field.layout.empty": "Niciun rând deocamdată", + "field.layout.select": "Alege un aranjament", + + "field.object.empty": "Nicio informație deocamdată", + + "field.pages.empty": "Nicio pagină aleasă deocamdată", + "field.pages.empty.single": "Nicio pagină selectată încă", + + "field.structure.delete.confirm": "Chiar vrei să ștergi acest rând?", + "field.structure.delete.confirm.all": "Chiar vrei să ștergi toate înregistrările?", + "field.structure.empty": "Nicio înregistrare deocamdată", + + "field.users.empty": "Niciun utilizator ales deocamdată", + "field.users.empty.single": "Niciun utilizator selectat încă", + + "fields.empty": "Niciun câmp deocamdată", + + "file": "Fișier", + "file.blueprint": "Acest fișier nu are încă un Blueprint. Poți să-l definești în /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Schimbă șablonul", + "file.changeTemplate.notice": "Schimbarea șablonului fișierului va înlătura conținutul câmpurilor care nu se potrivesc ca tip. Dacă noul șablon definește anumite reguli, de ex. dimensiuni de imagini, acestea vor fi de asemenea aplicate în mod ireversibil. Folosiți cu prudență.", + "file.delete.confirm": "Chiar vrei să ștergi
{filename}?", + "file.focus.placeholder": "Stabilește punct focal", + "file.focus.reset": "Înlătură punct focal", + "file.focus.title": "Focalizare", + "file.sort": "Schimbă poziția", + + "files": "Fișiere", + "files.delete.confirm.selected": "Chiar vrei să ștergi fișierele selectate? Această acțiune este ireversibilă.", + "files.empty": "Niciun fișier deocamdată", + + "filter": "Filtru", + + "form.discard": "Renunță la schimbări", + "form.discard.confirm": "Chiar vrei să renunți la toate schimbările tale?", + "form.locked": "Acest conținut îți este dezactivat fiindcă îl editează alt utilizator momentan", + "form.unsaved": "Schimbările curente nu au fost încă salvate", + "form.preview": "Previzualizează schimbări", + "form.preview.draft": "Previzualizează ciornă", + + "hide": "Ascunde", + "hour": "Ora", + "hue": "Nuanță", + "import": "Importă", + "info": "Informații", + "insert": "Inserează", + "insert.after": "Inserează după", + "insert.before": "Inserează înainte", + "install": "Instalează", + + "installation": "Instalare", + "installation.completed": "Panoul a fost instalat", + "installation.disabled": "Instalarea panoului este dezactivată în mod implicit pe servere publice. Te rog rulează instalarea pe o mașină locală sau activează-l cu opțiunea panel.install.", + "installation.issues.accounts": "Directorul /site/accounts nu există sau nu are permisiuni de scriere.", + "installation.issues.content": "Directorul /content nu există sau nu are permisiuni de scriere.", + "installation.issues.curl": "Extensia CURL este necesară", + "installation.issues.headline": "Panoul nu poate fi instalat", + "installation.issues.mbstring": "Extensia MB String este necesară", + "installation.issues.media": "Directorul /media nu există sau nu are permisiuni de scriere", + "installation.issues.php": "Asigură-te că folosești PHP 8+", + "installation.issues.sessions": "Directorul /site/sessions folder nu există sau nu are permisiuni de scriere", + + "language": "Limba", + "language.code": "Cod", + "language.convert": "Stabilește ca implicit", + "language.convert.confirm": "

Chiar vrei să transformi {name} în limba implicită? Această modificare este ireversibilă.

Dacă {name} are conținut netradus, unele părți din site s-ar putea să nu mai aibă conținut de rezervă, și vor apărea goale.

", + "language.create": "Adaugă o limbă nouă", + "language.default": "Limba implicită", + "language.delete.confirm": "Chiar vrei să ștergi limba {name}, inclusiv toate traducerile? Această operațiune este ireversibilă.", + "language.deleted": "Limba a fost ștearsă", + "language.direction": "Direcția de citire", + "language.direction.ltr": "De la stânga la dreapta", + "language.direction.rtl": "De la dreapta la stânga", + "language.locale": "String-ul PHP locale", + "language.locale.warning": "Folosești pentru localizare o formulă manuală. Modificările le poți face în fișierul de limbă în /site/languages", + "language.name": "Nume", + "language.secondary": "Limbă secundară", + "language.settings": "Reglaje limbă", + "language.updated": "Limba a fost actualizată", + "language.variables": "Variabile limbă", + "language.variables.empty": "Nicio traducere deocamdată", + + "language.variable.delete.confirm": "Chiar vrei să ștergi variabila pentru {key}?", + "language.variable.entries": "Valori", + "language.variable.entries.help": "Fiecare string va fi folosit pentru numărul aferent; de exemplu, trei stringuri vor fi folosite pentru numerele 0, 1, 2 sau mai mare. Folosește placeholderul {count} pentru a insera numărul propriu-zis.", + "language.variable.key": "Cheie", + "language.variable.multiple": "Numărabil?", + "language.variable.multiple.text": "Folosește stringuri de traducere diferite", + "language.variable.multiple.help": "Poți folosi valori diferite în funcție de numărul pe care îl transmiți împreună cu variabila de limbă, ceea ce îți permite să creezi traduceri dinamice, de ex. pentru singular și plural.", + "language.variable.notFound": "Variabila nu a fost găsită", + "language.variable.value": "Valoare", + + "languages": "Limbi", + "languages.default": "Limba implicită", + "languages.empty": "Nu există limbi deocamdată", + "languages.secondary": "Limbi secundare", + "languages.secondary.empty": "Nu există limbi secundare deocamdată.", + + "license": "Licența", + "license.activate": "Activați acum", + "license.activate.label": "Vă rugăm să activați licența", + "license.activate.domain": "Licența va fi activată pentru {host}.", + "license.activate.local": "Sunteți pe cale să activați licența Kirby pentru domeniul local {host}. Dacă acest site va fi implementat pe un domeniu public, vă rugăm să o activați acolo în schimb. Dacă {host} este domeniul pentru care doriți să utilizați licența, vă rugăm să continuați.", + "license.activated": "Activată", + "license.buy": "Cumpără o licență", + "license.code": "Cod", + "license.code.help": "Ați primit codul de licență după achiziție prin e-mail. Vă rugăm să-l copiați și să-l inserezi aici.", + "license.code.label": "Te rog introdu codul tău de licență", + "license.status.active.info": "Include noi versiuni majore până la data de {date}", + "license.status.active.label": "Licență validă", + "license.status.demo.info": "Aceasta este o instalare demo", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Reînnoiți licența pentru a actualiza la noile versiuni majore", + "license.status.inactive.label": "Fără noi versiuni majore", + "license.status.legacy.bubble": "Sunteți pregătit să reînnoiți licența?", + "license.status.legacy.info": "Licența dvs. nu acoperă această versiune", + "license.status.legacy.label": "Vă rugăm să reînnoiți licența", + "license.status.missing.bubble": "Sunteți pregătit să lansați site-ul?", + "license.status.missing.info": "Licență nevalidă", + "license.status.missing.label": "Vă rugăm să activați licența", + "license.status.unknown.info": "Statutul licenței este necunoscut", + "license.status.unknown.label": "Necunoscut", + "license.manage": "Gestionează-ți licențele", + "license.purchased": "Achiziționat", + "license.success": "Mulțumim că susții Kirby", + "license.unregistered.label": "Neînregistrat", + + "link": "Legătură", + "link.text": "Textul legăturii", + + "loading": "Se încarcă", + + "lock.unsaved": "Schimbări nesalvate", + "lock.unsaved.empty": "Nu mai există nicio schimbare nesalvată", + "lock.unsaved.files": "Fișiere nesalvate", + "lock.unsaved.pages": "Pagini nesalvate", + "lock.unsaved.users": "Conturi nesalvate", + "lock.isLocked": "Schimbări nesalvate de {email}", + "lock.unlock": "Deblochează", + "lock.unlock.submit": "Deblochează și suprascrie schimbările nesalvate de {email}", + "lock.isUnlocked": "A fost deblocată de alt utilizator", + + "login": "Conectează-te", + "login.code.label.login": "Cod de conectare", + "login.code.label.password-reset": "Cod de restabilire parolă", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Dacă adresa de e-mail este înregistrată, codul cerut a fost trimis pe adresă.", + "login.code.text.totp": "Vă rugăm să introduceți codul unic de pe aplicația dvs. de autentificare.", + "login.email.login.body": "Salut {user.nameOrEmail},\n\nAi cerut recent un cod de conectare pentru Panoul site-ului {site}.\nCodul de conectare de mai jos va fi valid pentru următoarele {timeout} minute:\n\n{code}\n\nDacă nu tu ai cerut un cod de conectare, te rog ignoră acest e-mail sau ia legătura cu administratorul site-ului dacă ai întrebări.\nDin motive de siguranță, te rog să NU trimiți acest email mai departe.", + "login.email.login.subject": "Codul tău de conectare", + "login.email.password-reset.body": "Salut {user.nameOrEmail},\n\nAi cerut recent un cod de restabilire a parolei pentru Panoul site-ului {site}.\nCodul de restabilire a parolei de mai jos este valabil pentru următoarele {timeout} minute:\n\n{code}\n\nDacă nu tu ai cerut codul de restabilire a parolei, te rog ignoră acest e-mail sau ia legătura cu administratorul site-ului dacă ai întrebări.\nDin motive de securitate, te rog să NU trimiți acest e-mail mai departe.", + "login.email.password-reset.subject": "Codul tău de restabilire a parolei", + "login.remember": "Ține-mă conectat", + "login.reset": "Restabilește parola", + "login.toggleText.code.email": "Conectare prin e-mail", + "login.toggleText.code.email-password": "Conectare cu parola", + "login.toggleText.password-reset.email": "Ți-ai uitat parola?", + "login.toggleText.password-reset.email-password": "← Înapoi la conectare", + "login.totp.enable.option": "Configurați codurile de unică folosință", + "login.totp.enable.intro": "Aplicațiile de autentificare pot genera coduri de unică folosință utilizate ca al doilea factor la autentificarea în contul dvs.", + "login.totp.enable.qr.label": "1. Scanați acest cod QR", + "login.totp.enable.qr.help": "Nu puteți scana? Adăugați manual cheia de configurare {secret} în aplicația dvs. de autentificare.", + "login.totp.enable.confirm.headline": "2. Confirmați cu codul generat", + "login.totp.enable.confirm.text": "Aplicația dvs. generează un nou cod de unică folosință la fiecare 30 de secunde. Introduceți codul curent pentru a finaliza configurarea:", + "login.totp.enable.confirm.label": "Cod curent", + "login.totp.enable.confirm.help": "După această configurare, vă vom solicita un cod de unică folosință de fiecare dată când vă autentificați.", + "login.totp.enable.success": "Codurile de unică folosință activate", + "login.totp.disable.option": "Dezactivați codurile de unică folosință", + "login.totp.disable.label": "Introduceți parola pentru a dezactiva codurile de unică folosință", + "login.totp.disable.help": "În viitor, va fi solicitat un al doilea factor diferit, cum ar fi un cod de autentificare trimis prin e-mail, atunci când vă autentificați. Puteți configura din nou codurile de unică folosință oricând mai târziu.", + "login.totp.disable.admin": "

Această acțiune va dezactiva codurile de unică folosință pentru {user}.

În viitor, se va solicita un al doilea factor diferit, cum ar fi un cod de autentificare trimis prin e-mail, atunci când se autentifică. {user} poate configura din nou codurile de unică folosință după următoarea autentificare.", + "login.totp.disable.success": "Codurile de unică folosință dezactivate", + + "logout": "Deconectare", + + "merge": "Îmbină", + "menu": "Meniu", + "meridiem": "AM/PM", + "mime": "Tipul media", + "minutes": "Minute", + + "month": "Luna", + "months.april": "Aprilie", + "months.august": "August", + "months.december": "Decembrie", + "months.february": "Februarie", + "months.january": "Ianuarie", + "months.july": "Iulie", + "months.june": "Iunie", + "months.march": "Martie", + "months.may": "Mai", + "months.november": "Noiembrie", + "months.october": "Octombrie", + "months.september": "Septembrie", + + "more": "Mai multe", + "move": "Mută", + "name": "Nume", + "next": "Următoarea", + "night": "Noapte", + "no": "nu", + "off": "oprit", + "on": "pornit", + "open": "Deschide", + "open.newWindow": "Deschide în fereastră nouă", + "option": "Opțiune", + "options": "Opțiuni", + "options.none": "Nicio opțiune", + "options.all": "Afișați toate {count} opțiunile", + + "orientation": "Orientare", + "orientation.landscape": "Landscape", + "orientation.portrait": "Portrait", + "orientation.square": "Pătrată", + + "page": "Pagină", + "page.blueprint": "Această pagină nu are încă un Blueprint. Poți să-l definești în /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Schimbă URL-ul", + "page.changeSlug.fromTitle": "Creează din titlu", + "page.changeStatus": "Schimbă starea", + "page.changeStatus.position": "Te rog alege o poziție", + "page.changeStatus.select": "Alege o stare nouă", + "page.changeTemplate": "Schimbă șablonul", + "page.changeTemplate.notice": "Schimbarea șablonului paginii va înlătura conținutul câmpurilor care nu se potrivesc ca tip. Folosește cu prudență.", + "page.create": "Creează ca {status}", + "page.delete.confirm": "Chiar vrei să ștergi {title}?", + "page.delete.confirm.subpages": "Această pagină are subpagini.
Subpaginile vor fi de asemenea toate șterse.", + "page.delete.confirm.title": "Introdu titlul paginii pentru a confirma", + "page.duplicate.appendix": "Copiază", + "page.duplicate.files": "Copiază fișierele", + "page.duplicate.pages": "Copiază paginile", + "page.move": "Mută pagina", + "page.sort": "Schimbă poziția", + "page.status": "Stare", + "page.status.draft": "Ciornă", + "page.status.draft.description": "Pagina este în modul ciornă și va fi vizibilă doar editorilor conectați sau printr-un link secret", + "page.status.listed": "Publică", + "page.status.listed.description": "Pagina este publică, accesibilă oricui", + "page.status.unlisted": "Nelistată", + "page.status.unlisted.description": "Pagina este accesibilă doar prin URL", + + "pages": "Pagini", + "pages.delete.confirm.selected": "Chiar vrei să ștergi paginile selectate? Această acțiune este ireversibilă.", + "pages.empty": "Nicio pagină deocamdată", + "pages.status.draft": "Ciorne", + "pages.status.listed": "Publicate", + "pages.status.unlisted": "Nelistate", + + "pagination.page": "Pagină", + + "password": "Parola", + "paste": "Inserează", + "paste.after": "Inserează după", + "paste.success": "inserate {count}!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugin-uri", + "prev": "Precedenta", + "preview": "Previzualizează", + + "publish": "Publică", + "published": "Publicate", + + "remove": "Înlătură", + "rename": "Redenumește", + "renew": "Reînnoiți", + "replace": "\u00cenlocuie\u0219te", + "replace.with": "Înlocuiește cu", + "retry": "Încearcă din nou", + "revert": "Renunță", + "revert.confirm": "Chiar vrei să ștergi toate schimbările nesalvate?", + + "role": "Rol", + "role.admin.description": "Administratorul are toate drepturile", + "role.admin.title": "Administrator", + "role.all": "Toate", + "role.empty": "Nu există niciun utilizator cu acest rol", + "role.description.placeholder": "Nicio descriere", + "role.nobody.description": "Acesta este un rol de rezervă fără nicio permisiune.", + "role.nobody.title": "Nimeni", + + "save": "Salveaz\u0103", + "saved": "Salvat", + "search": "Caută", + "searching": "Se caută", + "search.min": "Introdu {min} caractere pentru a căuta", + "search.all": "Afișați toate {count} rezultatele", + "search.results.none": "Niciun rezultat", + + "section.invalid": "Secțiunea este nevalidă", + "section.required": "Această secțiune este necesară", + + "security": "Securitate", + "select": "Alege", + "server": "Server", + "settings": "Reglaje", + "show": "Arată", + "site.blueprint": "Site-ul nu are încă un Blueprint. Poți să-l definești în /site/blueprints/site.yml", + "size": "Dimensiune", + "slug": "Apendicele URL", + "sort": "Sortare", + "sort.drag": "Trage pt. a sorta …", + "split": "Împarte", + + "stats.empty": "Niciun raport", + "status": "Stare", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "system.issues.content": "Directorul de conținut pare să fie expus", + "system.issues.eol.kirby": "Versiunea instalată de Kirby a ajuns la sfârșitul vieții utile și nu va mai primi actualizări de securitate.", + "system.issues.eol.plugin": "Versiunea instalată a plugin-ului { plugin } a ajuns la sfârșitul vieții utile și nu va mai primi actualizări de securitate.", + "system.issues.eol.php": "Versiunea PHP instalată { release } a ajuns la sfârșitul vieții și nu va mai primi actualizări de securitate", + "system.issues.debug": "Modul depanare trebuie să fie oprit în producție", + "system.issues.git": "Directorul .git pare să fie expus", + "system.issues.https": "Recomandăm HTTPS pentru toate site-urile.", + "system.issues.kirby": "Directorul Kirby pare să fie expus", + "system.issues.local": "Acest site rulează local cu verificări de siguranță mai laxe.", + "system.issues.site": "Directorul site pare să fie expus", + "system.issues.vue.compiler": "Compilatorul de șabloane Vue este activat", + "system.issues.vulnerability.kirby": "Instalarea ta ar putea fi afectată de următoarea vulnerabilitate ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Instalarea ta ar putea fi afectată de următoarea vulnerabilitate în plugin-ul { plugin } ({ severity } severity): { description }", + "system.updateStatus": "Starea actualizării", + "system.updateStatus.error": "Nu am putut căuta actualizări", + "system.updateStatus.not-vulnerable": "Nicio vulnerabilitate cunoscută", + "system.updateStatus.security-update": "Actualizare gratuită de securitate { version } disponibilă", + "system.updateStatus.security-upgrade": "Actualizarea { version } cu reparații de securitate disponibilă", + "system.updateStatus.unreleased": "Versiune nelansată", + "system.updateStatus.up-to-date": "La zi", + "system.updateStatus.update": "Actualizare gratuită { version } disponibilă", + "system.updateStatus.upgrade": "Actualizare { version } disponibilă", + + "tel": "Telefon", + "tel.placeholder": "+40123456789", + "template": "Șablon", + + "theme": "Temă", + "theme.light": "Lumina pornită", + "theme.dark": "Lumina stinsă", + "theme.automatic": "Aceeași cu sistemul", + + "title": "Titlu", + "today": "Astăzi", + + "toolbar.button.clear": "Elimină formatarea", + "toolbar.button.code": "Cod", + "toolbar.button.bold": "Bold", + "toolbar.button.email": "Adresă e-mail", + "toolbar.button.headings": "Subtitluri", + "toolbar.button.heading.1": "Subtitlu 1", + "toolbar.button.heading.2": "Subtitlu 2", + "toolbar.button.heading.3": "Subtitlu 3", + "toolbar.button.heading.4": "Subtitlu 4", + "toolbar.button.heading.5": "Subtitlu 5", + "toolbar.button.heading.6": "Subtitlu 6", + "toolbar.button.italic": "Italic", + "toolbar.button.file": "Fișier", + "toolbar.button.file.select": "Alege un fișier", + "toolbar.button.file.upload": "Încarcă un fișier", + "toolbar.button.link": "Link", + "toolbar.button.paragraph": "Paragraf", + "toolbar.button.strike": "Tăiat", + "toolbar.button.sub": "Indice", + "toolbar.button.sup": "Exponent", + "toolbar.button.ol": "Listă ordonată", + "toolbar.button.underline": "Subliniat", + "toolbar.button.ul": "Listă cu puncte", + + "translation.author": "Echipa Kirby", + "translation.direction": "ltr", + "translation.name": "Rom\u00e2n\u0103", + "translation.locale": "ro_RO", + + "type": "Tip", + + "upload": "Încarcă", + "upload.error.cantMove": "Fișierul încărcat nu a putut fi mutat", + "upload.error.cantWrite": "Nu s-a putut scrie fișierul pe disc", + "upload.error.default": "Fișierul nu a putut fi încărcat", + "upload.error.extension": "Încărcarea fișierelor oprită de extensie", + "upload.error.formSize": "Fișierul încărcat depășește directiva MAX_FILE_SIZE specificată în formular", + "upload.error.iniPostSize": "Fișierul încărcat depășește directiva post_max_size din php.ini", + "upload.error.iniSize": "Fișierul încărcat depășește directiva upload_max_filesize din php.ini", + "upload.error.noFile": "Nu a fost încărcat niciun fișier", + "upload.error.noFiles": "Nu au fost încărcate fișiere", + "upload.error.partial": "Fișierul a fost încărcat doar parțial", + "upload.error.tmpDir": "Lipsește un director temporar", + "upload.errors": "Eroare", + "upload.progress": "Se încarcă...", + + "url": "Url", + "url.placeholder": "https://example.com", + + "user": "Utilizator", + "user.blueprint": "Poți defini secțiuni și câmpuri de formular suplimentare pentru acest rol de utilizator în /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Schimbă adresa de e-mail", + "user.changeLanguage": "Schimbă limba", + "user.changeName": "Redenumește acest utilizator", + "user.changePassword": "Schimbă parola", + "user.changePassword.current": "Parola ta curentă", + "user.changePassword.new": "Parola nouă", + "user.changePassword.new.confirm": "Confirmă parola nouă...", + "user.changeRole": "Schimbă rolul", + "user.changeRole.select": "Alege un rol nou", + "user.create": "Adaugă un nou utilizator", + "user.delete": "Șterge acest utilizator", + "user.delete.confirm": "Chiar vrei să ștergi
{email}?", + + "users": "Utilizatori", + + "version": "Versiune", + "version.changes": "Versiune schimbată", + "version.compare": "Compară versiuni", + "version.current": "Versiunea curentă", + "version.latest": "Ultima versiune", + "versionInformation": "Informații despre versiune", + + "view": "Vezi", + "view.account": "Contul t\u0103u", + "view.installation": "Instalare", + "view.languages": "Limbi", + "view.resetPassword": "Restabilește parola", + "view.site": "Site", + "view.system": "Sistem", + "view.users": "Utilizatori", + + "welcome": "Bun venit", + "year": "Anul", + "yes": "da" +} diff --git a/public/kirby/i18n/translations/ru.json b/public/kirby/i18n/translations/ru.json new file mode 100644 index 0000000..b943793 --- /dev/null +++ b/public/kirby/i18n/translations/ru.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Изменить имя", + "account.delete": "Удалить пользователя", + "account.delete.confirm": "Вы действительно хотите удалить свой аккаунт? Вы сразу покинете панель управления, а аккаунт нельзя будет восстановить.", + + "activate": "Активировать", + "add": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c", + "alpha": "Альфа", + "author": "Автор", + "avatar": "\u0410\u0432\u0430\u0442\u0430\u0440 (\u0444\u043e\u0442\u043e)", + "back": "Назад", + "cancel": "\u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c", + "change": "\u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c", + "close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c", + "changes": "Изменения", + "confirm": "Ок", + "collapse": "Свернуть", + "collapse.all": "Свернуть все", + "color": "Цвет", + "coordinates": "Координаты", + "copy": "Скопировать", + "copy.all": "Копировать все", + "copy.success": "{count} скопировано", + "copy.success.multiple": "{count} скопировано", + "copy.url": "Скопировать ссылку", + "create": "Создать", + "custom": "Другое", + + "date": "Дата", + "date.select": "Выберите дату", + + "day": "День", + "days.fri": "\u041f\u0442", + "days.mon": "\u041f\u043d", + "days.sat": "\u0421\u0431", + "days.sun": "\u0412\u0441", + "days.thu": "\u0427\u0442", + "days.tue": "\u0412\u0442", + "days.wed": "\u0421\u0440", + + "debugging": "Отладка", + + "delete": "\u0423\u0434\u0430\u043b\u0438\u0442\u044c", + "delete.all": "Удалить все", + + "dialog.fields.empty": "Для этого окна нет полей", + "dialog.files.empty": "Нет файлов для выбора", + "dialog.pages.empty": "Нет страниц для выбора", + "dialog.text.empty": "Окно не содержит никакого текста", + "dialog.users.empty": "Нет пользователей для выбора", + + "dimensions": "Размеры", + "disable": "Отключить", + "disabled": "Отключено", + "discard": "\u0421\u0431\u0440\u043e\u0441", + + "drawer.fields.empty": "Нет полей", + + "domain": "Домен", + "download": "Скачать", + "duplicate": "Дублировать", + + "edit": "\u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c", + + "email": "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": "\u0410\u0432\u0430\u0442\u0430\u0440 (\u0444\u043e\u0442\u043e) \u043a \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0443 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u0443\u0434\u0430\u043b\u0435\u043d", + "error.avatar.dimensions.invalid": "Пожалуйста, сделайте чтобы ширина или высота фотографии была меньше 3000 пикселей", + "error.avatar.mime.forbidden": "Фотография профиля должна быть JPEG или PNG", + + "error.blueprint.notFound": "Не удалось загрузить разметку \"{name}\"", + + "error.blocks.max.plural": "Вы не можете добавить больше {max} блоков", + "error.blocks.max.singular": "Вы не можете добавить больше одного блока", + "error.blocks.min.plural": "Вы должны добавить хотя бы {min} блоков", + "error.blocks.min.singular": "Вы должны добавить хотя бы один блок", + "error.blocks.validation": "Ошибка в поле \"{field}\" в блоке {index} типа \"{fieldset}\"", + + "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": "Вы должны добавить не более одной записи", + "error.entries.min.plural": "Вы должны добавить не менее {min} записей", + "error.entries.min.singular": "Вы должны добавить хотя бы одну запись", + "error.entries.supports": "\"{type}\" тип поля не поддерживается для поля записи", + "error.entries.validation": "Ошибка в поле \"{field}\" в строке {index}", + + "error.email.preset.notFound": "Email-шаблон \"{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": "Расширение файла \"{extension}\" неразрешено", + "error.file.extension.invalid": "Неверное разрешение: {extension}", + "error.file.extension.missing": "Файлу \"{filename}\" не хватает расширения", + "error.file.maxheight": "Высота изображения не должна превышать {height} px", + "error.file.maxsize": "Файл слишком большой", + "error.file.maxwidth": "Ширина изображения не должна превышать {width} px", + "error.file.mime.differs": "Загружаемый файл должен иметь такое же расширение (тип): \"{mime}\"", + "error.file.mime.forbidden": "Расширение (тип) \"{mime}\" не допускается", + "error.file.mime.invalid": "Неверное расширение (тип): {mime}", + "error.file.mime.missing": "Не удалось определить тип медиа для файла \"{filename}\"", + "error.file.minheight": "Высота файла должна быть хотя бы {height} px", + "error.file.minsize": "Файл слишком маленький", + "error.file.minwidth": "Ширина файла должна быть хотя бы {width} px", + "error.file.name.unique": "Название файла должно быть уникальным", + "error.file.name.missing": "Название файла не может быть пустым", + "error.file.notFound": "\u0424\u0430\u0439\u043b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d", + "error.file.orientation": "Ориентация изображения должна быть \"{orientation}\"", + "error.file.sort.permission": "Вам не разрешается изменять сортировку \"{filename}\"", + "error.file.type.forbidden": "У вас нет права загружать файлы {type}", + "error.file.type.invalid": "Неверный тип файла: {type}", + "error.file.undefined": "\u0424\u0430\u0439\u043b \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d", + + "error.form.incomplete": "Пожалуйста, исправьте все ошибки в форме", + "error.form.notSaved": "Форма не может быть сохранена", + + "error.language.code": "Пожалуйста, впишите правильный код языка", + "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": "Язык уже есть", + "error.language.name": "Пожалуйста, впишите правильное название языка", + "error.language.notFound": "Не получилось найти этот язык", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "Ошибка в поле \"{field}\" в блоке {blockIndex} типа \"{fieldset}\" внутри разметки {layoutIndex}", + "error.layout.validation.settings": "Ошибка в настройках макета {index}", + + "error.license.domain": "Лицензия на этот домен отсутствует", + "error.license.email": "Пожалуйста, введите правильный Email", + "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": "\u0412\u044b \u043d\u0435 \u043c\u043e\u0436\u0435\u0442\u0435 \u0438\u0437\u043c\u0435\u043d\u0438\u0442\u044c URL \u044d\u0442\u043e\u0439 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b", + "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": "Черновик страницы с URL \"{slug}\" уже есть", + "error.page.duplicate": "Страница с URL \"{slug}\" уже есть", + "error.page.duplicate.permission": "У вас нет права дублировать \"{slug}\"", + "error.page.move.ancestor": "Невозможно переместить страницу саму в себя", + "error.page.move.directory": "Невозможно перенести каталог страницы", + "error.page.move.duplicate": "Подстраница с URL \"{slug}\" уже существует", + "error.page.move.noSections": "Страница \"{parent}\" не может быть родительской для какой-либо страницы, поскольку в ее разметке отсутствуют какие-либо разделы страниц", + "error.page.move.notFound": "Перемещенная страница не найдена", + "error.page.move.permission": "У вас нет права переместить \"{slug}\"", + "error.page.move.template": "Шаблон \"{template}\" не разрешен для подстраниц \"{parent}\"", + "error.page.notFound": "\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430", + "error.page.num.invalid": "Пожалуйста, впишите правильное число сортировки. Число не может быть отрицательным.", + "error.page.slug.invalid": "Пожалуйста, введите правильный URL", + "error.page.slug.maxlength": "Длина ссылки должна быть короче \"{length}\" символов", + "error.page.sort.permission": "Невозможно сортировать страницу \"{slug}\"", + "error.page.status.invalid": "Пожалуйста, установите верный статус страницы", + "error.page.undefined": "\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u0430", + "error.page.update.permission": "У вас нет права обновить \"{slug}\"", + + "error.section.files.max.plural": "Нельзя добавить больше чем {max} файлов в секции \"{section}\"", + "error.section.files.max.singular": "Можно добавить не больше 1 файла в секции \"{section}\"", + "error.section.files.min.plural": "Секция \"{section}\" требует хотя бы {min} файлов", + "error.section.files.min.singular": "Секция \"{section}\" требует хотя бы 1 файл", + + "error.section.pages.max.plural": "Можно добавить не больше {max} страниц в секции \"{section}\"", + "error.section.pages.max.singular": "Нельзя добавить больше чем 1 страницу в секции \"{section}\"", + "error.section.pages.min.plural": "Секция \"{section}\" требует хотя бы {min} страниц", + "error.section.pages.min.singular": "Секция \"{section}\" требует хотя бы одну страницу", + + "error.section.notLoaded": "Секция \"{name}\" не может быть загружена", + "error.section.type.invalid": "Тип секции {type} неверный", + + "error.site.changeTitle.empty": "Название не может быть пустым", + "error.site.changeTitle.permission": "У вас нет права изменять название сайта", + "error.site.update.permission": "У вас нет права обновить сайт", + + "error.structure.validation": "Ошибка в поле \"{field}\" в строке {index}", + + "error.template.default.notFound": "Нет шаблона по умолчанию", + + "error.unexpected": "Произошла непредвиденная ошибка! Включите режим отладки для получения дополнительной информации: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "У вас нет права изменять Email пользователя \"{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": "\u0410\u043a\u043a\u0430\u0443\u043d\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u0443\u0434\u0430\u043b\u0435\u043d", + "error.user.delete.lastAdmin": "\u0412\u044b \u043d\u0435 \u043c\u043e\u0436\u0435\u0442\u0435 \u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0435\u0434\u0438\u043d\u0441\u0442\u0432\u0435\u043d\u043d\u043e\u0433\u043e \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430", + "error.user.delete.lastUser": "Нельзя удалить единственного пользователя", + "error.user.delete.permission": "У вас нет права удалить пользователя \"{name}\"", + "error.user.duplicate": "Пользователь с Email \"{email}\" уже есть", + "error.user.email.invalid": "Пожалуйста, введите правильный Email", + "error.user.language.invalid": "Введите правильный язык", + "error.user.notFound": "Пользователь \"{name}\" не найден", + "error.user.password.excessive": "Пожалуйста, введите верный пароль. Длина паролей не должна превышать 1000 символов.", + "error.user.password.invalid": "Пожалуйста, введите правильный пароль. Он должен состоять минимум из 8 символов.", + "error.user.password.notSame": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c", + "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": "Пожалуйста, введите только буквы a-z", + "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": "Пожалуйста, впишите значение, которое содержит \"{needle}\"", + "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": "Пожалуйста, введите правильный Email", + "error.validation.endswith": "Значение должно заканчиваться с \"{end}\"", + "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} слов ", + "error.validation.min": "Пожалуйста, введите значение равное или больше чем {min}", + "error.validation.minlength": "Пожалуйста, введите значение длиннее (мин. {min} символов)", + "error.validation.minwords": "Пожалуйста, введите хотя бы {min} слов", + "error.validation.more": "Пожалуйста, введите значение больше, чем {min}", + "error.validation.notcontains": "Пожалуйста, введите значение, которое не содержит \"{needle}\"", + "error.validation.notin": "Пожалуйста, не вписывайте одно из: ({notIn})", + "error.validation.option": "Пожалуйста, выберите правильную опцию ", + "error.validation.num": "Пожалуйста, введите правильный номер", + "error.validation.required": "Пожалуйста, введите что-нибудь", + "error.validation.same": "Пожалуйста, введите \"{other}\"", + "error.validation.size": "Значение размера должно быть \"{size}\"", + "error.validation.startswith": "Значение должно начинаться с \"{start}\"", + "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": "Пожалуйста, введите правильный 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": "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": "Вы точно хотите удалить эту запись?", + "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": "Вы точно хотите удалить файл
{filename}?", + "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": "\u0412\u0441\u0442\u0430\u0432\u0438\u0442\u044c", + "insert.after": "Вставить ниже", + "insert.before": "Вставить выше", + "install": "Установить", + + "installation": "Установка", + "installation.completed": "Панель установлена", + "installation.disabled": "Установка панели по умолчанию отключена на общедоступных серверах. Пожалуйста запустите установку на локальном сервере или включите такую возможность с помощью опции panel.install", + "installation.issues.accounts": "Каталог /site/accounts не существует или не имеет прав записи", + "installation.issues.content": "Каталог /content не существует или не имеет прав записи", + "installation.issues.curl": "Расширение CURL необходимо", + "installation.issues.headline": "Не удалось установить панель", + "installation.issues.mbstring": "Расширение MB String необходимо", + "installation.issues.media": "Каталог /media не существует или нет прав записи", + "installation.issues.php": "Убедитесь, что используется PHP 8+", + "installation.issues.sessions": "Каталог /site/sessions не существует или нет прав записи", + + "language": "\u042f\u0437\u044b\u043a", + "language.code": "Код", + "language.convert": "Установить по умолчанию", + "language.convert.confirm": "

Вы точно хотите конвертировать {name} в главный язык? Это нельзя будет отменить.

Если {name} имеет непереведенный контент, то больше не будет верного каскада и части вашего сайта могут быть пустыми.

", + "language.create": "Добавить новый язык", + "language.default": "Главный язык", + "language.delete.confirm": "Вы точно хотите удалить {name} язык, включая все переводы? Это нельзя будет вернуть.", + "language.deleted": "Язык удален", + "language.direction": "Направление чтения", + "language.direction.ltr": "Слева направо", + "language.direction.rtl": "Справа налево", + "language.locale": "PHP locale string", + "language.locale.warning": "Вы используете кастомную локаль. Пожалуйста измените ее в файле языка в /site/languages", + "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": "Лицензия", + "license.activate": "Активировать сейчас", + "license.activate.label": "Пожалуйста, активируйте Вашу лицензию", + "license.activate.domain": "Ваша лицензия будет активирована на {host}.", + "license.activate.local": "Вы собираетесь активировать лицензию на локальный домен {host}. Если этот сайт будет размещен на общедоступном домене, то, пожалуйста, укажите его вместо {host}.", + "license.activated": "Активировано", + "license.buy": "Купить лицензию", + "license.code": "Код", + "license.code.help": "Вставьте код лицензии, который вы получили Email после покупки.", + "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": "Нет действительной лицензии", + "license.status.missing.label": "Пожалуйста, активируйте Вашу лицензию", + "license.status.unknown.info": "Статус лицензии неизвестен", + "license.status.unknown.label": "Неизвестно", + "license.manage": "Управление лицензиями", + "license.purchased": "Приобретено", + "license.success": "Спасибо за поддержку Kirby", + "license.unregistered.label": "Не зарегистрировано", + + "link": "\u0421\u0441\u044b\u043b\u043a\u0430", + "link.text": "\u0422\u0435\u043a\u0441\u0442 \u0441\u0441\u044b\u043b\u043a\u0438", + + "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": "Если ваш Email уже зарегистрирован, запрашиваемый код был отправлен на него.", + "login.code.text.totp": "Пожалуйста, введите одноразовый пароль из вашего приложения-аутентификатора.", + "login.email.login.body": "{code} — код для входа на сайт {site}. Код действителен {timeout} минут.\n\nЗдравствуйте, {user.nameOrEmail}!\n\nЕсли вы не запрашивали код для входа, проигнорируйте это письмо или обратитесь к администратору, если у вас есть вопросы.\nВ целях безопасности НЕ ПЕРЕСЫЛАЙТЕ это письмо.", + "login.email.login.subject": "Ваш код для входа", + "login.email.password-reset.body": "{code} — код для сброса пароля на сайт «{site}». Код действителен {timeout} минут.\n\nЗдравствуйте, {user.nameOrEmail}!\n\nЕсли вы не запрашивали сброс пароля, проигнорируйте это письмо или обратитесь к администратору, если у вас есть вопросы.\nВ целях безопасности НЕ ПЕРЕСЫЛАЙТЕ это письмо.", + "login.email.password-reset.subject": "Ваш код для сброса пароля", + "login.remember": "Запомнить пароль", + "login.reset": "Сбросить пароль", + "login.toggleText.code.email": "Вход с помощью 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} вручную в ваше приложение для проверки подлинности.", + "login.totp.enable.confirm.headline": "2. Подтвердите с помощью генерированного кода", + "login.totp.enable.confirm.text": "Ваше приложение генерирует новый одноразовый код каждые 30 секунд. Введите текущий код для завершения настройки:", + "login.totp.enable.confirm.label": "Текущий код", + "login.totp.enable.confirm.help": "После этой настройки мы будем запрашивать у вас одноразовый код при каждом входе.", + "login.totp.enable.success": "Одноразовые коды включены", + "login.totp.disable.option": "Отключить одноразовые коды", + "login.totp.disable.label": "Введите ваш пароль для отключения одноразовых паролей", + "login.totp.disable.help": "Теперь при входе в систему будет запрашиваться второй фактор, например, код для входа, отправленный по Email. Вы всегда можете повторно настроить одноразовые коды позже.", + "login.totp.disable.admin": "

Вы отключаете одноразовые коды для{user}.

Теперь при входе в систему будет запрашиваться другой второй фактор, например код для входа, отправленный по Email. {user} может повторно настроить одноразовые коды после следующего входа в систему.

", + "login.totp.disable.success": "Одноразовые коды выключены", + + "logout": "Выйти", + + "merge": "Объединить", + "menu": "Меню", + "meridiem": "До полудня / После полудня", + "mime": "Тип медиа", + "minutes": "Минуты", + + "month": "Месяц", + "months.april": "\u0410\u043f\u0440\u0435\u043b\u044c", + "months.august": "\u0410\u0432\u0433\u0443\u0441\u0442", + "months.december": "\u0414\u0435\u043a\u0430\u0431\u0440\u044c", + "months.february": "Февраль", + "months.january": "\u042f\u043d\u0432\u0430\u0440\u044c", + "months.july": "\u0418\u044e\u043b\u044c", + "months.june": "\u0418\u044e\u043d\u044c", + "months.march": "\u041c\u0430\u0440\u0442", + "months.may": "\u041c\u0430\u0439", + "months.november": "\u041d\u043e\u044f\u0431\u0440\u044c", + "months.october": "\u041e\u043a\u0442\u044f\u0431\u0440\u044c", + "months.september": "\u0421\u0435\u043d\u0442\u044f\u0431\u0440\u044c", + + "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": "Изменить ссылку", + "page.changeSlug.fromTitle": "Создать из названия", + "page.changeStatus": "Изменить статус", + "page.changeStatus.position": "Пожалуйста, выберите позицию", + "page.changeStatus.select": "Выбрать новый статус", + "page.changeTemplate": "Изменить шаблон", + "page.changeTemplate.notice": "Изменение шаблона страницы приведет к удалению содержимого полей, которые не совпадут по типу. Используйте с осторожностью.", + "page.create": "Создать как {status}", + "page.delete.confirm": "Вы точно хотите удалить страницу {title}?", + "page.delete.confirm.subpages": "У этой страницы есть внутренние страницы.
Все внутренние страницы так же будут удалены.", + "page.delete.confirm.title": "Напишите название страницы, чтобы подтвердить", + "page.duplicate.appendix": "(копия)", + "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": "Страница доступна только по URL", + + "pages": "Страницы", + "pages.delete.confirm.selected": "Вы действительно хотите удалить выбранные страницы? Это действие невозможно отменить.", + "pages.empty": "Страниц нет", + "pages.status.draft": "Черновики", + "pages.status.listed": "Опубликовано", + "pages.status.unlisted": "Скрытая", + + "pagination.page": "Страница", + + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "paste": "Вставить", + "paste.after": "Вставить после", + "paste.success": "{count} вставлено", + "pixel": "Пиксель", + "plugin": "Расширение", + "plugins": "Плагины", + "prev": "Предыдущий", + "preview": "Предпросмотр", + + "publish": "Опубликовать", + "published": "Опубликовано", + + "remove": "Удалить", + "rename": "Переименовать", + "renew": "Обновить", + "replace": "\u0417\u0430\u043c\u0435\u043d\u0438\u0442\u044c", + "replace.with": "Заменить на", + "retry": "\u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c", + "revert": "\u0421\u0431\u0440\u043e\u0441", + "revert.confirm": "Вы действительно хотите удалить все несохраненные изменения?", + + "role": "\u0420\u043e\u043b\u044c", + "role.admin.description": "Администратор имеет все права", + "role.admin.title": "Администратор", + "role.all": "Все", + "role.empty": "Пользователей с такой ролью нет", + "role.description.placeholder": "Без описания", + "role.nobody.description": "Эта роль применяется если у пользователя нет никаких прав", + "role.nobody.title": "Никто", + + "save": "\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c", + "saved": "Сохранено", + "search": "Поиск", + "searching": "Поиск", + "search.min": "Введите хотя бы {min} символов для поиска", + "search.all": "Показать все результаты ({count})", + "search.results.none": "Нет результатов", + + "section.invalid": "Неверная секция", + "section.required": "Секция обязательна", + + "security": "Безопасность", + "select": "Выбрать", + "server": "Сервер", + "settings": "Настройка", + "show": "Показать", + "site.blueprint": "У сайта пока нет разметки. Вы можете определить новые секции и поля разметки в /site/blueprints/site.yml", + "size": "Размер", + "slug": "URL", + "sort": "Сортировать", + "sort.drag": "Потяните для сортировки…", + "split": "Разделить", + + "stats.empty": "Статистики нет", + "status": "Статус", + + "system.info.copy": "Copy info", + "system.info.copied": "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": "Включен режим отладки (debugging). Используйте его только при разработке.", + "system.issues.git": "Похоже, к папке .git есть несанкционированный доступ", + "system.issues.https": "Рекомендуется использовать HTTPS на всех сайтах", + "system.issues.kirby": "Похоже, к папке kirby есть несанкционированный доступ", + "system.issues.local": "Сайт работает локально, с ослабленными проверками безопасности", + "system.issues.site": "Похоже, к папке site есть несанкционированный доступ", + "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": "+79123456789", + "template": "\u0428\u0430\u0431\u043b\u043e\u043d", + + "theme": "Тема", + "theme.light": "Светлая тема", + "theme.dark": "Темная тема", + "theme.automatic": "Как в системе", + + "title": "Название", + "today": "Сегодня", + + "toolbar.button.clear": "Очистить форматирование", + "toolbar.button.code": "Код", + "toolbar.button.bold": "\u0416\u0438\u0440\u043d\u044b\u0439 \u0448\u0440\u0438\u0444\u0442", + "toolbar.button.email": "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": "Курсив", + "toolbar.button.file": "Файл", + "toolbar.button.file.select": "Выбрать файл", + "toolbar.button.file.upload": "Загрузить файл", + "toolbar.button.link": "\u0421\u0441\u044b\u043b\u043a\u0430", + "toolbar.button.paragraph": "Параграф", + "toolbar.button.strike": "Зачёркнутый", + "toolbar.button.sub": "Нижний индекс", + "toolbar.button.sup": "Верхний индекс", + "toolbar.button.ol": "Нумерованный список", + "toolbar.button.underline": "Подчёркнутый", + "toolbar.button.ul": "Маркированный список", + + "translation.author": "Команда Kirby", + "translation.direction": "ltr", + "translation.name": "Русский (Russian)", + "translation.locale": "ru_RU", + + "type": "Введите", + + "upload": "Загрузить", + "upload.error.cantMove": "Не удалось переместить загруженный файл", + "upload.error.cantWrite": "Не получилось записать файл на диск", + "upload.error.default": "Не удалось загрузить файл", + "upload.error.extension": "Загрузка файла остановлена из-за расширения", + "upload.error.formSize": "Загружаемый файл больше указанного в параметре MAX_FILE_SIZE в форме", + "upload.error.iniPostSize": "Загружаемый файл больше указанного в параметре \"post_max_size\" в php.ini", + "upload.error.iniSize": "Загружаемый файл больше указанного в параметре \"upload_max_filesize\" в php.ini", + "upload.error.noFile": "Файл не был загружен", + "upload.error.noFiles": "Файлы не были загружены", + "upload.error.partial": "Файл загружен только частично", + "upload.error.tmpDir": "Не хватает временной папки", + "upload.errors": "Ошибка", + "upload.progress": "Загрузка...", + + "url": "URL", + "url.placeholder": "https://example.com", + + "user": "Пользователь", + "user.blueprint": "Вы можете определить новые секции и поля разметки для пользователя в /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Изменить Email", + "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": "Ваш аккаунт", + "view.installation": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430", + "view.languages": "Языки", + "view.resetPassword": "Сбросить пароль", + "view.site": "Сайт", + "view.system": "Система", + "view.users": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", + + "welcome": "Добро пожаловать", + "year": "Год", + "yes": "да" +} diff --git a/public/kirby/i18n/translations/sk.json b/public/kirby/i18n/translations/sk.json new file mode 100644 index 0000000..1650067 --- /dev/null +++ b/public/kirby/i18n/translations/sk.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Zmeniť vaše meno", + "account.delete": "Zmazať váš účet", + "account.delete.confirm": "Do you really want to delete your account? You will be logged out immediately. Your account cannot be recovered.", + + "activate": "Activate", + "add": "Pridať", + "alpha": "Alpha", + "author": "Autor", + "avatar": "Profilový obrázok", + "back": "Späť", + "cancel": "Zrušiť", + "change": "Zmeniť", + "close": "Zavrieť", + "changes": "Zmeny", + "confirm": "Ok", + "collapse": "Zabaliť", + "collapse.all": "Zabaliť všetky", + "color": "Farba", + "coordinates": "Koordináty", + "copy": "Kopírovať", + "copy.all": "Copy all", + "copy.success": "{count} copied!", + "copy.success.multiple": "{count} copied!", + "copy.url": "Copy URL", + "create": "Vytvoriť", + "custom": "Custom", + + "date": "Dátum", + "date.select": "Zvoliť dátum", + + "day": "Deň", + "days.fri": "Pia", + "days.mon": "Pon", + "days.sat": "Sob", + "days.sun": "Ned", + "days.thu": "Štv", + "days.tue": "Uto", + "days.wed": "Str", + + "debugging": "Debugging", + + "delete": "Zmazať", + "delete.all": "Zmazať všetky", + + "dialog.fields.empty": "This dialog has no fields", + "dialog.files.empty": "No files to select", + "dialog.pages.empty": "No pages to select", + "dialog.text.empty": "This dialog does not define any text", + "dialog.users.empty": "Zvolení neboli žiadni uživátelia", + + "dimensions": "Rozmery", + "disable": "Disable", + "disabled": "Disabled", + "discard": "Zahodiť", + + "drawer.fields.empty": "This drawer has no fields", + + "domain": "Domain", + "download": "Stiahnuť", + "duplicate": "Duplikovať", + + "edit": "Upraviť", + + "email": "E-mail", + "email.placeholder": "mail@example.com", + + "enter": "Enter", + "entries": "Entries", + "entry": "Entry", + + "environment": "Environment", + + "error": "Chyba", + "error.access.code": "Neplatný kód", + "error.access.login": "Neplatné prihlásenie", + "error.access.panel": "Nemáte povolenie na prístup do Panel-u", + "error.access.view": "You are not allowed to access this part of the panel", + + "error.avatar.create.fail": "Profilový obrázok sa nepodarilo nahrať", + "error.avatar.delete.fail": "Profilový obrázok sa nepodarilo zmazať", + "error.avatar.dimensions.invalid": "Prosím, dodržte, aby šírka a výška profilového obrázka bola menšia ako 3000 pixelov", + "error.avatar.mime.forbidden": "Profilový obrázok musí byť súbor JPEG alebo PNG.", + + "error.blueprint.notFound": "Blueprint \"{name}\" sa nepodarilo načítať", + + "error.blocks.max.plural": "You must not add more than {max} blocks", + "error.blocks.max.singular": "You must not add more than one block", + "error.blocks.min.plural": "You must add at least {min} blocks", + "error.blocks.min.singular": "You must add at least one block", + "error.blocks.validation": "There's an error on the \"{field}\" field in block {index} using the \"{fieldset}\" block type", + + "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": "E-mailovú predvoľbu \"{name}\" nie je možné nájsť", + + "error.field.converter.invalid": "Neplatný converter \"{converter}\"", + "error.field.link.options": "Invalid options: {options}", + "error.field.type.missing": "Field \"{ name }\": The field type \"{ type }\" does not exist", + + "error.file.changeName.empty": "Meno nesmie byť prázdne", + "error.file.changeName.permission": "Nemáte povolenie na zmenu názvu pre \"{filename}\"", + "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": "Súbor s názvom \"{filename}\" už existuje", + "error.file.extension.forbidden": "Prípona \"{extension}\" nie je povolená", + "error.file.extension.invalid": "Neplatná prípona: \"{extension}\"", + "error.file.extension.missing": "Prípona pre \"{filename}\" chýba", + "error.file.maxheight": "Výška obrázku nesmie prekročiť \"{height}\" pixelov", + "error.file.maxsize": "Súbor je príliš velký", + "error.file.maxwidth": "Šírka obrázku nesmie prekročiť \"{width}\" pixelov", + "error.file.mime.differs": "Mime typ nahratého súboru msa musí zhodovať s \"{mime}\"", + "error.file.mime.forbidden": "Typ média \"{mime}\" nie je povolený", + "error.file.mime.invalid": "Neplatný mime typ: \"{mime}\"", + "error.file.mime.missing": "Typ média pre \"{filename}\" sa nepodarilo zistiť", + "error.file.minheight": "Výška obrázku musí byť aspoň \"{height}\" pixelov", + "error.file.minsize": "Súbor je príliš malý", + "error.file.minwidth": "Šírka obrázku musí byť aspoň \"{width}\" pixelov", + "error.file.name.unique": "The filename must be unique", + "error.file.name.missing": "Názov súboru nemôže byť prázdny", + "error.file.notFound": "Súbor \"{filename}\" sa nepodarilo nájsť", + "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": "Nemáte povolenie na nahrávanie súborov s typom {type}", + "error.file.type.invalid": "Neplatný typ súboru: \"{type}\"", + "error.file.undefined": "Súbor nie je možné nájsť", + + "error.form.incomplete": "Prosím, opravte všetky chyby v rámci formuláru...", + "error.form.notSaved": "Formulár sa nepodarilo uložiť", + + "error.language.code": "Please enter a valid code for the language", + "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": "The language already exists", + "error.language.name": "Please enter a valid name for the language", + "error.language.notFound": "The language could not be found", + "error.language.update.permission": "You are not allowed to update the language", + + "error.layout.validation.block": "There's an error on the \"{field}\" field in block {blockIndex} using the \"{fieldset}\" block type in layout {layoutIndex}", + "error.layout.validation.settings": "There's an error in layout {index} settings", + + "error.license.domain": "The domain for the license is missing", + "error.license.email": "Prosím, zadajte platnú e-mailovú adresu", + "error.license.format": "Please enter a valid license code", + "error.license.verification": "The license could not be verified", + + "error.login.totp.confirm.invalid": "Neplatný kód", + "error.login.totp.confirm.missing": "Please enter the current code", + + "error.object.validation": "There’s an error in the \"{label}\" field:\n{message}", + + "error.offline": "The Panel is currently offline", + + "error.page.changeSlug.permission": "Nemáte povolenie na zmenu URL príponu pre \"{slug}\"", + "error.page.changeSlug.reserved": "The path of top-level pages must not start with \"{path}\"", + "error.page.changeStatus.incomplete": "Stránka obsahuje chyby a nemôže byť zverejnená", + "error.page.changeStatus.permission": "Status tejto stránky nemôže byť zmenený", + "error.page.changeStatus.toDraft.invalid": "Stránka \"{slug}\" nemôže byť zmenená na koncept.", + "error.page.changeTemplate.invalid": "Šablónu pre stránku \"{slug}\" nie je možné zmeniť", + "error.page.changeTemplate.permission": "Nemáte povolenie na zmenu šablóny pre \"{slug}\"", + "error.page.changeTitle.empty": "Titulok nemôže byť prázdny", + "error.page.changeTitle.permission": "Nemáte povolenie na zmenu titulku pre \"{slug}\"", + "error.page.create.permission": "Nemáte povolenie na vytvorenie \"{slug}\"", + "error.page.delete": "Stránku \"{slug}\" nie je možné vymazať", + "error.page.delete.confirm": "Prosím, zadajte titulok stránky pre potvrdenie", + "error.page.delete.hasChildren": "Táto stránka obsahuje podstránky a nemôže byť zmazaná", + "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": "Nemáte povolenie na zmazanie stránky \"{slug}\"", + "error.page.draft.duplicate": "Koncept stránky s URL appendix-om \"{slug}\" už existuje", + "error.page.duplicate": "Stránka s URL appendix-om \"{slug}\" už existuje", + "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"", + "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.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}\"", + "error.page.notFound": "Stránku \"{slug}\" nie je možné nájsť", + "error.page.num.invalid": "Prosím, zadajte platné číslo pre radenie. Čísla nemôžu byť záporné.", + "error.page.slug.invalid": "Please enter a valid URL appendix", + "error.page.slug.maxlength": "Slug length must be less than \"{length}\" characters", + "error.page.sort.permission": "Stránku \"{slug}\" nie je možné preradiť.", + "error.page.status.invalid": "Prosím, nastavte platnú status pre stránku", + "error.page.undefined": "Stránku nie je možné nájsť", + "error.page.update.permission": "Nemáte povolenie na aktualizáciu \"{slug}\"", + + "error.section.files.max.plural": "Nemôžete pridať viac ako {max} súbory/ov do sekcie \"{section}\"", + "error.section.files.max.singular": "Nemôžete pridať viac ako 1 súbor do sekcie \"{section}\"", + "error.section.files.min.plural": "The \"{section}\" section requires at least {min} files", + "error.section.files.min.singular": "The \"{section}\" section requires at least one file", + + "error.section.pages.max.plural": "Nemôžete pridať viac ako {max} stránky/ok do sekcie \"{section}\"", + "error.section.pages.max.singular": "Nemôžete pridať viac ako 1 stránku do sekcie \"{section}\"", + "error.section.pages.min.plural": "The \"{section}\" section requires at least {min} pages", + "error.section.pages.min.singular": "The \"{section}\" section requires at least one page", + + "error.section.notLoaded": "Sekciu \"{name}\" sa nepodarilo nahrať", + "error.section.type.invalid": "Typ sekcie \"{type}\" nie je platný", + + "error.site.changeTitle.empty": "Titulok nemôže byť prázdny", + "error.site.changeTitle.permission": "Nemáte povolenie na zmenu titulku pre portál", + "error.site.update.permission": "Nemáte povolenie na aktualizovanie portálu", + + "error.structure.validation": "There's an error on the \"{field}\" field in row {index}", + + "error.template.default.notFound": "Predvolená šablóna neexistuje", + + "error.unexpected": "An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Nemáte povolenie na zmenu e-mailu pre užívateľa \"{name}\"", + "error.user.changeLanguage.permission": "Nemáte povolenie na zmenu jazyka pre užívateľa \"{name}\"", + "error.user.changeName.permission": "Nemáte povolenie na zmenu mena pre užívateľa \"{name}\"", + "error.user.changePassword.permission": "Nemáte povolenie na zmenu hesla pre užívateľa \"{name}\"", + "error.user.changeRole.lastAdmin": "Rolu pre posledného administrátora nie je možné zmeniť", + "error.user.changeRole.permission": "Nemáte povolenie na zmenu role pre užívateľa \"{name}\"", + "error.user.changeRole.toAdmin": "You are not allowed to promote someone to the admin role", + "error.user.create.permission": "Nemáte povolenie na vytvorenie tohto užívateľa", + "error.user.delete": "Užívateľa \"{name}\" nie je možné zmazať", + "error.user.delete.lastAdmin": "Posledného administrátora nie je možné zmazať", + "error.user.delete.lastUser": "Posledného užívateľa nie je možné zmazať", + "error.user.delete.permission": "Nemáte povolenie na zmazanie užívateľa \"{name}\"", + "error.user.duplicate": "Užívateľ s e-mailovou adresou \"{email}\" už existuje", + "error.user.email.invalid": "Prosím, zadajte platnú e-mailovú adresu", + "error.user.language.invalid": "Prosím, zadajte platný jazyk", + "error.user.notFound": "Užívateľa \"{name}\" nie je možné nájsť", + "error.user.password.excessive": "Please enter a valid password. Passwords must not be longer than 1000 characters.", + "error.user.password.invalid": "Prosím, zadajte platné heslo. Dĺžka hesla musí byť aspoň 8 znakov.", + "error.user.password.notSame": "Heslá nie sú rovnaké", + "error.user.password.undefined": "Užívateľ nemá heslo", + "error.user.password.wrong": "Wrong password", + "error.user.role.invalid": "Prosím, zadajte platnú rolu", + "error.user.undefined": "Užívateľa sa nepodarilo nájsť", + "error.user.update.permission": "Nemáte povolenie na aktualizáciu užívateľa \"{name}\"", + + "error.validation.accepted": "Prosím, potvrďte", + "error.validation.alpha": "Prosím, zadajte len znaky z hlások a-z", + "error.validation.alphanum": "Prosím, zadajte len znaky z hlások a-z a čísloviek 0-9", + "error.validation.anchor": "Please enter a correct link anchor", + "error.validation.between": "Prosím, zadajte hodnotu od \"{min}\" do \"{max}\"", + "error.validation.boolean": "Prosím, potvrďte alebo odmietnite", + "error.validation.color": "Please enter a valid color in the {format} format", + "error.validation.contains": "Prosím, zadajte hodnotu, ktorá obsahuje \"{needle}\"", + "error.validation.date": "Prosím, zadajte platný dátum", + "error.validation.date.after": "Please enter a date after {date}", + "error.validation.date.before": "Please enter a date before {date}", + "error.validation.date.between": "Please enter a date between {min} and {max}", + "error.validation.denied": "Prosím, odmietnite", + "error.validation.different": "Hodnota nemôže byť \"{other}\"", + "error.validation.email": "Prosím, zadajte platnú e-mailovú adresu", + "error.validation.endswith": "Hodnota musí končiť na \"{end}\"", + "error.validation.filename": "Prosím, zadajte platný názov súboru", + "error.validation.in": "Prosím, zadajte jedno z nasledujúcich: ({in})", + "error.validation.integer": "Prosím, zadajte platné celé číslo", + "error.validation.ip": "Prosím, zadajte platnú e-mailovú adresu", + "error.validation.less": "Prosím, zadajte hodnotu menšiu ako {max}", + "error.validation.linkType": "The link type is not allowed", + "error.validation.match": "Hodnota nezodpovedá očakávanému vzoru", + "error.validation.max": "Prosím, zadajte hodnotu rovnú alebo menšiu ako {max}", + "error.validation.maxlength": "Prosím, zadajte kratšiu hodnotu. (max. {max} charaktery/ov)", + "error.validation.maxwords": "Prosím, nezadávajte viac ako {max} slovo/á/ov", + "error.validation.min": "Prosím, zadajte hodnotu rovnú alebo väčšiu ako {min}", + "error.validation.minlength": "Prosím, zadajte dlhšiu hodnotu. (min. {min} charaktery/ov)", + "error.validation.minwords": "Prosím, zadajte aspoň {min} slovo/á/ov", + "error.validation.more": "Prosím zadajte hodnotu väčšiu ako {min}", + "error.validation.notcontains": "Prosím, zadajte hodnotu, ktorá neobsahuje \"{needle}\"", + "error.validation.notin": "Prosím, nezadávajte ani jedno z nasledujúcich: ({notIn})", + "error.validation.option": "Prosím, zadajte platnú voľbu", + "error.validation.num": "Prosím, zadajte platné číslo", + "error.validation.required": "Prosím, zadajte niečo", + "error.validation.same": "Prosím, zadajte \"{other}\"", + "error.validation.size": "Veľkosť hodnoty musí byť \"{size}\"", + "error.validation.startswith": "Hodnota musí začínať s \"{start}\"", + "error.validation.tel": "Please enter an unformatted phone number", + "error.validation.time": "Prosím, zadajte platný čas", + "error.validation.time.after": "Please enter a time after {time}", + "error.validation.time.before": "Please enter a time before {time}", + "error.validation.time.between": "Please enter a time between {min} and {max}", + "error.validation.uuid": "Please enter a valid UUID", + "error.validation.url": "Prosím, zadajte platnú URL", + + "expand": "Rozbaliť", + "expand.all": "Rozbaliť všetky", + + "field.invalid": "The field is invalid", + "field.required": "The field is required", + "field.blocks.changeType": "Change type", + "field.blocks.code.name": "Kód", + "field.blocks.code.language": "Jazyk", + "field.blocks.code.placeholder": "Váš kód ...", + "field.blocks.delete.confirm": "Naozaj chcete zmazať tento blok?", + "field.blocks.delete.confirm.all": "Naozaj chcete zmazať všetky bloky?", + "field.blocks.delete.confirm.selected": "Naozaj chcete zmazať vybrané bloky?", + "field.blocks.empty": "No blocks yet", + "field.blocks.fieldsets.empty": "No fieldsets yet", + "field.blocks.fieldsets.label": "Please select a block type …", + "field.blocks.fieldsets.paste": "Press {{ shortcut }} to import layouts/blocks from your clipboard Only those allowed in the current field will get inserted.", + "field.blocks.gallery.name": "Galéria", + "field.blocks.gallery.images.empty": "No images yet", + "field.blocks.gallery.images.label": "Obrázky", + "field.blocks.heading.level": "Level", + "field.blocks.heading.name": "Nadpis", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Nadpis ...", + "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": "Alternative text", + "field.blocks.image.caption": "Popis", + "field.blocks.image.crop": "Orezanie", + "field.blocks.image.link": "Odkaz", + "field.blocks.image.location": "Poloha", + "field.blocks.image.location.internal": "This website", + "field.blocks.image.location.external": "External source", + "field.blocks.image.name": "Obrázok", + "field.blocks.image.placeholder": "Select an image", + "field.blocks.image.ratio": "Ratio", + "field.blocks.image.url": "Image URL", + "field.blocks.line.name": "Line", + "field.blocks.list.name": "List", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Quote", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Quote …", + "field.blocks.quote.citation.label": "Citation", + "field.blocks.quote.citation.placeholder": "by …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autoplay", + "field.blocks.video.caption": "Popis", + "field.blocks.video.controls": "Controls", + "field.blocks.video.location": "Poloha", + "field.blocks.video.loop": "Loop", + "field.blocks.video.muted": "Muted", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Enter a video URL", + "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": "Do you really want to delete all entries?", + "field.entries.empty": "Zatiaľ žiadne údaje", + + "field.files.empty": "Žiadne súbory zatiaľ neboli zvolené", + "field.files.empty.single": "No file selected yet", + + "field.layout.change": "Change layout", + "field.layout.delete": "Delete layout", + "field.layout.delete.confirm": "Do you really want to delete this layout?", + "field.layout.delete.confirm.all": "Do you really want to delete all layouts?", + "field.layout.empty": "No rows yet", + "field.layout.select": "Select a layout", + + "field.object.empty": "No information yet", + + "field.pages.empty": "Žiadne stránky zatiaľ neboli zvolené", + "field.pages.empty.single": "No page selected yet", + + "field.structure.delete.confirm": "Ste si istý, že chcete zmazať tento riadok?", + "field.structure.delete.confirm.all": "Do you really want to delete all entries?", + "field.structure.empty": "Zatiaľ žiadne údaje", + + "field.users.empty": "Žiadni užívatelia zatiaľ neboli zvolení", + "field.users.empty.single": "No user selected yet", + + "fields.empty": "No fields yet", + + "file": "Súbor", + "file.blueprint": "This file has no blueprint yet. You can define the setup in /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Zmeniť šablónu", + "file.changeTemplate.notice": "Changing the file's template will remove content for fields that don't match in type. If the new template defines certain rules, e.g. image dimensions, those will also be applied irreversibly. Use with caution.", + "file.delete.confirm": "Ste si istý, že chcete zmazať
{filename}?", + "file.focus.placeholder": "Set focal point", + "file.focus.reset": "Remove focal point", + "file.focus.title": "Focus", + "file.sort": "Change position", + + "files": "Súbory", + "files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.", + "files.empty": "Zatiaľ žiadne súbory", + + "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": "Hodina", + "hue": "Hue", + "import": "Import", + "info": "Info", + "insert": "Vložiť", + "insert.after": "Insert after", + "insert.before": "Insert before", + "install": "Inštalovať", + + "installation": "Inštalácia", + "installation.completed": "Panel bol nainštalovaný", + "installation.disabled": "Inštalácia Panelu na verejných serveroch je štandardne zablokovaná. Prosím, spustite inštaláciu na lokálnom serveri alebo aktivujte voľbu panel.install.", + "installation.issues.accounts": "Priečinok /site/accounts neexistuje alebo nie je nastavený ako zapisovateľný", + "installation.issues.content": "Priečinok /content neexistuje alebo nie je nastavený ako zapisovateľný", + "installation.issues.curl": "CURL rozšírenie je povinné", + "installation.issues.headline": "Panel nie je možné naištalovať", + "installation.issues.mbstring": "MB String rozšírenie je povinné", + "installation.issues.media": "Priečinok /media neexistuje alebo nie je nastavený ako zapisovateľný", + "installation.issues.php": "Uistite sa, že používate PHP 8+", + "installation.issues.sessions": "Priečinok /site/sessions neexistuje alebo nie je nastavený ako zapisovateľný", + + "language": "Jazyk", + "language.code": "Kód", + "language.convert": "Nastaviť ako predvolené", + "language.convert.confirm": "

Ste si istý, že chcete nastaviť {name} ako predvolený jazyk? Túto akciu nie je možné zvrátiť.

Ak {name} obsahuje nepreložený obsah, tak pre tento obsah nebude fungovať platné volanie a niektoré časti vašich stránok zostanú prázdne.

", + "language.create": "Pridať nový jazyk", + "language.default": "Predvolený jazyk", + "language.delete.confirm": "Ste si istý, že chcete zmazať jazyk {name} vrátane všetkých prekladov? Túto akciu nie je možné zvrátiť.", + "language.deleted": "Jazyk bol zmazaný", + "language.direction": "Smer čítania", + "language.direction.ltr": "Zľava doprava", + "language.direction.rtl": "Zprava doľava", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Názov", + "language.secondary": "Secondary language", + "language.settings": "Language settings", + "language.updated": "Jazyk bol aktualizovaný", + "language.variables": "Language variables", + "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", + + "languages": "Jazyky", + "languages.default": "Predvolený jazyk", + "languages.empty": "Zatiaľ žiadne jazyky", + "languages.secondary": "Sekundárne jazyky", + "languages.secondary.empty": "Zatiaľ žiadne sekundárne jazyky", + + "license": "Licencia", + "license.activate": "Activate it now", + "license.activate.label": "Please activate your license", + "license.activate.domain": "Your license will be activated for {host}.", + "license.activate.local": "You are about to activate your Kirby license for your local domain {host}. If this site will be deployed to a public domain, please activate it there instead. If {host} is the domain you want to use your license for, please continue.", + "license.activated": "Activated", + "license.buy": "Zakúpiť licenciu", + "license.code": "Kód", + "license.code.help": "You received your license code after the purchase via email. Please copy and paste it here.", + "license.code.label": "Prosím, zadajte váš licenčný kód", + "license.status.active.info": "Includes new major versions until {date}", + "license.status.active.label": "Valid license", + "license.status.demo.info": "This is a demo installation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Renew license to update to new major versions", + "license.status.inactive.label": "No new major versions", + "license.status.legacy.bubble": "Ready to renew your license?", + "license.status.legacy.info": "Your license does not cover this version", + "license.status.legacy.label": "Please renew your license", + "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": "Ďakujeme za vašu podporu Kirby", + "license.unregistered.label": "Unregistered", + + "link": "Odkaz", + "link.text": "Text odkazu", + + "loading": "Načítavanie", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more 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}", + "lock.isUnlocked": "Was unlocked by another user", + + "login": "Prihlásenie", + "login.code.label.login": "Login code", + "login.code.label.password-reset": "Password reset code", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "If your email address is registered, the requested code was sent via email.", + "login.code.text.totp": "Please enter the one‑time code from your authenticator app.", + "login.email.login.body": "Hi {user.nameOrEmail},\n\nYou recently requested a login code for the Panel of {site}.\nThe following login code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a login code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.login.subject": "Your login code", + "login.email.password-reset.body": "Hi {user.nameOrEmail},\n\nYou recently requested a password reset code for the Panel of {site}.\nThe following password reset code will be valid for {timeout} minutes:\n\n{code}\n\nIf you did not request a password reset code, please ignore this email or contact your administrator if you have questions.\nFor security, please DO NOT forward this email.", + "login.email.password-reset.subject": "Your password reset code", + "login.remember": "Ponechať ma prihláseného", + "login.reset": "Reset password", + "login.toggleText.code.email": "Login via email", + "login.toggleText.code.email-password": "Login with password", + "login.toggleText.password-reset.email": "Forgot your password?", + "login.toggleText.password-reset.email-password": "← Back to login", + "login.totp.enable.option": "Set up one‑time codes", + "login.totp.enable.intro": "Authenticator apps can generate one‑time codes that are used as a second factor when signing into your account.", + "login.totp.enable.qr.label": "1. Scan this QR code", + "login.totp.enable.qr.help": "Unable to scan? Add the setup key {secret} manually to your authenticator app.", + "login.totp.enable.confirm.headline": "2. Confirm with generated code", + "login.totp.enable.confirm.text": "Your app generates a new one‑time code every 30 seconds. Enter the current code to complete the setup:", + "login.totp.enable.confirm.label": "Current code", + "login.totp.enable.confirm.help": "After this setup, we will ask you for a one‑time code every time you log in.", + "login.totp.enable.success": "One‑time codes enabled", + "login.totp.disable.option": "Disable one‑time codes", + "login.totp.disable.label": "Enter your password to disable one‑time codes", + "login.totp.disable.help": "In the future, a different second factor like a login code sent via email will be requested when you log in. You can always set up one‑time codes again later.", + "login.totp.disable.admin": "

This will disable one‑time codes for {user}.

In the future, a different second factor like a login code sent via email will be requested when they log in. {user} can set up one‑time codes again after their next login.

", + "login.totp.disable.success": "One‑time codes disabled", + + "logout": "Odhlásenie", + + "merge": "Merge", + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Typ média", + "minutes": "Minúty", + + "month": "Mesiac", + "months.april": "Apríl", + "months.august": "August", + "months.december": "December", + "months.february": "Február", + "months.january": "Január", + "months.july": "Júl", + "months.june": "Jún", + "months.march": "Marec", + "months.may": "Máj", + "months.november": "November", + "months.october": "Október", + "months.september": "September", + + "more": "Viac", + "move": "Move", + "name": "Meno", + "next": "Ďalej", + "night": "Night", + "no": "no", + "off": "off", + "on": "on", + "open": "Otvoriť", + "open.newWindow": "Open in new window", + "option": "Option", + "options": "Nastavenia", + "options.none": "No options", + "options.all": "Show all {count} options", + + "orientation": "Orientácia", + "orientation.landscape": "Širokouhlá", + "orientation.portrait": "Portrét", + "orientation.square": "Štvorec", + + "page": "Stránka", + "page.blueprint": "This page has no blueprint yet. You can define the setup in /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Zmeniť URL", + "page.changeSlug.fromTitle": "Vytvoriť z titulku", + "page.changeStatus": "Zmeniť status", + "page.changeStatus.position": "Prosím, zmeňte pozíciu", + "page.changeStatus.select": "Zvoľte nový status", + "page.changeTemplate": "Zmeniť šablónu", + "page.changeTemplate.notice": "Changing the page's template will remove content for fields that don't match in type. Use with caution.", + "page.create": "Create as {status}", + "page.delete.confirm": "Ste si istý, že chcete zmazať {title}?", + "page.delete.confirm.subpages": "Táto stránka obsahuje podstránky.
Všetky podstránky budú taktiež zmazané.", + "page.delete.confirm.title": "Pre potvrdenie zadajte titulok stránky", + "page.duplicate.appendix": "Kopírovať", + "page.duplicate.files": "Copy files", + "page.duplicate.pages": "Copy pages", + "page.move": "Move page", + "page.sort": "Change position", + "page.status": "Status", + "page.status.draft": "Koncept", + "page.status.draft.description": "The page is in draft mode and only visible for logged in editors or via secret link", + "page.status.listed": "Verejné", + "page.status.listed.description": "Stránka je prístupná pre všetkých", + "page.status.unlisted": "Skryté", + "page.status.unlisted.description": "Stránka je prístupná len prostredníctvom priamej URL", + + "pages": "Stránky", + "pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.", + "pages.empty": "Zatiaľ žiadne stránky", + "pages.status.draft": "Koncepty", + "pages.status.listed": "Zverejnené", + "pages.status.unlisted": "Skryté", + + "pagination.page": "Stránka", + + "password": "Heslo", + "paste": "Paste", + "paste.after": "Paste after", + "paste.success": "{count} pasted!", + "pixel": "Pixel", + "plugin": "Plugin", + "plugins": "Plugins", + "prev": "Predchádzajúci", + "preview": "Preview", + + "publish": "Publish", + "published": "Zverejnené", + + "remove": "Odstrániť", + "rename": "Premenovať", + "renew": "Renew", + "replace": "Nahradiť", + "replace.with": "Replace with", + "retry": "Skúsiť ešte raz", + "revert": "Vrátiť späť", + "revert.confirm": "Do you really want to delete all unsaved changes?", + + "role": "Rola", + "role.admin.description": "The admin has all rights", + "role.admin.title": "Admin", + "role.all": "Všetko", + "role.empty": "S touto rolou neexistujú žiadni užívatelia", + "role.description.placeholder": "Žiadny popis", + "role.nobody.description": "This is a fallback role without any permissions", + "role.nobody.title": "Nobody", + + "save": "Uložiť", + "saved": "Saved", + "search": "Hľadať", + "searching": "Searching", + "search.min": "Enter {min} characters to search", + "search.all": "Show all {count} results", + "search.results.none": "No results", + + "section.invalid": "The section is invalid", + "section.required": "The section is required", + + "security": "Security", + "select": "Zvoliť", + "server": "Server", + "settings": "Nastavenia", + "show": "Show", + "site.blueprint": "The site has no blueprint yet. You can define the setup in /site/blueprints/site.yml", + "size": "Veľkosť", + "slug": "URL appendix", + "sort": "Zoradiť", + "sort.drag": "Drag to sort …", + "split": "Split", + + "stats.empty": "No reports", + "status": "Status", + + "system.info.copy": "Copy info", + "system.info.copied": "System info copied", + "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", + "system.issues.eol.php": "Your installed PHP release { release } has reached end-of-life and will not receive further security updates", + "system.issues.debug": "Debugging must be turned off in production", + "system.issues.git": "The .git folder seems to be exposed", + "system.issues.https": "We recommend HTTPS for all your sites", + "system.issues.kirby": "The kirby folder seems to be exposed", + "system.issues.local": "The site is running locally with relaxed security checks", + "system.issues.site": "The site folder seems to be exposed", + "system.issues.vue.compiler": "The Vue template compiler is enabled", + "system.issues.vulnerability.kirby": "Your installation might be affected by the following vulnerability ({ severity } severity): { description }", + "system.issues.vulnerability.plugin": "Your installation might be affected by the following vulnerability in the { plugin } plugin ({ severity } severity): { description }", + "system.updateStatus": "Update status", + "system.updateStatus.error": "Could not check for updates", + "system.updateStatus.not-vulnerable": "No known vulnerabilities", + "system.updateStatus.security-update": "Free security update { version } available", + "system.updateStatus.security-upgrade": "Upgrade { version } with security fixes available", + "system.updateStatus.unreleased": "Unreleased version", + "system.updateStatus.up-to-date": "Up to date", + "system.updateStatus.update": "Free update { version } available", + "system.updateStatus.upgrade": "Upgrade { version } available", + + "tel": "Phone", + "tel.placeholder": "+49123456789", + "template": "Šablóna", + + "theme": "Theme", + "theme.light": "Lights on", + "theme.dark": "Lights off", + "theme.automatic": "Match system default", + + "title": "Titulok", + "today": "Dnes", + + "toolbar.button.clear": "Clear formatting", + "toolbar.button.code": "Kód", + "toolbar.button.bold": "Tučný", + "toolbar.button.email": "E-mail", + "toolbar.button.headings": "Nadpisy", + "toolbar.button.heading.1": "Nadpis 1", + "toolbar.button.heading.2": "Nadpis 2", + "toolbar.button.heading.3": "Nadpis 3", + "toolbar.button.heading.4": "Heading 4", + "toolbar.button.heading.5": "Heading 5", + "toolbar.button.heading.6": "Heading 6", + "toolbar.button.italic": "Kurzíva", + "toolbar.button.file": "Súbor", + "toolbar.button.file.select": "Select a file", + "toolbar.button.file.upload": "Upload a file", + "toolbar.button.link": "Odkaz", + "toolbar.button.paragraph": "Paragraph", + "toolbar.button.strike": "Strike-through", + "toolbar.button.sub": "Subscript", + "toolbar.button.sup": "Superscript", + "toolbar.button.ol": "Číslovaný zoznam", + "toolbar.button.underline": "Underline", + "toolbar.button.ul": "Odrážkový zoznam", + + "translation.author": "Tím Kirby", + "translation.direction": "ltr", + "translation.name": "Slovensky", + "translation.locale": "sk_SK", + + "type": "Type", + + "upload": "Nahrať", + "upload.error.cantMove": "The uploaded file could not be moved", + "upload.error.cantWrite": "Failed to write file to disk", + "upload.error.default": "The file could not be uploaded", + "upload.error.extension": "File upload stopped by extension", + "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form", + "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini", + "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini", + "upload.error.noFile": "No file was uploaded", + "upload.error.noFiles": "No files were uploaded", + "upload.error.partial": "The uploaded file was only partially uploaded", + "upload.error.tmpDir": "Missing a temporary folder", + "upload.errors": "Chyba", + "upload.progress": "Nahrávanie...", + + "url": "URL", + "url.placeholder": "https://example.com", + + "user": "Užívateľ", + "user.blueprint": "You can define additional sections and form fields for this user role in /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Zmeniť e-mail", + "user.changeLanguage": "Zmeniť jazyk", + "user.changeName": "Premenovať tohto užívateľa", + "user.changePassword": "Zmeniť heslo", + "user.changePassword.current": "Your current password", + "user.changePassword.new": "Nové heslo", + "user.changePassword.new.confirm": "Potvrdiť nové heslo...", + "user.changeRole": "Zmeniť rolu", + "user.changeRole.select": "Zvoliť novú rolu", + "user.create": "Pridať nového užívateľa", + "user.delete": "Zmazať tohto užívateľa", + "user.delete.confirm": "Ste si istý, že chcete zmazať
{email}?", + + "users": "Užívatelia", + + "version": "Verzia", + "version.changes": "Changed version", + "version.compare": "Compare versions", + "version.current": "Current version", + "version.latest": "Latest version", + "versionInformation": "Version information", + + "view": "View", + "view.account": "Váš účet", + "view.installation": "Inštalácia", + "view.languages": "Jazyky", + "view.resetPassword": "Reset password", + "view.site": "Portál", + "view.system": "System", + "view.users": "Užívatelia", + + "welcome": "Vitajte", + "year": "Rok", + "yes": "yes" +} 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 new file mode 100644 index 0000000..53ac863 --- /dev/null +++ b/public/kirby/i18n/translations/sv_SE.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "Ändra ditt namn", + "account.delete": "Radera ditt konto", + "account.delete.confirm": "Vill du verkligen radera ditt konto? Du kommer att loggas ut omedelbart. Ditt konto kan inte återställas.", + + "activate": "Aktivera", + "add": "L\u00e4gg till", + "alpha": "Alpha", + "author": "Författare", + "avatar": "Profilbild", + "back": "Tillbaka", + "cancel": "Avbryt", + "change": "\u00c4ndra", + "close": "St\u00e4ng", + "changes": "Ändringar", + "confirm": "Spara", + "collapse": "Kollapsa", + "collapse.all": "Kollapsa alla", + "color": "Färg", + "coordinates": "Koordinater", + "copy": "Kopiera", + "copy.all": "Kopiera alla", + "copy.success": "{count} kopierad!", + "copy.success.multiple": "{count} kopierad!", + "copy.url": "Kopiera URL", + "create": "Skapa", + "custom": "Anpassad", + + "date": "Datum", + "date.select": "Välj ett datum", + + "day": "Dag", + "days.fri": "Fre", + "days.mon": "M\u00e5n", + "days.sat": "L\u00f6r", + "days.sun": "S\u00f6n", + "days.thu": "Tor", + "days.tue": "Tis", + "days.wed": "Ons", + + "debugging": "Felsökning", + + "delete": "Radera", + "delete.all": "Radera allt", + + "dialog.fields.empty": "Den här dialogrutan har inga fält", + "dialog.files.empty": "Inga filer att välja", + "dialog.pages.empty": "Inga sidor att välja", + "dialog.text.empty": "Den här dialogrutan definierar ingen text", + "dialog.users.empty": "Inga användare att välja", + + "dimensions": "Dimensioner", + "disable": "Inaktivera", + "disabled": "Inaktiverad", + "discard": "Kassera", + + "drawer.fields.empty": "Denna vy har inga fält", + + "domain": "Domän", + "download": "Ladda ner", + "duplicate": "Duplicera", + + "edit": "Redigera", + + "email": "E-postadress", + "email.placeholder": "namn@exempel.se", + + "enter": "Enter", + "entries": "Poster", + "entry": "Post", + + "environment": "Miljö", + + "error": "Fel", + "error.access.code": "Ogiltig kod", + "error.access.login": "Ogiltig inloggning", + "error.access.panel": "Du saknar behörighet att nå panelen", + "error.access.view": "Du saknar behörighet att nå denna del av panelen", + + "error.avatar.create.fail": "Profilbilden kunde inte laddas upp", + "error.avatar.delete.fail": "Profilbilden kunde inte raderas", + "error.avatar.dimensions.invalid": "Se till att profilbildens bredd och höjd är mindre än 3000 pixlar", + "error.avatar.mime.forbidden": "Profilbilden måste vara i formatet JPEG eller PNG", + + "error.blueprint.notFound": "Blueprint \"{name}\" kunde inte laddas", + + "error.blocks.max.plural": "Du får inte lägga till mer än {max} block", + "error.blocks.max.singular": "Du får inte lägga till mer än ett block", + "error.blocks.min.plural": "Du måste lägga till minst {min} block", + "error.blocks.min.singular": "Du måste lägga till minst ett block", + "error.blocks.validation": "Det finns ett fel i fältet \"{field}\" i block {index} med blocktypen \"{fieldset}\"", + + "error.cache.type.invalid": "Ogiltig cachetyp \"{type}\"", + + "error.content.lock.delete": "Versionen är låst och kan inte raderas", + "error.content.lock.move": "Källversionen är låst och kan inte flyttas", + "error.content.lock.publish": "Denna version är redan publicerad", + "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": "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": "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", + "error.file.changeName.permission": "Du har inte behörighet att ändra namnet på \"{filename}\"", + "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": "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}", + "error.file.extension.missing": "Filen \"{filename}\" saknar filändelse", + "error.file.maxheight": "Bildens höjd får inte överstiga {height} pixlar", + "error.file.maxsize": "Filen är för stor", + "error.file.maxwidth": "Bildens bredd får inte överstiga {width} pixlar", + "error.file.mime.differs": "Den uppladdade filen måste vara av samma mime-typ \"{mime}\"", + "error.file.mime.forbidden": "Mediatypen \"{mime}\" är inte tillåten", + "error.file.mime.invalid": "Ogiltig mime-typ: {mime}", + "error.file.mime.missing": "Mediatypen för \"{filename}\" kan inte detekteras", + "error.file.minheight": "Bildens höjd måste vara minst {height} pixlar", + "error.file.minsize": "Filen är för liten", + "error.file.minwidth": "Bildens bredd måste vara minst {width} pixlar", + "error.file.name.unique": "Filnamnet måste vara unikt", + "error.file.name.missing": "Filnamnet får inte vara tomt", + "error.file.notFound": "Filen \"{filename}\" kan ej hittas", + "error.file.orientation": "Bildens orientering måste vara \"{orientation}\"", + "error.file.sort.permission": "Du har inte behörighet att ändra sorteringen av \"{filename}\"", + "error.file.type.forbidden": "Du har inte behörighet att ladda upp filer av typen {type}", + "error.file.type.invalid": "Ogiltig filtyp: {type}", + "error.file.undefined": "Filen kan inte hittas", + + "error.form.incomplete": "Vänligen åtgärda alla formulärfel...", + "error.form.notSaved": "Formuläret kunde inte sparas", + + "error.language.code": "Ange en giltig kod för språket", + "error.language.create.permission": "Du saknar behörighet att skapa ett nytt språk", + "error.language.delete.permission": "Du saknar behörighet att radera språket", + "error.language.duplicate": "Språket finns redan", + "error.language.name": "Ange ett giltigt namn för språket", + "error.language.notFound": "Språket hittades inte", + "error.language.update.permission": "Du saknar behörighet att redigera språket", + + "error.layout.validation.block": "Det finns ett fel i fältet \"{field}\" i blocket {blockIndex} med blocktypen \"{fieldset}\" i layouten {layoutIndex}", + "error.layout.validation.settings": "Det finns ett fel i inställningarna för layout {index}", + + "error.license.domain": "Domänen för licensen saknas", + "error.license.email": "Ange en giltig e-postadress", + "error.license.format": "Ange en giltig licenskod", + "error.license.verification": "Licensen kunde inte verifieras", + + "error.login.totp.confirm.invalid": "Ogiltig kod", + "error.login.totp.confirm.missing": "Vänligen ange den aktuella koden", + + "error.object.validation": "Det finns ett fel i fältet \"{label}\":\n{message}", + + "error.offline": "Panelen är för närvarande offline", + + "error.page.changeSlug.permission": "Du har inte behörighet att ändra URL-appendixen för \"{slug}\"", + "error.page.changeSlug.reserved": "Sökvägen till sidor på toppnivå får inte börja med \"{path}\"", + "error.page.changeStatus.incomplete": "Sidan innehåller fel och kan inte publiceras", + "error.page.changeStatus.permission": "Statusen för denna sida kan inte ändras", + "error.page.changeStatus.toDraft.invalid": "Statusen för sidan \"{slug}\" kan inte ändras till utkast", + "error.page.changeTemplate.invalid": "Mallen för sidan \"{slug}\" kan inte ändras", + "error.page.changeTemplate.permission": "Du har inte behörighet att ändra mallen för \"{slug}\"", + "error.page.changeTitle.empty": "Titeln får inte vara tom", + "error.page.changeTitle.permission": "Du har inte behörighet att ändra titeln för \"{slug}\"", + "error.page.create.permission": "Du har inte behörighet att skapa \"{slug}\"", + "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": "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", + "error.page.duplicate.permission": "Du har inte behörighet att duplicera \"{slug}\"", + "error.page.move.ancestor": "Sidan kan inte flyttas in i sig själv", + "error.page.move.directory": "Sidans mapp kan inte flyttas", + "error.page.move.duplicate": "En undersida med URL-appendixen \"{slug}\" existerar redan", + "error.page.move.noSections": "Sidan \"{parent}\" kan inte vara en förälder till någon sida eftersom den saknar sidsektioner i dess blueprint", + "error.page.move.notFound": "Den flyttade sidan kunde inte hittas", + "error.page.move.permission": "Du saknar behörighet för att flytta \"{slug}\"", + "error.page.move.template": "Mallen \"{template}\" accepteras inte som en undersida till \"{parent}\"", + "error.page.notFound": "Sidan \"{slug}\" kan inte hittas", + "error.page.num.invalid": "Ange ett giltigt nummer för sortering. Numret får inte vara negativt.", + "error.page.slug.invalid": "Ange en giltig URL-appendix", + "error.page.slug.maxlength": "Permalänkens längd måste vara kortare än \"{length}\" tecken", + "error.page.sort.permission": "Sidan \"{slug}\" kan inte sorteras", + "error.page.status.invalid": "Sätt en giltig status för sidan", + "error.page.undefined": "Sidan kan inte hittas", + "error.page.update.permission": "Du har inte behörighet att uppdatera \"{slug}\"", + + "error.section.files.max.plural": "Du får inte lägga till mer än {max} filer till sektionen \"{section}\"", + "error.section.files.max.singular": "Du får inte lägga till mer än en fil i sektionen \"{section}\"", + "error.section.files.min.plural": "Sektionen \"{section}\" kräver minst {min} filer", + "error.section.files.min.singular": "Sektionen \"{section}\" kräver minst en fil", + + "error.section.pages.max.plural": "Du får inte lägga till mer än {max} sidor till sektionen \"{section}\"", + "error.section.pages.max.singular": "Du får inte lägga till mer än en sida i sektionen \"{section}\"", + "error.section.pages.min.plural": "Sektionen \"{section}\" kräver minst {min} sidor", + "error.section.pages.min.singular": "Sektionen \"{section}\" kräver minst en sida", + + "error.section.notLoaded": "Sektionen \"{name}\" kunde inte laddas", + "error.section.type.invalid": "Sektionstypen \"{type}\" är inte giltig", + + "error.site.changeTitle.empty": "Titeln får inte vara tom", + "error.site.changeTitle.permission": "Du har inte behörighet att ändra titeln på webbplatsen", + "error.site.update.permission": "Du har inte behörighet att uppdatera webbplatsen", + + "error.structure.validation": "Det finns ett fel i fältet \"{field}\" i rad {index}", + + "error.template.default.notFound": "Standardmallen finns inte", + + "error.unexpected": "Ett oväntat fel uppstod! Aktivera felsökningsläge för mer information: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "Du har inte behörighet att ändra e-postadressen för användaren \"{name}\"", + "error.user.changeLanguage.permission": "Du har inte behörighet att ändra språket för användaren \"{name}\"", + "error.user.changeName.permission": "Du har inte behörighet att ändra namnet för användaren \"{name}\"", + "error.user.changePassword.permission": "Du har inte behörighet att ändra lösenordet för användaren \"{name}\"", + "error.user.changeRole.lastAdmin": "Rollen för den återstående adminanvändaren kan inte ändras", + "error.user.changeRole.permission": "Du har inte behörighet att ändra rollen för användaren \"{name}\"", + "error.user.changeRole.toAdmin": "Du har inte behörighet att ge någon en administratörsroll", + "error.user.create.permission": "Du har inte behörighet att skapa denna användare", + "error.user.delete": "Användaren kan inte raderas", + "error.user.delete.lastAdmin": "Den återstående administratören kan inte raderas", + "error.user.delete.lastUser": "Den återstående användaren kan inte raderas", + "error.user.delete.permission": "Du har inte behörighet att radera användaren \"{name}\"", + "error.user.duplicate": "En användare med e-postadressen \"{email}\" finns redan", + "error.user.email.invalid": "Ange en giltig e-postadress", + "error.user.language.invalid": "Ange ett giltigt språk", + "error.user.notFound": "Användaren \"{name}\" kan ej hittas", + "error.user.password.excessive": "Var vänlig skriv in ett giltigt lösenord. Lösenord får inte vara längre än 1000 tecken.", + "error.user.password.invalid": "Ange ett giltigt lösenord. Lösenordet måste vara minst 8 tecken långt.", + "error.user.password.notSame": "Lösenorden matchar inte", + "error.user.password.undefined": "Användaren har inget lösenord", + "error.user.password.wrong": "Fel lösenord", + "error.user.role.invalid": "Ange en giltig roll", + "error.user.undefined": "Användaren kan inte hittas", + "error.user.update.permission": "Du har inte behörighet att uppdatera användaren \"{name}\"", + + "error.validation.accepted": "Vänligen bekräfta", + "error.validation.alpha": "Ange endast tecken mellan a-z", + "error.validation.alphanum": "Ange endast tecken mellan a-z eller siffror 0-9", + "error.validation.anchor": "Vänligen ange en korrekt länk", + "error.validation.between": "Ange ett värde mellan \"{min}\" och \"{max}\"", + "error.validation.boolean": "Bekräfta eller neka", + "error.validation.color": "Ange en giltig färg i formatet {format}", + "error.validation.contains": "Ange ett värde som innehåller \"{needle}\"", + "error.validation.date": "Ange ett giltigt datum", + "error.validation.date.after": "Ange ett datum efter {date}", + "error.validation.date.before": "Ange ett datum före {date}", + "error.validation.date.between": "Ange ett datum mellan {min} och {max}", + "error.validation.denied": "Vänligen neka", + "error.validation.different": "Värdet får inte vara \"{other}\"", + "error.validation.email": "Ange en giltig e-postadress", + "error.validation.endswith": "Värdet måste sluta med \"{end}\"", + "error.validation.filename": "Ange ett giltigt filnamn", + "error.validation.in": "Ange ett av följande: ({in})", + "error.validation.integer": "Ange en giltig heltalssiffra", + "error.validation.ip": "Ange en giltig IP-adress", + "error.validation.less": "Ange ett värde lägre än {max}", + "error.validation.linkType": "Länktypen är inte tillåten", + "error.validation.match": "Värdet matchar inte det förväntade mönstret", + "error.validation.max": "Ange ett värde som är lika med eller lägre än {max}", + "error.validation.maxlength": "Ange ett kortare värde. (max {max} tecken)", + "error.validation.maxwords": "Ange inte mer än {max} ord", + "error.validation.min": "Ange ett värde som är lika med eller större än {min}", + "error.validation.minlength": "Ange ett längre värde. (minst {min} tecken)", + "error.validation.minwords": "Ange minst {min} ord", + "error.validation.more": "Ange ett större värde än {min}", + "error.validation.notcontains": "Ange ett värde som inte innehåller \"{needle}\"", + "error.validation.notin": "Ange inte något av följande: ({notIn})", + "error.validation.option": "Välj ett giltigt alternativ", + "error.validation.num": "Ange ett giltigt nummer", + "error.validation.required": "Ange någonting", + "error.validation.same": "Ange \"{other}\"", + "error.validation.size": "Storleken av värdet måste vara \"{size}\"", + "error.validation.startswith": "Värdet måste börja med \"{start}\"", + "error.validation.tel": "Ange ett oformaterat telefonnummer", + "error.validation.time": "Ange en giltig tid", + "error.validation.time.after": "Ange en tid efter {time}", + "error.validation.time.before": "Ange en tid före {time}", + "error.validation.time.between": "Ange en tid mellan {min} och {max}", + "error.validation.uuid": "Ange ett giltigt UUID", + "error.validation.url": "Ange en giltig URL", + + "expand": "Expandera", + "expand.all": "Expandera alla", + + "field.invalid": "Fältet är ogiltigt", + "field.required": "Fältet krävs", + "field.blocks.changeType": "Ändra typ", + "field.blocks.code.name": "Kod", + "field.blocks.code.language": "Språk", + "field.blocks.code.placeholder": "Din kod …", + "field.blocks.delete.confirm": "Vill du verkligen radera detta block?", + "field.blocks.delete.confirm.all": "Vill du verkligen radera alla block?", + "field.blocks.delete.confirm.selected": "Vill du verkligen radera de valda blocken?", + "field.blocks.empty": "Inga block än", + "field.blocks.fieldsets.empty": "Inga fältuppsättningar ännu", + "field.blocks.fieldsets.label": "Välj en typ av block …", + "field.blocks.fieldsets.paste": "Tryck på {{ shortcut }} för att importera layouter/block från urklipp Endast de som är tillåtna i det aktuella fältet kommer att infogas.", + "field.blocks.gallery.name": "Galleri", + "field.blocks.gallery.images.empty": "Inga bilder än", + "field.blocks.gallery.images.label": "Bilder", + "field.blocks.heading.level": "Nivå", + "field.blocks.heading.name": "Rubrik", + "field.blocks.heading.text": "Text", + "field.blocks.heading.placeholder": "Rubrik …", + "field.blocks.figure.back.plain": "Vanlig", + "field.blocks.figure.back.pattern.light": "Mönster (ljust)", + "field.blocks.figure.back.pattern.dark": "Mönster (mörkt)", + "field.blocks.image.alt": "Alternativ text", + "field.blocks.image.caption": "Rubrik", + "field.blocks.image.crop": "Beskär", + "field.blocks.image.link": "Länk", + "field.blocks.image.location": "Plats", + "field.blocks.image.location.internal": "Denna webbplats", + "field.blocks.image.location.external": "Extern källa", + "field.blocks.image.name": "Bild", + "field.blocks.image.placeholder": "Välj en bild", + "field.blocks.image.ratio": "Bildförhållande", + "field.blocks.image.url": "Bild-URL", + "field.blocks.line.name": "Linje", + "field.blocks.list.name": "Punktlista", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Text", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Citat", + "field.blocks.quote.text.label": "Text", + "field.blocks.quote.text.placeholder": "Citat …", + "field.blocks.quote.citation.label": "Citat", + "field.blocks.quote.citation.placeholder": "av …", + "field.blocks.text.name": "Text", + "field.blocks.text.placeholder": "Text …", + "field.blocks.video.autoplay": "Autospela", + "field.blocks.video.caption": "Rubrik", + "field.blocks.video.controls": "Kontroller", + "field.blocks.video.location": "Plats", + "field.blocks.video.loop": "Loopa", + "field.blocks.video.muted": "Ljud av", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Ange en URL till en video", + "field.blocks.video.poster": "Stillbild", + "field.blocks.video.preload": "Förladda", + "field.blocks.video.url.label": "Video-URL", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Vill du verkligen radera alla poster?", + "field.entries.empty": "Inga poster än", + + "field.files.empty": "Inga filer valda än", + "field.files.empty.single": "Ingen fil har valts än", + + "field.layout.change": "Ändra layout", + "field.layout.delete": "Radera layout", + "field.layout.delete.confirm": "Vill du verkligen radera denna layout?", + "field.layout.delete.confirm.all": "Vill du verkligen ta bort alla layouter?", + "field.layout.empty": "Inga rader än", + "field.layout.select": "Välj en layout", + + "field.object.empty": "Ingen information ännu", + + "field.pages.empty": "Inga sidor valda än", + "field.pages.empty.single": "Ingen sida har valts än", + + "field.structure.delete.confirm": "Vill du verkligen radera denna rad?", + "field.structure.delete.confirm.all": "Vill du verkligen radera alla poster?", + "field.structure.empty": "Inga poster än", + + "field.users.empty": "Inga användare valda än", + "field.users.empty.single": "Ingen användare har valts än", + + "fields.empty": "Inga fält ännu", + + "file": "Fil", + "file.blueprint": "Denna fil har ingen blueprint än. Du kan skapa en i /site/blueprints/files/{blueprint}.yml", + "file.changeTemplate": "Ändra mall", + "file.changeTemplate.notice": "Att ändra filens mall kommer att ta bort innehåll för fält som inte matchar fältets typ. Om den nya mallen definierar vissa regler, t.ex. bilddimensioner, kommer de också att tillämpas oåterkalleligt. Använd med försiktighet.", + "file.delete.confirm": "Vill du verkligen radera
{filename}?", + "file.focus.placeholder": "Ange fokuspunkt", + "file.focus.reset": "Ta bort fokuspunkt", + "file.focus.title": "Fokus", + "file.sort": "Ändra position", + + "files": "Filer", + "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", + + "form.discard": "Kassera ändringar", + "form.discard.confirm": "Vill du verkligen kassera dina ändringar?", + "form.locked": "Detta innehåll är inaktiverat för dig eftersom det för närvarande redigeras av en annan användare", + "form.unsaved": "De aktuella ändringarna har inte sparats än", + "form.preview": "Förhandsgranska ändringar", + "form.preview.draft": "Förhandsgranska utkast", + + "hide": "Göm", + "hour": "Timme", + "hue": "Nyans", + "import": "Importera", + "info": "Info", + "insert": "Infoga", + "insert.after": "Infoga efter", + "insert.before": "Infoga före", + "install": "Installera", + + "installation": "Installation", + "installation.completed": "Panelen har installerats", + "installation.disabled": "Installeraren för panelen är som standard inaktiverad på offentliga servrar. Kör installeraren på en lokal maskin eller aktivera den med alternativet panel.install.", + "installation.issues.accounts": "Mappen /site/accounts finns inte eller är inte skrivbar", + "installation.issues.content": "Mappen /content finns inte eller är inte skrivbar", + "installation.issues.curl": "Tillägget CURL krävs", + "installation.issues.headline": "Panelen kan inte installeras", + "installation.issues.mbstring": "Tillägget MB String krävs", + "installation.issues.media": "Mappen /media finns inte eller är inte skrivbar", + "installation.issues.php": "Se till att du använder PHP 8+", + "installation.issues.sessions": "Mappen /site/sessions finns inte eller är inte skrivbar", + + "language": "Spr\u00e5k", + "language.code": "Kod", + "language.convert": "Ange som standard", + "language.convert.confirm": "

Vill du verkligen göra {name} till standardspråket? Detta kan inte ångras.

Om {name} har oöversatt innehåll, kommer det inte längre finnas en alternativ översättning och delar av sajten kommer kanske att vara tom.

", + "language.create": "Lägg till ett nytt språk", + "language.default": "Standardspråk", + "language.delete.confirm": "Vill du verkligen radera språket {name} inklusive alla översättningar? Detta kan inte ångras!", + "language.deleted": "Språket har raderats", + "language.direction": "Läsriktning", + "language.direction.ltr": "Vänster till höger", + "language.direction.rtl": "Höger till vänster", + "language.locale": "PHP locale string", + "language.locale.warning": "Du använder en anpassad språkinställning. Ändra den i språkfilen i mappen /site/languages", + "language.name": "Namn", + "language.secondary": "Sekundärt språk", + "language.settings": "Språkinställningar", + "language.updated": "Språket har uppdaterats", + "language.variables": "Språkvariabler", + "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", + + "languages": "Språk", + "languages.default": "Standardspråk", + "languages.empty": "Det finns inga språk ännu", + "languages.secondary": "Sekundära språk", + "languages.secondary.empty": "Det finns inga sekundära språk ännu", + + "license": "Licens", + "license.activate": "Aktivera nu", + "license.activate.label": "Vänligen aktivera din licens", + "license.activate.domain": "Din licens kommer att vara aktiverad för {host}.", + "license.activate.local": "Du är på väg att aktivera din Kirby-licens för din lokala domän {host}. Om den här webbplatsen kommer att publiceras på en offentlig domän, aktivera den där istället. Om {host} är domänen du vill använda din licens för, fortsätt.", + "license.activated": "Aktiverad", + "license.buy": "Köp en licens", + "license.code": "Kod", + "license.code.help": "Du fick din licenskod efter köpet via e-post. Kopiera och klistra in det här.", + "license.code.label": "Ange din licenskod", + "license.status.active.info": "Inkluderar nya större versioner fram till {date}", + "license.status.active.label": "Giltig licens", + "license.status.demo.info": "Detta är en demoinstallation", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Förnya licensen för att uppdatera till nyare större versioner", + "license.status.inactive.label": "Inga nya större versioner", + "license.status.legacy.bubble": "Är du redo att förnya din licens?", + "license.status.legacy.info": "Din licens täcker inte denna version", + "license.status.legacy.label": "Vänligen förnya din licens", + "license.status.missing.bubble": "Är du redo att lansera din webbplats?", + "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": "Okänd", + "license.manage": "Hantera dina licenser", + "license.purchased": "Köpt", + "license.success": "Tack för att du stödjer Kirby", + "license.unregistered.label": "Oregistrerad", + + "link": "L\u00e4nk", + "link.text": "L\u00e4nktext", + + "loading": "Laddar", + + "lock.unsaved": "Osparade ändringar", + "lock.unsaved.empty": "Det finns inga fler osparade ändringar", + "lock.unsaved.files": "Osparade filer", + "lock.unsaved.pages": "Osparade sidor", + "lock.unsaved.users": "Osparade konton", + "lock.isLocked": "Osparade ändringar av {email}", + "lock.unlock": "Lås upp", + "lock.unlock.submit": "Lås upp och skriv över osparade ändringar av {email}", + "lock.isUnlocked": "Låstes upp av en annan användare", + + "login": "Logga in", + "login.code.label.login": "Inloggningskod", + "login.code.label.password-reset": "Kod för återställning av lösenord", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "Om din e-postadress är registrerad skickades den begärda koden via e-post.", + "login.code.text.totp": "Ange engångskoden från din autentiseringsapp.", + "login.email.login.body": "Hej {user.nameOrEmail}.\n\nDu begärde nyligen en inloggningskod till panelen för {site}.\nFöljande kod är giltig i {timeout} minuter:\n\n{code}\n\nOm du inte har begärt någon inloggningskod, ignorera detta e-postmeddelande eller kontakta din administratör om du har frågor.\nAv säkerhetsskäl, vidarebefordra INTE detta e-postmeddelande.", + "login.email.login.subject": "Din inloggningskod", + "login.email.password-reset.body": "Hej {user.nameOrEmail}.\n\nDu begärde nyligen en kod för återställning av ditt lösenord till panelen för {site}.\nFöljande kod är giltig i {timeout} minuter:\n\n{code}\n\nOm du inte har begärt en återställning av ditt lösenord, ignorera detta e-postmeddelande eller kontakta din administratör om du har frågor.\nAv säkerhetsskäl, vidarebefordra INTE detta e-postmeddelande.", + "login.email.password-reset.subject": "Din kod för återställning av lösenord", + "login.remember": "Håll mig inloggad", + "login.reset": "Återställ lösenord", + "login.toggleText.code.email": "Logga in via e-post", + "login.toggleText.code.email-password": "Logga in med lösenord", + "login.toggleText.password-reset.email": "Glömt ditt lösenord?", + "login.toggleText.password-reset.email-password": "← Tillbaka till inloggning", + "login.totp.enable.option": "Ställ in engångskoder", + "login.totp.enable.intro": "Autentiseringsappar kan generera engångskoder som används som en andra faktor när du loggar in på ditt konto.", + "login.totp.enable.qr.label": "1. Skanna den här QR-koden", + "login.totp.enable.qr.help": "Kan du inte skanna? Lägg till inställningsnyckeln {secret} manuellt i din autentiseringsapp.", + "login.totp.enable.confirm.headline": "2. Bekräfta med genererad kod", + "login.totp.enable.confirm.text": "Din app genererar en ny engångskod var 30:e sekund. Ange den aktuella koden för att slutföra installationen:", + "login.totp.enable.confirm.label": "Nuvarande kod", + "login.totp.enable.confirm.help": "Efter denna installation kommer vi att be dig om en engångskod varje gång du loggar in.", + "login.totp.enable.success": "Engångskoder aktiverade", + "login.totp.disable.option": "Inaktivera engångskoder", + "login.totp.disable.label": "Ange ditt lösenord för att inaktivera engångskoder", + "login.totp.disable.help": "I fortsättningen kommer en annan andra faktor som en inloggningskod som skickas via e-post att begäras när du loggar in. Du kan alltid ställa in engångskoder igen senare.", + "login.totp.disable.admin": "

Detta kommer att inaktivera engångskoder för {user}.

I fortsättningen kommer en annan andra faktor som en inloggningskod som skickas via e-post att begäras när de loggar in. {user} kan ställa in engångskoder igen efter nästa inloggning.

", + "login.totp.disable.success": "Engångskoder inaktiverade", + + "logout": "Logga ut", + + "merge": "Slå ihop", + "menu": "Meny", + "meridiem": "a.m./p.m.", + "mime": "Mediatyp", + "minutes": "Minuter", + + "month": "Månad", + "months.april": "April", + "months.august": "Augusti", + "months.december": "December", + "months.february": "Februari", + "months.january": "Januari", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "Mars", + "months.may": "Maj", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Mer", + "move": "Flytta", + "name": "Namn", + "next": "Nästa", + "night": "Natt", + "no": "nej", + "off": "av", + "on": "på", + "open": "Öppna", + "open.newWindow": "Öppna i nytt fönster", + "option": "Alternativ", + "options": "Alternativ", + "options.none": "Inga alternativ", + "options.all": "Visa alla {count} alternativ", + + "orientation": "Orientering", + "orientation.landscape": "Liggande", + "orientation.portrait": "Stående", + "orientation.square": "Kvadrat", + + "page": "Sida", + "page.blueprint": "Denna sida har ingen blueprint än. Du kan skapa en i /site/blueprints/pages/{blueprint}.yml", + "page.changeSlug": "Ändra URL", + "page.changeSlug.fromTitle": "Skapa utifr\u00e5n titel", + "page.changeStatus": "Ändra status", + "page.changeStatus.position": "Välj en ny position", + "page.changeStatus.select": "Välj en ny status", + "page.changeTemplate": "Ändra mall", + "page.changeTemplate.notice": "Att ändra filens mall kommer att ta bort innehåll för fält som inte matchar fältets typ. Använd med försiktighet.", + "page.create": "Skapa som {status}", + "page.delete.confirm": "Vill du verkligen radera {title}?", + "page.delete.confirm.subpages": "Denna sida har undersidor.
Alla undersidor kommer också att raderas.", + "page.delete.confirm.title": "Fyll i sidans titel för att bekräfta", + "page.duplicate.appendix": "Kopiera", + "page.duplicate.files": "Kopiera filer", + "page.duplicate.pages": "Kopiera sidor", + "page.move": "Flytta sidan", + "page.sort": "Ändra position", + "page.status": "Status", + "page.status.draft": "Utkast", + "page.status.draft.description": "Sidan är ett utkast och endast synlig för inloggade redaktörer eller via en hemlig länk", + "page.status.listed": "Publik", + "page.status.listed.description": "Sidan är publik för vem som helst", + "page.status.unlisted": "Olistad", + "page.status.unlisted.description": "Sidan är endast åtkomlig via URL", + + "pages": "Sidor", + "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", + "pages.status.unlisted": "Olistade", + + "pagination.page": "Sida", + + "password": "L\u00f6senord", + "paste": "Klistra in", + "paste.after": "Klistra in efter", + "paste.success": "{count} har klistrats in!", + "pixel": "Pixel", + "plugin": "Tillägg", + "plugins": "Tillägg", + "prev": "Föregående", + "preview": "Förhandsgranska", + + "publish": "Publicera", + "published": "Publicerade", + + "remove": "Ta bort", + "rename": "Byt namn", + "renew": "Förnya", + "replace": "Ersätt", + "replace.with": "Ersätt med", + "retry": "F\u00f6rs\u00f6k igen", + "revert": "Återgå", + "revert.confirm": "Vill du verkligen radera alla osparade ändringar?", + + "role": "Roll", + "role.admin.description": "Administratören har alla behörigheter", + "role.admin.title": "Administratör", + "role.all": "Alla", + "role.empty": "Det finns inga användare med denna roll", + "role.description.placeholder": "Ingen beskrivning", + "role.nobody.description": "Detta är en roll utan några behörigheter", + "role.nobody.title": "Ingen", + + "save": "Spara", + "saved": "Sparad", + "search": "Sök", + "searching": "Söker", + "search.min": "Ange {min} tecken för att söka", + "search.all": "Visa alla {count} resultat", + "search.results.none": "Inga träffar", + + "section.invalid": "Sektionen är ogiltig", + "section.required": "Sektionen krävs", + + "security": "Säkerhet", + "select": "Välj", + "server": "Server", + "settings": "Inställningar", + "show": "Visa", + "site.blueprint": "Webbplatsen har ingen blueprint än. Du kan skapa en i /site/blueprints/site.yml", + "size": "Storlek", + "slug": "URL-appendix", + "sort": "Sortera", + "sort.drag": "Dra för att sortera …", + "split": "Dela", + + "stats.empty": "Inga rapporter", + "status": "Status", + + "system.info.copy": "Kopiera info", + "system.info.copied": "Systeminformation kopierad", + "system.issues.content": "Mappen content verkar vara exponerad", + "system.issues.eol.kirby": "Din installerade Kirby-version har nått slutet av sin livscykel och kommer inte att få fler säkerhetsuppdateringar", + "system.issues.eol.plugin": "Den installerade versionen av tillägget { plugin } har nått slutet på sin livscykel och kommer inte att få fler säkerhetsuppdateringar.", + "system.issues.eol.php": "Din installerade PHP-version { release } har nått slutet av sin livslängd och kommer inte att få fler säkerhetsuppdateringar", + "system.issues.debug": "Felsökningsläget måste vara avstängt i produktion", + "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": "Sajten drivs lokalt med förenklade säkerhetskontroller", + "system.issues.site": "Mappen site verkar vara exponerad", + "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", + "system.updateStatus.error": "Det gick inte att söka efter uppdateringar", + "system.updateStatus.not-vulnerable": "Inga kända sårbarheter", + "system.updateStatus.security-update": "Gratis säkerhetsuppdatering { version } tillgänglig", + "system.updateStatus.security-upgrade": "Uppgradering { version } med säkerhetskorrigeringar är tillgänglig", + "system.updateStatus.unreleased": "Osläppt version", + "system.updateStatus.up-to-date": "Uppdaterad", + "system.updateStatus.update": "Gratis uppdatering { version } tillgänglig", + "system.updateStatus.upgrade": "Uppgradering { version } tillgänglig", + + "tel": "Telefon", + "tel.placeholder": "+46701234567", + "template": "Mall", + + "theme": "Tema", + "theme.light": "Ljus på", + "theme.dark": "Ljus av", + "theme.automatic": "Matcha systemstandard", + + "title": "Titel", + "today": "Idag", + + "toolbar.button.clear": "Rensa formatering", + "toolbar.button.code": "Kod", + "toolbar.button.bold": "Fet", + "toolbar.button.email": "E-post", + "toolbar.button.headings": "Rubriker", + "toolbar.button.heading.1": "Rubrik 1", + "toolbar.button.heading.2": "Rubrik 2", + "toolbar.button.heading.3": "Rubrik 3", + "toolbar.button.heading.4": "Rubrik 4", + "toolbar.button.heading.5": "Rubrik 5", + "toolbar.button.heading.6": "Rubrik 6", + "toolbar.button.italic": "Kursiv", + "toolbar.button.file": "Fil", + "toolbar.button.file.select": "Välj en fil", + "toolbar.button.file.upload": "Ladda upp en fil", + "toolbar.button.link": "L\u00e4nk", + "toolbar.button.paragraph": "Stycke", + "toolbar.button.strike": "Genomstruken", + "toolbar.button.sub": "Nedsänkt", + "toolbar.button.sup": "Upphöjd", + "toolbar.button.ol": "Sorterad lista", + "toolbar.button.underline": "Understruken", + "toolbar.button.ul": "Punktlista", + + "translation.author": "Kirby-teamet, Ola Christensson", + "translation.direction": "ltr", + "translation.name": "Svenska", + "translation.locale": "sv_SE", + + "type": "Typ", + + "upload": "Ladda upp", + "upload.error.cantMove": "Den överförda filen kunde inte flyttas", + "upload.error.cantWrite": "Det gick inte att skriva filen till hårddisken", + "upload.error.default": "Filen kunde inte laddas upp", + "upload.error.extension": "Filuppladdningen förhindrades på grund av filändelsen", + "upload.error.formSize": "Den överförda filen överskrider den maximala filstorlek som anges i formuläret (MAX_FILE_SIZE)", + "upload.error.iniPostSize": "Den överförda filen överskrider post_max_size-direktivet i php.ini", + "upload.error.iniSize": "Den överförda filen överskrider direktivet upload_max_filesize i php.ini", + "upload.error.noFile": "Ingen fil laddades upp", + "upload.error.noFiles": "Inga filer laddades upp", + "upload.error.partial": "Den överförda filen laddades bara delvis upp", + "upload.error.tmpDir": "Saknar en temporär mapp", + "upload.errors": "Fel", + "upload.progress": "Laddar upp...", + + "url": "URL", + "url.placeholder": "https://exempel.se", + + "user": "Användare", + "user.blueprint": "Du kan skapa ytterligare sektioner och fält för den här användarrollen i /site/blueprints/users/{blueprint}.yml", + "user.changeEmail": "Ändra e-postadress", + "user.changeLanguage": "Ändra språk", + "user.changeName": "Byt namn på denna användare", + "user.changePassword": "Ändra lösenord", + "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", + "user.changeRole.select": "Välj en ny roll", + "user.create": "Lägg till en ny användare", + "user.delete": "Radera denna användare", + "user.delete.confirm": "Vill du verkligen radera
{email}?", + + "users": "Användare", + + "version": "Version", + "version.changes": "Ändrad version", + "version.compare": "Jämför versioner", + "version.current": "Aktuell version", + "version.latest": "Senaste version", + "versionInformation": "Versionsinformation", + + "view": "Visa", + "view.account": "Ditt konto", + "view.installation": "Installation", + "view.languages": "Språk", + "view.resetPassword": "Återställ lösenord", + "view.site": "Webbplats", + "view.system": "System", + "view.users": "Anv\u00e4ndare", + + "welcome": "Välkommen", + "year": "År", + "yes": "ja" +} diff --git a/public/kirby/i18n/translations/tr.json b/public/kirby/i18n/translations/tr.json new file mode 100644 index 0000000..2838b7d --- /dev/null +++ b/public/kirby/i18n/translations/tr.json @@ -0,0 +1,799 @@ +{ + "account.changeName": "İsminizi değiştirin", + "account.delete": "Hesabınızı silin", + "account.delete.confirm": "Hesabınızı gerçekten silmek istiyor musunuz? Oturumunuz hemen sonlandırılacaktır. Hesabınız daha sonra geri alınamaz.", + + "activate": "Etkinleştir", + "add": "Ekle", + "alpha": "Alfa", + "author": "Yazar", + "avatar": "Profil resmi", + "back": "Geri", + "cancel": "\u0130ptal", + "change": "De\u011fi\u015ftir", + "close": "Kapat", + "changes": "Değişiklikler", + "confirm": "Tamam", + "collapse": "Daralt", + "collapse.all": "Tümünü daralt", + "color": "Renk", + "coordinates": "Koordinatlar", + "copy": "Kopyala", + "copy.all": "Tümünü kopyala", + "copy.success": "{count} kopyalandı!", + "copy.success.multiple": "{count} kopyalandı!", + "copy.url": "URL kopyala", + "create": "Oluştur", + "custom": "Özel", + + "date": "Tarih", + "date.select": "Bir tarih seçiniz", + + "day": "Gün", + "days.fri": "Cum", + "days.mon": "Pzt", + "days.sat": "Cmt", + "days.sun": "Paz", + "days.thu": "Per", + "days.tue": "Sal", + "days.wed": "\u00c7ar", + + "debugging": "Hata ayıklama", + + "delete": "Sil", + "delete.all": "Tümünü sil", + + "dialog.fields.empty": "Bu iletişim kutusunda alan yok", + "dialog.files.empty": "Seçilecek dosya yok", + "dialog.pages.empty": "Seçilecek sayfa yok", + "dialog.text.empty": "Bu iletişim kutusu herhangi bir metin tanımlamaz", + "dialog.users.empty": "Seçilecek kullanıcı yok", + + "dimensions": "Boyutlar", + "disable": "Devre dışı bırak", + "disabled": "Devredışı", + "discard": "Vazge\u00e7", + + "drawer.fields.empty": "Bu çekmecede alan yok", + + "domain": "Alan adı", + "download": "İndir", + "duplicate": "Kopyala", + + "edit": "D\u00fczenle", + + "email": "E-Posta", + "email.placeholder": "eposta@ornek.com", + + "enter": "Giriş", + "entries": "Girdiler", + "entry": "Girdi", + + "environment": "Ortam", + + "error": "Hata", + "error.access.code": "Geçersiz kod", + "error.access.login": "Geçersiz giriş", + "error.access.panel": "Panel'e erişim izniniz yok", + "error.access.view": "Panel'in bu bölümüne erişim izniniz yok", + + "error.avatar.create.fail": "Profil resmi yüklenemedi", + "error.avatar.delete.fail": "Profil resmi silinemedi", + "error.avatar.dimensions.invalid": "Lütfen profil resminin genişliğini ve yüksekliğini 3000 pikselin altında tutun", + "error.avatar.mime.forbidden": "Profil resmi JPEG veya PNG dosyaları olmalıdır", + + "error.blueprint.notFound": "\"{name}\" adlı plan yüklenemedi", + + "error.blocks.max.plural": "{max} bloktan fazlasını eklememelisiniz", + "error.blocks.max.singular": "Birden fazla blok eklememelisiniz", + "error.blocks.min.plural": "En az {min} blok eklemelisiniz", + "error.blocks.min.singular": "En az bir blok eklemelisiniz", + "error.blocks.validation": "\"{fieldset}\" blok türünü kullanan {index}. bloktaki \"{field}\" alanında bir hata var", + + "error.cache.type.invalid": "Geçersiz önbellek türü \"{type}\"", + + "error.content.lock.delete": "Bu sürüm kilitlidir ve silinemez", + "error.content.lock.move": "Bu kaynak sürümü kilitlidir ve taşınamaz", + "error.content.lock.publish": "Bu sürüm zaten yayınlandı", + "error.content.lock.replace": "Bu sürüm kilitlidir ve değiştirilemez", + "error.content.lock.update": "Bu sürüm kilitlidir ve güncellenemez", + + "error.entries.max.plural": "{max} girdiden fazlasını eklememelisiniz", + "error.entries.max.singular": "Birden fazla girdi eklememelisiniz", + "error.entries.min.plural": "En az {min} girdi eklemelisiniz", + "error.entries.min.singular": "En az bir girdi eklemelisiniz", + "error.entries.supports": "\"{type}\" alan türü girdiler alanı için desteklenmiyor", + "error.entries.validation": "{index} satırındaki \"{field}\" alanında bir hata var", + + "error.email.preset.notFound": "\"{name}\" e-posta adresi bulunamadı", + + "error.field.converter.invalid": "Geçersiz dönüştürücü \"{converter}\"", + "error.field.link.options": "Geçersiz seçenekler: {options}", + "error.field.type.missing": "\"{ name }\" alanı: \"{ type }\" alan türü mevcut değil", + + "error.file.changeName.empty": "İsim boş olmamalıdır", + "error.file.changeName.permission": "\"{filename}\" adını değiştiremezsiniz", + "error.file.changeTemplate.invalid": "\"{id}\" dosyası için şablon \"{template}\" olarak değiştirilemez (geçerli: \"{blueprints}\")", + "error.file.changeTemplate.permission": "\"{id}\" dosyası için şablonu değiştirmenize izin verilmiyor", + + "error.file.delete.multiple": "Tüm dosyalar silinemedi. Silinmeyi engelleyen belirli hatayı görmek için kalan her dosyayı ayrı ayrı deneyin.", + "error.file.duplicate": "\"{filename}\" isimli bir dosya zaten var", + "error.file.extension.forbidden": "\"{extension}\" dosya uzantısına izin verilmiyor", + "error.file.extension.invalid": "Geçersiz uzantı: {extension}", + "error.file.extension.missing": "\"{filename}\" dosyasının uzantısı yok", + "error.file.maxheight": "Resmin yüksekliği {height} pikselden büyük olmamalıdır", + "error.file.maxsize": "Dosya çok büyük", + "error.file.maxwidth": "Resmin genişliği {width} pikselden büyük olmamalıdır", + "error.file.mime.differs": "Yüklenen dosya aynı dosya türü \"{mime}\" olmalıdır", + "error.file.mime.forbidden": "\"{mime}\" medya türüne izin verilmiyor", + "error.file.mime.invalid": "Geçersiz medya türü: {mime}", + "error.file.mime.missing": "\"{filename}\" için medya türü tespit edilemiyor", + "error.file.minheight": "Resmin yüksekliği en az {height} piksel olmalıdır", + "error.file.minsize": "Dosya çok küçük", + "error.file.minwidth": "Resmin genişliği en az {width} piksel olmalıdır", + "error.file.name.unique": "Dosya adı benzersiz olmalıdır", + "error.file.name.missing": "Dosya adı boş bırakılamaz", + "error.file.notFound": "\"{filename}\" dosyası bulunamadı", + "error.file.orientation": "Resmin oryantasyonu \"{orientation}\" olmalıdır", + "error.file.sort.permission": "\"{filename}\" dosyasının sıralamasını değiştiremezsiniz", + "error.file.type.forbidden": "{type} dosya yükleme izni yok", + "error.file.type.invalid": "Geçersiz dosya türü: {type}", + "error.file.undefined": "Dosya bulunamad\u0131", + + "error.form.incomplete": "Lütfen tüm form hatalarını düzeltin...", + "error.form.notSaved": "Form kaydedilemedi", + + "error.language.code": "Lütfen dil için geçerli bir kod girin", + "error.language.create.permission": "Bir dil oluşturmanıza izin verilmiyor", + "error.language.delete.permission": "Dili silmenize izin verilmiyor", + "error.language.duplicate": "Bu dil zaten var", + "error.language.name": "Lütfen dil için geçerli bir isim girin", + "error.language.notFound": "Dil bulunamadı", + "error.language.update.permission": "Dili güncellemenize izin verilmiyor", + + "error.layout.validation.block": "{layoutIndex}. sıradaki düzende \"{fieldset}\" blok türünü kullanan {blockIndex}. bloktaki \"{field}\" alanında bir hata var", + "error.layout.validation.settings": "{index}. düzen ayarlarında bir hata var", + + "error.license.domain": "Lisans için alan adı eksik", + "error.license.email": "Lütfen geçerli bir e-posta adresi girin", + "error.license.format": "Lütfen geçerli bir lisans anahtarı girin", + "error.license.verification": "Lisans doğrulanamadı", + + "error.login.totp.confirm.invalid": "Geçersiz kod", + "error.login.totp.confirm.missing": "Lütfen geçerli kodu girin", + + "error.object.validation": "\"{label}\" alanında bir hata var:\n{message}", + + "error.offline": "Panel şu anda çevrimdışı", + + "error.page.changeSlug.permission": "\"{slug}\" uzantısına sahip bu sayfanın adresini değiştirilemez", + "error.page.changeSlug.reserved": "Üst düzey sayfaların yolu \"{path}\" ile başlamamalıdır", + "error.page.changeStatus.incomplete": "Sayfada hatalar var ve yayınlanamadı", + "error.page.changeStatus.permission": "Bu sayfanın durumu değiştirilemez", + "error.page.changeStatus.toDraft.invalid": "\"{slug}\" sayfası bir taslak haline dönüştürülemiyor", + "error.page.changeTemplate.invalid": "\"{slug}\" sayfası için şablon değiştirilemiyor", + "error.page.changeTemplate.permission": "\"{slug}\" için şablonu değiştiremezsiniz", + "error.page.changeTitle.empty": "Başlık boş bırakılamaz", + "error.page.changeTitle.permission": "\"{slug}\" için başlığı değiştiremezsiniz", + "error.page.create.permission": "\"{slug}\" oluşturmanıza izin verilmiyor", + "error.page.delete": "\"{slug}\" sayfası silinemedi", + "error.page.delete.confirm": "Onaylamak için sayfa başlığını girin", + "error.page.delete.hasChildren": "Sayfada alt sayfalar var ve silinemiyor", + "error.page.delete.multiple": "Tüm sayfalar silinemedi. Silinmeyi engelleyen belirli hatayı görmek için kalan her sayfayı ayrı ayrı deneyin.", + "error.page.delete.permission": "\"{slug}\" öğesini silmenize izin verilmiyor", + "error.page.draft.duplicate": "\"{slug}\" adres eki olan bir sayfa taslağı zaten mevcut", + "error.page.duplicate": "\"{slug}\" adres eki içeren bir sayfa zaten mevcut", + "error.page.duplicate.permission": "\"{slug}\" öğesini çoğaltmanıza izin verilmiyor", + "error.page.move.ancestor": "Sayfa kendi içine taşınamaz", + "error.page.move.directory": "Sayfa dizini taşınamaz", + "error.page.move.duplicate": "\"{slug}\" URL ekine sahip bir alt sayfa zaten mevcut", + "error.page.move.noSections": "\"{parent}\" sayfası, planında herhangi bir sayfa bölümü bulunmadığı için herhangi bir sayfanın üst öğesi olamaz", + "error.page.move.notFound": "Taşınan sayfa bulunamadı", + "error.page.move.permission": "\"{slug}\" öğesini taşımanıza izin verilmiyor", + "error.page.move.template": "\"{template}\" şablonu \"{parent}\" alt sayfası olarak kabul edilmiyor", + "error.page.notFound": "\"{slug}\" uzantısındaki sayfa bulunamadı", + "error.page.num.invalid": "Lütfen geçerli bir sıralama numarası girin. Sayılar negatif olmamalıdır.", + "error.page.slug.invalid": "Lütfen geçerli bir URL eki girin", + "error.page.slug.maxlength": "Adres uzantısı \"{length}\" karakterden az olmalıdır", + "error.page.sort.permission": "\"{slug}\" sayfası sıralanamıyor", + "error.page.status.invalid": "Lütfen geçerli bir sayfa durumu ayarlayın", + "error.page.undefined": "Sayfa bulunamad\u0131", + "error.page.update.permission": "\"{slug}\" güncellemesine izin verilmiyor", + + "error.section.files.max.plural": "\"{section}\" bölümüne {max} dosyadan daha fazlasını eklememelisiniz", + "error.section.files.max.singular": "\"{section}\" bölümüne birden fazla dosya eklememelisiniz", + "error.section.files.min.plural": "\"{section}\" bölümü en az {min} dosya gerektiriyor", + "error.section.files.min.singular": "\"{section}\" bölümü en az bir dosya gerektiriyor", + + "error.section.pages.max.plural": "\"{section}\" bölümüne maksimum {max} sayfadan fazla ekleyemezsiniz", + "error.section.pages.max.singular": "\"{section}\" bölümüne birden fazla sayfa ekleyemezsiniz", + "error.section.pages.min.plural": "\"{section}\" bölümü en az {min} sayfa gerektiriyor", + "error.section.pages.min.singular": "\"{section}\" bölümü en az bir sayfa gerektiriyor", + + "error.section.notLoaded": "\"{name}\" bölümü yüklenemedi", + "error.section.type.invalid": "\"{type}\" tipi geçerli değil", + + "error.site.changeTitle.empty": "Başlık boş bırakılamaz", + "error.site.changeTitle.permission": "Sitenin başlığını değiştiremezsin", + "error.site.update.permission": "Siteyi güncellemenize izin verilmiyor", + + "error.structure.validation": "{index} satırındaki \"{field}\" alanında bir hata var", + + "error.template.default.notFound": "Varsayılan şablon yok", + + "error.unexpected": "Beklenmeyen bir hata oluştu! Daha fazla bilgi için hata ayıklama modunu etkinleştirin: https://getkirby.com/docs/reference/system/options/debug", + + "error.user.changeEmail.permission": "\"{name}\" kullanıcısı için e-postayı değiştiremezsiniz", + "error.user.changeLanguage.permission": "\"{name}\" kullanıcısının dilini değiştiremezsin", + "error.user.changeName.permission": "\"{name}\" kullanıcısının adını değiştiremezsiniz", + "error.user.changePassword.permission": "\"{name}\" kullanıcısının şifresini değiştiremezsiniz", + "error.user.changeRole.lastAdmin": "Son yöneticinin rolü değiştirilemez", + "error.user.changeRole.permission": "\"{name}\" kullanıcısının rolünü değiştiremezsin", + "error.user.changeRole.toAdmin": "Birini yönetici rolüne tanıtmanıza izin verilmiyor", + "error.user.create.permission": "Bu kullanıcıyı oluşturmanıza izin verilmiyor", + "error.user.delete": "\"{name}\" kullanıcısı silinemedi", + "error.user.delete.lastAdmin": "Son y\u00f6netici kullan\u0131c\u0131y\u0131 silemezsiniz", + "error.user.delete.lastUser": "Son kullanıcı silinemez", + "error.user.delete.permission": "\"{name}\" kullanıcısını silme yetkiniz yok", + "error.user.duplicate": "\"{email}\" e-posta adresine sahip bir kullanıcı zaten var", + "error.user.email.invalid": "Lütfen geçerli bir e-posta adresi girin", + "error.user.language.invalid": "Lütfen geçerli bir dil girin", + "error.user.notFound": "\"{name}\" kullanıcısı bulunamadı", + "error.user.password.excessive": "Lütfen geçerli bir şifre girin. Şifreler 1000 karakterden uzun olmamalıdır.", + "error.user.password.invalid": "Lütfen geçerli bir şifre giriniz. Şifreler en az 8 karakter uzunluğunda olmalıdır.", + "error.user.password.notSame": "L\u00fctfen \u015fifreyi do\u011frulay\u0131n", + "error.user.password.undefined": "Bu kullanıcının şifresi yok", + "error.user.password.wrong": "Yanlış şifre", + "error.user.role.invalid": "Lütfen geçerli bir rol girin", + "error.user.undefined": "Kullanıcı bulunamadı", + "error.user.update.permission": "\"{name}\" kullanıcısını güncellemenize izin verilmiyor", + + "error.validation.accepted": "Lütfen onaylayın", + "error.validation.alpha": "Lütfen sadece a-z arasındaki karakterleri girin", + "error.validation.alphanum": "Lütfen sadece a-z veya 0-9 arasındaki rakamları girin", + "error.validation.anchor": "Lütfen doğru bir bağlantı çapası girin", + "error.validation.between": "Lütfen \"{min}\" ile \"{max}\" arasında bir değer girin", + "error.validation.boolean": "Lütfen onaylayın veya reddedin", + "error.validation.color": "Lütfen {format} biçiminde geçerli bir renk girin", + "error.validation.contains": "Lütfen \"{needle}\" içeren bir değer girin", + "error.validation.date": "Lütfen geçerli bir tarih girin", + "error.validation.date.after": "Lütfen {date} tarihinden sonra bir tarih girin", + "error.validation.date.before": "Lütfen {date} tarihinden önce bir tarih girin", + "error.validation.date.between": "Lütfen {min} ve {max} arasında bir tarih girin", + "error.validation.denied": "Lütfen reddedin", + "error.validation.different": "Değer \"{other}\" olmamalıdır", + "error.validation.email": "Lütfen geçerli bir e-posta adresi girin", + "error.validation.endswith": "Değer \"{end}\" ile bitmelidir", + "error.validation.filename": "Lütfen geçerli bir dosya adı girin", + "error.validation.in": "Lütfen bunlardan birini girin: ({in})", + "error.validation.integer": "Lütfen geçerli bir tamsayı girin", + "error.validation.ip": "Lütfen geçerli bir ip adresi girin", + "error.validation.less": "Lütfen {max} 'dan daha düşük bir değer girin", + "error.validation.linkType": "Bağlantı türüne izin verilmiyor", + "error.validation.match": "Değer beklenen modelle eşleşmiyor", + "error.validation.max": "Lütfen {max} 'a eşit veya daha küçük bir değer girin", + "error.validation.maxlength": "Lütfen daha kısa bir değer girin. (maks. {max} karakter)", + "error.validation.maxwords": "Lütfen en fazla {max} kelime(ler) girin", + "error.validation.min": "Lütfen {min} ile eşit veya daha büyük bir değer girin", + "error.validation.minlength": "Lütfen daha uzun bir değer girin. (min. {min} karakter)", + "error.validation.minwords": "Lütfen en az {min} kelime(ler) girin", + "error.validation.more": "Lütfen {min} değerinden daha büyük bir değer girin", + "error.validation.notcontains": "Lütfen \"{needle}\" içermeyen bir değer girin", + "error.validation.notin": "Lütfen bunlardan herhangi birini girmeyin: ({notIn})", + "error.validation.option": "Lütfen geçerli bir seçenek girin", + "error.validation.num": "Lütfen geçerli bir sayı girin", + "error.validation.required": "Lütfen birşeyler girin", + "error.validation.same": "Lütfen \"{other}\" yazınız", + "error.validation.size": "Değerin boyutu \"{size}\" olmalıdır", + "error.validation.startswith": "Değer \"{start}\" ile başlamalıdır", + "error.validation.tel": "Lütfen biçimlendirilmemiş bir telefon numarası girin", + "error.validation.time": "Lütfen geçerli bir zaman girin", + "error.validation.time.after": "Lütfen {time} sonrası bir tarih girin", + "error.validation.time.before": "Lütfen {time} öncesi bir tarih girin", + "error.validation.time.between": "Lütfen {min} ile {max} arasında bir tarih girin", + "error.validation.uuid": "Lütfen geçerli bir UUID girin", + "error.validation.url": "Lütfen geçerli bir adres girin", + + "expand": "Genişlet", + "expand.all": "Tümünü genişlet", + + "field.invalid": "Bu alan geçersizdir", + "field.required": "Alan gereklidir", + "field.blocks.changeType": "Türü değiştir", + "field.blocks.code.name": "Kod", + "field.blocks.code.language": "Dil", + "field.blocks.code.placeholder": "Kodunuz …", + "field.blocks.delete.confirm": "Bu bloğu gerçekten silmek istiyor musunuz?", + "field.blocks.delete.confirm.all": "Tüm blokları gerçekten silmek istiyor musunuz?", + "field.blocks.delete.confirm.selected": "Seçilen blokları gerçekten silmek istiyor musunuz?", + "field.blocks.empty": "Henüz blok yok", + "field.blocks.fieldsets.empty": "Henüz alan kümesi yok", + "field.blocks.fieldsets.label": "Lütfen bir blok türü seçiniz …", + "field.blocks.fieldsets.paste": "Panonuzdan düzenleri/blokları içe aktarmak için {{ shortcut }} tuşuna basın Yalnızca geçerli alanda izin verilenler eklenecektir.", + "field.blocks.gallery.name": "Galeri", + "field.blocks.gallery.images.empty": "Henüz görsel yok", + "field.blocks.gallery.images.label": "Görseller", + "field.blocks.heading.level": "Seviye", + "field.blocks.heading.name": "Başlık", + "field.blocks.heading.text": "Metin", + "field.blocks.heading.placeholder": "Başlık …", + "field.blocks.figure.back.plain": "Düz", + "field.blocks.figure.back.pattern.light": "Desen (açık)", + "field.blocks.figure.back.pattern.dark": "Desen (koyu)", + "field.blocks.image.alt": "Alternatif metin", + "field.blocks.image.caption": "Altyazı", + "field.blocks.image.crop": "Kırp", + "field.blocks.image.link": "Bağlantı", + "field.blocks.image.location": "Lokasyon", + "field.blocks.image.location.internal": "Bu website", + "field.blocks.image.location.external": "Dış kaynak", + "field.blocks.image.name": "Görsel", + "field.blocks.image.placeholder": "Bir görsel seçin", + "field.blocks.image.ratio": "Oran", + "field.blocks.image.url": "Görsel URL", + "field.blocks.line.name": "Çizgi", + "field.blocks.list.name": "Liste", + "field.blocks.markdown.name": "Markdown", + "field.blocks.markdown.label": "Metin", + "field.blocks.markdown.placeholder": "Markdown …", + "field.blocks.quote.name": "Alıntı", + "field.blocks.quote.text.label": "Metin", + "field.blocks.quote.text.placeholder": "Alıntı …", + "field.blocks.quote.citation.label": "Alıntı", + "field.blocks.quote.citation.placeholder": "yazar …", + "field.blocks.text.name": "Metin", + "field.blocks.text.placeholder": "Metin …", + "field.blocks.video.autoplay": "Otomatik oynatma", + "field.blocks.video.caption": "Altyazı", + "field.blocks.video.controls": "Kontroller", + "field.blocks.video.location": "Lokasyon", + "field.blocks.video.loop": "Döngü", + "field.blocks.video.muted": "Sessiz", + "field.blocks.video.name": "Video", + "field.blocks.video.placeholder": "Bir video URL'si girin", + "field.blocks.video.poster": "Kapak", + "field.blocks.video.preload": "Önyükleme", + "field.blocks.video.url.label": "Video-URL", + "field.blocks.video.url.placeholder": "https://youtube.com/?v=", + + "field.entries.delete.confirm.all": "Tüm girdileri gerçekten silmek istiyor musunuz?", + "field.entries.empty": "Henüz bir girdi yok", + + "field.files.empty": "Henüz dosya seçilmedi", + "field.files.empty.single": "Henüz dosya seçilmedi", + + "field.layout.change": "Düzeni değiştir", + "field.layout.delete": "Düzeni sil", + "field.layout.delete.confirm": "Bu düzeni gerçekten silmek istiyor musunuz?", + "field.layout.delete.confirm.all": "Gerçekten tüm düzenleri silmek istiyor musunuz?", + "field.layout.empty": "Henüz satır yok", + "field.layout.select": "Bir düzen seçin", + + "field.object.empty": "Henüz bilgi yok", + + "field.pages.empty": "Henüz sayfa seçilmedi", + "field.pages.empty.single": "Henüz sayfa seçilmedi", + + "field.structure.delete.confirm": "Bu girdiyi silmek istedi\u011finizden emin misiniz?", + "field.structure.delete.confirm.all": "Tüm girdileri gerçekten silmek istiyor musunuz?", + "field.structure.empty": "Hen\u00fcz bir girdi yok", + + "field.users.empty": "Henüz kullanıcı seçilmedi", + "field.users.empty.single": "Henüz kullanıcı seçilmedi", + + "fields.empty": "Henüz alan yok", + + "file": "Dosya", + "file.blueprint": "Bu dosyanın henüz bir planı yok. Kurulumu /site/blueprints/files/{blueprint}.yml dosyasında tanımlayabilirsiniz.", + "file.changeTemplate": "Şablonu değiştir", + "file.changeTemplate.notice": "Dosyanın şablonunun değiştirilmesi, tür olarak eşleşmeyen alanların içeriğini kaldıracaktır. Yeni şablon, görüntü boyutları gibi belirli kuralları tanımlıyorsa, bunlar da geri döndürülemez şekilde uygulanacaktır. Dikkatli kullanın.", + "file.delete.confirm": "{filename} dosyasını silmek istediğinizden emin misiniz?", + "file.focus.placeholder": "Odak noktasını belirleyin", + "file.focus.reset": "Odak noktasını kaldırın", + "file.focus.title": "Odak", + "file.sort": "Pozisyon değiştir", + + "files": "Dosyalar", + "files.delete.confirm.selected": "Seçili dosyaları gerçekten silmek istiyor musunuz? Bu eylem geri alınamaz.", + "files.empty": "Henüz dosya yok", + + "filter": "Filtre", + + "form.discard": "Değişiklikleri iptal et", + "form.discard.confirm": "Gerçekten tüm değişikliklerinizi silmek istiyor musunuz?", + "form.locked": "Bu içerik şu anda başka bir kullanıcı tarafından düzenlendiği için sizin için devre dışı bırakıldı", + "form.unsaved": "Mevcut değişiklikler henüz kaydedilmedi", + "form.preview": "Değişiklikleri önizle", + "form.preview.draft": "Taslağı önizle", + + "hide": "Gizle", + "hour": "Saat", + "hue": "Renk tonu", + "import": "İçe aktar", + "info": "Bilgi", + "insert": "Ekle", + "insert.after": "Sonrasına ekle", + "insert.before": "Öncesine ekle", + "install": "Kurulum", + + "installation": "Kurulum", + "installation.completed": "Panel kuruldu", + "installation.disabled": "Panel yükleyici, herkese açık sunucularda varsayılan olarak devre dışıdır. Lütfen yükleyiciyi yerel bir makinede çalıştırın veya panel.install seçeneğiyle etkinleştirin.", + "installation.issues.accounts": "/site/accounts klasörü yok yada yazılabilir değil", + "installation.issues.content": "/content klasörü yok yada yazılabilir değil", + "installation.issues.curl": "CURL eklentisi gerekli", + "installation.issues.headline": "Panel kurulamadı", + "installation.issues.mbstring": "MB String eklentisi gerekli", + "installation.issues.media": "/media klasörü yok yada yazılamaz", + "installation.issues.php": "PHP 8+ kullandığınızdan emin olun. ", + "installation.issues.sessions": "/site/sessions klasörü mevcut değil veya yazılabilir değil", + + "language": "Dil", + "language.code": "Kod", + "language.convert": "Varsayılan yap", + "language.convert.confirm": "

{name}'i varsayılan dile dönüştürmek istiyor musunuz? Bu geri alınamaz.

{name} çevrilmemiş içeriğe sahipse, artık geçerli bir geri dönüş olmaz ve sitenizin bazı bölümleri boş olabilir.

", + "language.create": "Yeni bir dil ekle", + "language.default": "Varsayılan dil", + "language.delete.confirm": "Tüm çevirileri içeren {name} dilini gerçekten silmek istiyor musunuz? Bu geri alınamaz!", + "language.deleted": "Dil silindi", + "language.direction": "Okuma yönü", + "language.direction.ltr": "Soldan sağa", + "language.direction.rtl": "Sağdan sola", + "language.locale": "PHP yerel dizesi", + "language.locale.warning": "Özel bir yerel ayar kullanıyorsunuz. Lütfen /site/languages konumundaki dil dosyasından değiştirin.", + "language.name": "İsim", + "language.secondary": "İkincil dil", + "language.settings": "Dil ayarları", + "language.updated": "Dil güncellendi", + "language.variables": "Dil değişkenleri", + "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", + + "languages": "Diller", + "languages.default": "Varsayılan dil", + "languages.empty": "Henüz hiç dil yok", + "languages.secondary": "İkincil diller", + "languages.secondary.empty": "Henüz ikincil bir dil yok", + + "license": "Lisans", + "license.activate": "Şimdi etkinleştirin", + "license.activate.label": "Lütfen lisansınızı etkinleştirin", + "license.activate.domain": "Lisansınız {host} için etkinleştirilecektir.", + "license.activate.local": "Yerel etki alanınız {host} için Kirby lisansınızı etkinleştirmek üzeresiniz. Bu site genel bir etki alanına kurulacaksa, lütfen bunun yerine orada etkinleştirin. Eğer lisansınızı kullanmak istediğiniz alan adı {host} ise lütfen devam edin.", + "license.activated": "Etkinleştirildi", + "license.buy": "Bir lisans satın al", + "license.code": "Kod", + "license.code.help": "Lisans kodunuzu satın alma işleminden sonra e-posta yoluyla aldınız. Lütfen kopyalayıp buraya yapıştırın.", + "license.code.label": "Lütfen lisans kodunu giriniz", + "license.status.active.info": "{date} tarihine kadar yeni ana sürümleri içerir", + "license.status.active.label": "Geçerli lisans", + "license.status.demo.info": "Bu bir demo kurulumudur", + "license.status.demo.label": "Demo", + "license.status.inactive.info": "Yeni ana sürümlere güncellemek için lisansı yenileyin", + "license.status.inactive.label": "Yeni ana sürüm yok", + "license.status.legacy.bubble": "Lisansınızı yenilemeye hazır mısınız?", + "license.status.legacy.info": "Lisansınız bu sürümü kapsamıyor", + "license.status.legacy.label": "Lütfen lisansınızı yenileyin", + "license.status.missing.bubble": "Sitenizi yayına almaya hazır mısınız?", + "license.status.missing.info": "Geçerli lisans yok", + "license.status.missing.label": "Lütfen lisansınızı etkinleştirin", + "license.status.unknown.info": "Lisans durumu bilinmiyor", + "license.status.unknown.label": "Bilinmiyor", + "license.manage": "Lisanslarınızı yönetin", + "license.purchased": "Satın alındı", + "license.success": "Kirby'yi desteklediğiniz için teşekkürler", + "license.unregistered.label": "Kayıtsız", + + "link": "Ba\u011flant\u0131", + "link.text": "Ba\u011flant\u0131 yaz\u0131s\u0131", + + "loading": "Yükleniyor", + + "lock.unsaved": "Kaydedilmemiş değişiklikler", + "lock.unsaved.empty": "Daha fazla kaydedilmemiş değişiklik yok", + "lock.unsaved.files": "Kaydedilmemiş dosyalar", + "lock.unsaved.pages": "Kaydedilmemiş sayfalar", + "lock.unsaved.users": "Kaydedilmemiş hesaplar", + "lock.isLocked": "{email} tarafından yapılan kaydedilmemiş değişiklikler", + "lock.unlock": "Kilidi Aç", + "lock.unlock.submit": "Kaydedilmemiş değişikliklerin kilidini {email} ile açın ve üzerine yazın", + "lock.isUnlocked": "Başka bir kullanıcı tarafından kilidi açıldı", + + "login": "Giriş", + "login.code.label.login": "Giriş kodu", + "login.code.label.password-reset": "Şifre sıfırlama kodu", + "login.code.placeholder.email": "000 000", + "login.code.placeholder.totp": "000000", + "login.code.text.email": "E-posta adresiniz kayıtlıysa, istenen kod e-posta yoluyla gönderilmiştir.", + "login.code.text.totp": "Lütfen kimlik doğrulayıcı uygulamanızdaki tek seferlik kodu girin.", + "login.email.login.body": "Merhaba {user.nameOrEmail},\n\nKısa süre önce {site} Panel'i için bir giriş kodu istediniz.\nAşağıdaki giriş kodu {timeout} dakika boyunca geçerli olacaktır:\n\n{code}\n\nBir giriş kodu istemediyseniz, lütfen bu e-postayı dikkate almayın veya sorularınız varsa yöneticinize başvurun.\nGüvenliğiniz için lütfen bu e-postayı İLETMEYİN.", + "login.email.login.subject": "Giriş kodunuz", + "login.email.password-reset.body": "Merhaba {user.nameOrEmail},\n\nKısa süre önce {site} Panel'i için bir şifre sıfırlama kodu istediniz.\nAşağıdaki şifre sıfırlama kodu {timeout} dakika boyunca geçerli olacaktır:\n\n{code}\n\nŞifre sıfırlama kodu istemediyseniz, lütfen bu e-postayı dikkate almayın veya sorularınız varsa yöneticinizle iletişime geçin.\nGüvenliğiniz için lütfen bu e-postayı İLETMEYİN.", + "login.email.password-reset.subject": "Şifre sıfırlama kodunuz", + "login.remember": "Oturumumu açık tut", + "login.reset": "Şifreyi sıfırla", + "login.toggleText.code.email": "E-posta ile giriş yapın", + "login.toggleText.code.email-password": "Şifre ile giriş yapın", + "login.toggleText.password-reset.email": "Şifrenizi mi unuttunuz?", + "login.toggleText.password-reset.email-password": "← Girişe geri dön", + "login.totp.enable.option": "Tek seferlik kodlar ayarlama", + "login.totp.enable.intro": "Kimlik doğrulayıcı uygulamalar, hesabınızda oturum açarken ikinci bir faktör olarak kullanılan tek seferlik kodlar oluşturabilir.", + "login.totp.enable.qr.label": "1. Bu QR kodunu tarayın", + "login.totp.enable.qr.help": "Tarama yapılamıyor mu? Kurulum anahtarını {secret} kimlik doğrulayıcı uygulamanıza elle ekleyin.", + "login.totp.enable.confirm.headline": "2. Oluşturulan kod ile onaylayın", + "login.totp.enable.confirm.text": "Uygulamanız her 30 saniyede bir yeni bir kerelik kod oluşturur. Kurulumu tamamlamak için geçerli kodu girin:", + "login.totp.enable.confirm.label": "Geçerli kod", + "login.totp.enable.confirm.help": "Bu kurulumdan sonra, her oturum açtığınızda sizden tek seferlik bir kod isteyeceğiz.", + "login.totp.enable.success": "Tek seferlik kodlar etkinleştirildi", + "login.totp.disable.option": "Tek seferlik kodları devre dışı bırakma", + "login.totp.disable.label": "Tek seferlik kodları devre dışı bırakmak için şifrenizi girin", + "login.totp.disable.help": "Gelecekte, oturum açtığınızda e-posta yoluyla gönderilen bir oturum açma kodu gibi farklı bir ikinci faktör istenecektir. Tek seferlik kodları daha sonra her zaman yeniden ayarlayabilirsiniz.", + "login.totp.disable.admin": "

Bu {user} için tek seferlik kodları devre dışı bırakacaktır.

Gelecekte, oturum açtıklarında e-posta yoluyla gönderilen bir oturum açma kodu gibi farklı bir ikinci faktör istenecektir. {user} bir sonraki girişinden sonra tek seferlik kodları tekrar ayarlayabilir.

", + "login.totp.disable.success": "Tek seferlik kodlar devre dışı", + + "logout": "Oturumu kapat", + + "merge": "Birleştir", + "menu": "Menü", + "meridiem": "AM/PM", + "mime": "Medya Türü", + "minutes": "Dakika", + + "month": "Ay", + "months.april": "Nisan", + "months.august": "A\u011fustos", + "months.december": "Aral\u0131k", + "months.february": "Şubat", + "months.january": "Ocak", + "months.july": "Temmuz", + "months.june": "Haziran", + "months.march": "Mart", + "months.may": "May\u0131s", + "months.november": "Kas\u0131m", + "months.october": "Ekim", + "months.september": "Eyl\u00fcl", + + "more": "Daha Fazla", + "move": "Taşı", + "name": "İsim", + "next": "Sonraki", + "night": "Gece", + "no": "hayır", + "off": "kapalı", + "on": "açık", + "open": "Önizleme", + "open.newWindow": "Yeni pencerede aç", + "option": "Seçenek", + "options": "Seçenekler", + "options.none": "Seçenek yok", + "options.all": "Tüm {count} seçeneklerini göster", + + "orientation": "Oryantasyon", + "orientation.landscape": "Yatay", + "orientation.portrait": "Dikey", + "orientation.square": "Kare", + + "page": "Sayfa", + "page.blueprint": "Bu dosyanın henüz bir planı yok. Kurulumu /site/blueprints/pages/{blueprint}.yml dosyasında tanımlayabilirsiniz.", + "page.changeSlug": "Web Adresini Değiştir", + "page.changeSlug.fromTitle": "Ba\u015fl\u0131ktan olu\u015ftur", + "page.changeStatus": "Durumu değiştir", + "page.changeStatus.position": "Lütfen bir pozisyon seçin", + "page.changeStatus.select": "Yeni bir durum seçin", + "page.changeTemplate": "Şablonu değiştir", + "page.changeTemplate.notice": "Sayfanın şablonunu değiştirmek, tür olarak eşleşmeyen alanların içeriğini kaldıracaktır. Dikkatli kullanın.", + "page.create": "{status} olarak oluştur", + "page.delete.confirm": "{title} sayfasını silmek istediğinizden emin misiniz?", + "page.delete.confirm.subpages": "Bu sayfada alt sayfalar var.
Tüm alt sayfalar da silinecek.", + "page.delete.confirm.title": "Onaylamak için sayfa başlığını girin", + "page.duplicate.appendix": "Kopya", + "page.duplicate.files": "Dosyaları kopyala", + "page.duplicate.pages": "Sayfaları kopyala", + "page.move": "Sayfayı taşı", + "page.sort": "Pozisyon değiştir", + "page.status": "Durum", + "page.status.draft": "Taslak", + "page.status.draft.description": "Sayfa taslak halinde ve yalnızca oturum açmış editörler için veya gizli bağlantı üzerinden görülebilir", + "page.status.listed": "Herkese Açık", + "page.status.listed.description": "Bu sayfa herkese açık", + "page.status.unlisted": "Liste Dışı", + "page.status.unlisted.description": "Bu sayfa sadece bağlantı adresi ile erişilebilir", + + "pages": "Sayfalar", + "pages.delete.confirm.selected": "Seçili sayfaları gerçekten silmek istiyor musunuz? Bu işlem geri alınamaz.", + "pages.empty": "Henüz sayfa yok", + "pages.status.draft": "Taslaklar", + "pages.status.listed": "Yayınlandı", + "pages.status.unlisted": "Liste Dışı", + + "pagination.page": "Sayfa", + + "password": "\u015eifre", + "paste": "Yapıştır", + "paste.after": "Sonrasına yapıştır", + "paste.success": "{count} yapıştırıldı!", + "pixel": "Piksel", + "plugin": "Eklenti", + "plugins": "Eklentiler", + "prev": "Önceki", + "preview": "Önizle", + + "publish": "Yayınla", + "published": "Yayınlandı", + + "remove": "Kaldır", + "rename": "Yeniden Adlandır", + "renew": "Yenileme", + "replace": "De\u011fi\u015ftir", + "replace.with": "Değiştir", + "retry": "Tekrar Dene", + "revert": "Vazge\u00e7", + "revert.confirm": "Gerçekten kaydedilmemiş tüm değişiklikleri silmek istiyor musunuz?", + + "role": "Rol", + "role.admin.description": "Yönetici tüm haklara sahiptir", + "role.admin.title": "Yönetici", + "role.all": "Tümü", + "role.empty": "Bu role ait kullanıcı bulunamadı", + "role.description.placeholder": "Açıklama yok", + "role.nobody.description": "Bu hiçbir izni olmayan bir geri dönüş rolüdür.", + "role.nobody.title": "Hiçkimse", + + "save": "Kaydet", + "saved": "Kaydedildi", + "search": "Arama", + "searching": "Arama", + "search.min": "Aramak için {min} karakter girin", + "search.all": "Tüm {count} sonuçlarını göster", + "search.results.none": "Sonuç yok", + + "section.invalid": "Bu bölüm geçersizdir", + "section.required": "Bölüm gereklidir", + + "security": "Güvenlik", + "select": "Seç", + "server": "Sunucu", + "settings": "Ayarlar", + "show": "Göster", + "site.blueprint": "Sitenin henüz bir planı yok. Kurulumu /site/blueprints/site.yml'de tanımlayabilirsiniz.", + "size": "Boyut", + "slug": "Web Adres Uzantısı", + "sort": "Sırala", + "sort.drag": "Sıralamak için sürükleyin …", + "split": "Ayır", + + "stats.empty": "Rapor yok", + "status": "Durum", + + "system.info.copy": "Bilgileri kopyala", + "system.info.copied": "Sistem bilgisi kopyalandı", + "system.issues.content": "İçerik klasörü açığa çıkmış görünüyor", + "system.issues.eol.kirby": "Yüklü Kirby sürümünüz kullanım ömrünün sonuna ulaştı ve daha fazla güvenlik güncellemesi almayacak", + "system.issues.eol.plugin": "{ plugin } eklentisinin yüklü sürümü kullanım ömrünün sonuna ulaştı ve daha fazla güvenlik güncellemesi almayacak", + "system.issues.eol.php": "Yüklü PHP sürümünüz { release } kullanım ömrünün sonuna ulaşmıştır ve başka güvenlik güncellemeleri almayacaktır", + "system.issues.debug": "Canlı modda hata ayıklama kapatılmalıdır", + "system.issues.git": ".git klasörü açığa çıkmış görünüyor", + "system.issues.https": "Tüm siteleriniz için HTTPS'yi öneriyoruz", + "system.issues.kirby": "Kirby klasörü açığa çıkmış görünüyor", + "system.issues.local": "Site rahat güvenlik kontrolleri ile yerel olarak çalışıyor", + "system.issues.site": "Site klasörü açığa çıkmış görünüyor", + "system.issues.vue.compiler": "Vue şablon derleyicisi etkinleştirildi", + "system.issues.vulnerability.kirby": "Kurulumunuz aşağıdaki güvenlik açığından ({ severity } önem derecesi) etkilenebilir: { description }", + "system.issues.vulnerability.plugin": "Kurulumunuz, { plugin } eklentisindeki ({ severity } önem derecesi) aşağıdaki güvenlik açığından etkilenebilir: { description }", + "system.updateStatus": "Güncelleme durumu", + "system.updateStatus.error": "Güncellemeler kontrol edilemedi", + "system.updateStatus.not-vulnerable": "Bilinen güvenlik açığı yok", + "system.updateStatus.security-update": "Ücretsiz güvenlik güncellemesi { version } mevcut", + "system.updateStatus.security-upgrade": "Mevcut güvenlik düzeltmeleriyle { version } sürümüne yükseltin", + "system.updateStatus.unreleased": "Yayınlanmamış sürüm", + "system.updateStatus.up-to-date": "Güncel", + "system.updateStatus.update": "Ücretsiz güncelleme { version } mevcut", + "system.updateStatus.upgrade": "{ version } yükseltme mevcut", + + "tel": "Telefon", + "tel.placeholder": "+49123456789", + "template": "\u015eablon", + + "theme": "Tema", + "theme.light": "Açık mod", + "theme.dark": "Koyu mod", + "theme.automatic": "Sistem varsayılanı", + + "title": "Başlık", + "today": "Bugün", + + "toolbar.button.clear": "Biçimlendirmeyi temizle", + "toolbar.button.code": "Kod", + "toolbar.button.bold": "Kalın Yazı", + "toolbar.button.email": "E-Posta", + "toolbar.button.headings": "Başlıklar", + "toolbar.button.heading.1": "Başlık 1", + "toolbar.button.heading.2": "Başlık 2", + "toolbar.button.heading.3": "Başlık 3", + "toolbar.button.heading.4": "Başlık 4", + "toolbar.button.heading.5": "Başlık 5", + "toolbar.button.heading.6": "Başlık 6", + "toolbar.button.italic": "Eğik Yazı", + "toolbar.button.file": "Dosya", + "toolbar.button.file.select": "Bir dosya seçin", + "toolbar.button.file.upload": "Bir dosya yükleyin", + "toolbar.button.link": "Ba\u011flant\u0131", + "toolbar.button.paragraph": "Paragraf", + "toolbar.button.strike": "Üstü çizili", + "toolbar.button.sub": "Alt simge", + "toolbar.button.sup": "Üst simge", + "toolbar.button.ol": "Sıralı liste", + "toolbar.button.underline": "Altı çizili", + "toolbar.button.ul": "Madde listesi", + + "translation.author": "Kirby Takımı", + "translation.direction": "ltr", + "translation.name": "T\u00fcrk\u00e7e", + "translation.locale": "tr_TR", + + "type": "Tür", + + "upload": "Yükle", + "upload.error.cantMove": "Yüklenen dosya taşınamadı", + "upload.error.cantWrite": "Dosya diske yazılamadı", + "upload.error.default": "Dosya yüklenemedi", + "upload.error.extension": "Dosya yükleme uzantısı tarafından durduruldu", + "upload.error.formSize": "Yüklenen dosya, formda belirtilen MAX_FILE_SIZE yönergesini aşıyor", + "upload.error.iniPostSize": "Yüklenen dosya php.ini içindeki post_max_size yönergesini aşıyor", + "upload.error.iniSize": "Yüklenen dosya php.ini içindeki upload_max_filesize yönergesini aşıyor", + "upload.error.noFile": "Dosya yüklenmedi", + "upload.error.noFiles": "Dosyalar yüklenmedi", + "upload.error.partial": "Yüklenen dosya sadece kısmen yüklendi", + "upload.error.tmpDir": "Geçici klasör eksik", + "upload.errors": "Hata", + "upload.progress": "Yükleniyor...", + + "url": "Url", + "url.placeholder": "https://ornek.com", + + "user": "Kullanıcı", + "user.blueprint": "Bu kullanıcı rolü için /site/blueprints/users/{blueprint}.yml içinde ek bölümler ve form alanları tanımlayabilirsiniz", + "user.changeEmail": "E-postayı değiştir", + "user.changeLanguage": "Dili değiştir", + "user.changeName": "Kullanıcıyı yeniden adlandır", + "user.changePassword": "Şifre değiştir", + "user.changePassword.current": "Mevcut şifreniz", + "user.changePassword.new": "Yeni Şifre", + "user.changePassword.new.confirm": "Şifreyi onaylayın...", + "user.changeRole": "Rolü değiştir", + "user.changeRole.select": "Yeni bir rol seçin", + "user.create": "Yeni bir kullanıcı ekle", + "user.delete": "Bu kullanıcıyı sil", + "user.delete.confirm": "{email} kullanıcısını silmek istediğinizden emin misiniz?", + + "users": "Kullanıcılar", + + "version": "Versiyon", + "version.changes": "Değiştirilen sürüm", + "version.compare": "Sürümleri karşılaştırın", + "version.current": "Mevcut sürüm", + "version.latest": "En son sürüm", + "versionInformation": "Sürüm bilgisi", + + "view": "Görüntüle", + "view.account": "Hesap Bilgilerin", + "view.installation": "Kurulum", + "view.languages": "Diller", + "view.resetPassword": "Şifreyi sıfırla", + "view.site": "Site", + "view.system": "Sistem", + "view.users": "Kullan\u0131c\u0131lar", + + "welcome": "Hoşgeldiniz", + "year": "Yıl", + "yes": "evet" +} 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/kirby.pub b/public/kirby/kirby.pub new file mode 100644 index 0000000..ddf9130 --- /dev/null +++ b/public/kirby/kirby.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Ux4q7LmQ5hfTYTtz3/a +mohFJMWo/iCnxVcY84PZjLwWnT+G2DTKGaEWydB77TteJQnmsgtvO5734oj3Ga3r +QCfwr2gxo/0WDEBq7C5HP+YNJiuZ/iD/tYV+gloF+Aaa3Mo8AK5DYH3dnjuyfHc1 +veIlYX1D2MXji2IRqdweAzVi1dfI4I3Ys8awhzv653vFLj5LvAtlwlYlmYeRwci7 +GkAOWw709CuKQNdPBXGFQQ/pEB5mnp8mI31j8og845u6v/Sk4+85gFORSufIRfnQ +GFYrPOeavxfAWQGjh7JQjr/sbKSXaJ3nDlrYsOPIrC0Rwn/jsQPO7OLdVwkc9ofL +GQIDAQAB +-----END PUBLIC KEY----- diff --git a/public/kirby/router.php b/public/kirby/router.php new file mode 100644 index 0000000..86fb005 --- /dev/null +++ b/public/kirby/router.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Api +{ + /** + * Authentication callback + */ + protected Closure|null $authentication = null; + + /** + * Debugging flag + */ + protected bool $debug = false; + + /** + * Collection definition + */ + protected array $collections = []; + + /** + * Injected data/dependencies + */ + protected array $data = []; + + /** + * Model definitions + */ + protected array $models = []; + + /** + * The current route + */ + protected Route|null $route = null; + + /** + * The Router instance + */ + protected Router|null $router = null; + + /** + * Route definition + */ + protected array $routes = []; + + /** + * Request data + * [query, body, files] + */ + protected array $requestData = []; + + /** + * The applied request method + * (GET, POST, PATCH, etc.) + */ + protected string|null $requestMethod = null; + + /** + * Creates a new API instance + */ + public function __construct(array $props) + { + $this->authentication = $props['authentication'] ?? null; + $this->data = $props['data'] ?? []; + $this->routes = $props['routes'] ?? []; + $this->debug = $props['debug'] ?? false; + + if ($collections = $props['collections'] ?? null) { + $this->collections = array_change_key_case($collections); + } + + if ($models = $props['models'] ?? null) { + $this->models = array_change_key_case($models); + } + + $this->setRequestData($props['requestData'] ?? null); + $this->setRequestMethod($props['requestMethod'] ?? null); + } + + /** + * Magic accessor for any given data + * + * @throws \Kirby\Exception\NotFoundException + */ + public function __call(string $method, array $args = []) + { + return $this->data($method, ...$args); + } + + /** + * Runs the authentication method + * if set + */ + public function authenticate() + { + return $this->authentication()?->call($this) ?? true; + } + + /** + * Returns the authentication callback + */ + public function authentication(): Closure|null + { + return $this->authentication; + } + + /** + * Execute an API call for the given path, + * request method and optional request data + * + * @throws \Kirby\Exception\NotFoundException + * @throws \Exception + */ + public function call( + string|null $path = null, + string $method = 'GET', + array $requestData = [] + ): mixed { + $path = rtrim($path ?? '', '/'); + + $this->setRequestMethod($method); + $this->setRequestData($requestData); + + $this->router = new Router($this->routes()); + $this->route = $this->router->find($path, $method); + $auth = $this->route?->attributes()['auth'] ?? true; + + if ($auth !== false) { + $user = $this->authenticate(); + + // set PHP locales based on *user* language + // so that e.g. strftime() gets formatted correctly + if ($user instanceof User) { + $language = $user->language(); + + // get the locale from the translation + $locale = $user->kirby()->translation($language)->locale(); + + // provide some variants as fallbacks to be + // compatible with as many systems as possible + $locales = [ + $locale . '.UTF-8', + $locale . '.UTF8', + $locale . '.ISO8859-1', + $locale, + $language, + setlocale(LC_ALL, 0) // fall back to the previously defined locale + ]; + + // set the locales that are relevant for string formatting + // *don't* set LC_CTYPE to avoid breaking other parts of the system + setlocale(LC_MONETARY, $locales); + setlocale(LC_NUMERIC, $locales); + setlocale(LC_TIME, $locales); + } + } + + // don't throw pagination errors if pagination + // page is out of bounds + $validate = Pagination::$validate; + Pagination::$validate = false; + + $output = $this->route?->action()->call( + $this, + ...$this->route->arguments() + ); + + // restore old pagination validation mode + Pagination::$validate = $validate; + + if ( + is_object($output) === true && + $output instanceof Response === false + ) { + return $this->resolve($output)->toResponse(); + } + + return $output; + } + + /** + * Creates a new instance while + * merging initial and new properties + */ + public function clone(array $props = []): static + { + return new static([ + 'autentication' => $this->authentication, + 'data' => $this->data, + 'routes' => $this->routes, + 'debug' => $this->debug, + 'collections' => $this->collections, + 'models' => $this->models, + 'requestData' => $this->requestData, + 'requestMethod' => $this->requestMethod, + ...$props + ]); + } + + /** + * Setter and getter for an API collection + * + * @throws \Kirby\Exception\NotFoundException If no collection for `$name` exists + * @throws \Exception + */ + public function collection( + string $name, + array|BaseCollection|null $collection = null + ): Collection { + if (isset($this->collections[$name]) === false) { + throw new NotFoundException( + message: sprintf('The collection "%s" does not exist', $name) + ); + } + + return new Collection($this, $collection, $this->collections[$name]); + } + + /** + * Returns the collections definition + */ + public function collections(): array + { + return $this->collections; + } + + /** + * Returns the injected data array + * or certain parts of it by key + * + * @throws \Kirby\Exception\NotFoundException If no data for `$key` exists + */ + public function data(string|null $key = null, ...$args): mixed + { + if ($key === null) { + return $this->data; + } + + if ($this->hasData($key) === false) { + throw new NotFoundException( + message: sprintf('Api data for "%s" does not exist', $key) + ); + } + + // lazy-load data wrapped in Closures + if ($this->data[$key] instanceof Closure) { + return $this->data[$key]->call($this, ...$args); + } + + return $this->data[$key]; + } + + /** + * Returns the debugging flag + */ + public function debug(): bool + { + return $this->debug; + } + + /** + * Checks if injected data exists for the given key + */ + public function hasData(string $key): bool + { + return isset($this->data[$key]) === true; + } + + /** + * Matches an object with an array item + * based on the `type` field + * + * @param array models or collections + * @return string|null key of match + */ + protected function match( + array $array, + $object = null + ): string|null { + foreach ($array as $definition => $model) { + if ($object instanceof $model['type']) { + return $definition; + } + } + + return null; + } + + /** + * Returns an API model instance by name + * + * @throws \Kirby\Exception\NotFoundException If no model for `$name` exists + */ + public function model( + string|null $name = null, + $object = null + ): Model { + // Try to auto-match object with API models + $name ??= $this->match($this->models, $object); + + if (isset($this->models[$name]) === false) { + throw new NotFoundException( + message: sprintf('The model "%s" does not exist', $name ?? 'NULL') + ); + } + + return new Model($this, $object, $this->models[$name]); + } + + /** + * Returns all model definitions + */ + public function models(): array + { + return $this->models; + } + + /** + * Getter for request data + * Can either get all the data + * or certain parts of it. + */ + public function requestData( + string|null $type = null, + string|null $key = null, + mixed $default = null + ): mixed { + if ($type === null) { + return $this->requestData; + } + + if ($key === null) { + return $this->requestData[$type] ?? []; + } + + $data = array_change_key_case($this->requestData($type)); + $key = strtolower($key); + + return $data[$key] ?? $default; + } + + /** + * Returns the request body if available + */ + public function requestBody( + string|null $key = null, + mixed $default = null + ): mixed { + return $this->requestData('body', $key, $default); + } + + /** + * Returns the files from the request if available + */ + public function requestFiles( + string|null $key = null, + mixed $default = null + ): mixed { + return $this->requestData('files', $key, $default); + } + + /** + * Returns all headers from the request if available + */ + public function requestHeaders( + string|null $key = null, + mixed $default = null + ): mixed { + return $this->requestData('headers', $key, $default); + } + + /** + * Returns the request method + */ + public function requestMethod(): string|null + { + return $this->requestMethod; + } + + /** + * Returns the request query if available + */ + public function requestQuery( + string|null $key = null, + mixed $default = null + ): mixed { + return $this->requestData('query', $key, $default); + } + + /** + * Turns a Kirby object into an + * API model or collection representation + * + * @throws \Kirby\Exception\NotFoundException If `$object` cannot be resolved + */ + public function resolve($object): Model|Collection + { + if ( + $object instanceof Model || + $object instanceof Collection + ) { + return $object; + } + + if ($model = $this->match($this->models, $object)) { + return $this->model($model, $object); + } + + if ($collection = $this->match($this->collections, $object)) { + return $this->collection($collection, $object); + } + + throw new NotFoundException( + message: sprintf('The object "%s" cannot be resolved', $object::class) + ); + } + + /** + * Returns all defined routes + */ + public function routes(): array + { + return $this->routes; + } + + /** + * Renders the API call + */ + public function render( + string $path, + string $method = 'GET', + array $requestData = [] + ): mixed { + try { + $result = $this->call($path, $method, $requestData); + } catch (Throwable $e) { + $result = $this->responseForException($e); + } + + $result = match ($result) { + null => $this->responseFor404(), + false => $this->responseFor400(), + true => $this->responseFor200(), + default => $result + }; + + if (is_array($result) === false) { + return $result; + } + + // pretty print json data + $pretty = (bool)($requestData['query']['pretty'] ?? false) === true; + + if (($result['status'] ?? 'ok') === 'error') { + $code = $result['code'] ?? 400; + + // sanitize the error code + if ($code < 400 || $code > 599) { + $code = 500; + } + + return Response::json($result, $code, $pretty); + } + + return Response::json($result, 200, $pretty); + } + + /** + * Returns a 200 - ok + * response array. + */ + public function responseFor200(): array + { + return [ + 'status' => 'ok', + 'message' => 'ok', + 'code' => 200 + ]; + } + + /** + * Returns a 400 - bad request + * response array. + */ + public function responseFor400(): array + { + return [ + 'status' => 'error', + 'message' => 'bad request', + 'code' => 400, + ]; + } + + /** + * Returns a 404 - not found + * response array. + */ + public function responseFor404(): array + { + return [ + 'status' => 'error', + 'message' => 'not found', + 'code' => 404, + ]; + } + + /** + * Creates the response array for + * an exception. Kirby exceptions will + * have more information + */ + public function responseForException(Throwable $e): array + { + if (isset($this->kirby) === true) { + $docRoot = $this->kirby->environment()->get('DOCUMENT_ROOT'); + } else { + $docRoot = $_SERVER['DOCUMENT_ROOT'] ?? null; + } + + // prepare the result array for all exception types + $result = [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'code' => empty($e->getCode()) === true ? 500 : $e->getCode(), + 'exception' => $e::class, + 'key' => null, + 'file' => F::relativepath($e->getFile(), $docRoot), + 'line' => $e->getLine(), + 'details' => [], + 'route' => $this->route?->pattern() + ]; + + // extend the information for Kirby Exceptions + if ($e instanceof ExceptionException) { + $result['key'] = $e->getKey(); + $result['details'] = $e->getDetails(); + $result['code'] = $e->getHttpCode(); + } + + // remove critical info from the result set if + // debug mode is switched off + if ($this->debug !== true) { + unset( + $result['file'], + $result['exception'], + $result['line'], + $result['route'] + ); + } + + return $result; + } + + /** + * Setter for the request data + * @return $this + */ + protected function setRequestData( + array|null $requestData = [] + ): static { + $this->requestData = [ + 'query' => [], + 'body' => [], + 'files' => [], + ...$requestData ?? [] + ]; + return $this; + } + + /** + * Setter for the request method + * @return $this + */ + protected function setRequestMethod( + string|null $requestMethod = null + ): static { + $this->requestMethod = $requestMethod ?? 'GET'; + return $this; + } + + /** + * Upload helper method + * + * move_uploaded_file() not working with unit test + * Added debug parameter for testing purposes as we did in the Email class + * + * @throws \Exception If request has no files or there was an error with the upload + */ + public function upload( + Closure $callback, + bool $single = false, + bool $debug = false + ): array { + return (new Upload($this, $single, $debug))->process($callback); + } +} diff --git a/public/kirby/src/Api/Collection.php b/public/kirby/src/Api/Collection.php new file mode 100644 index 0000000..c9a9a16 --- /dev/null +++ b/public/kirby/src/Api/Collection.php @@ -0,0 +1,153 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Collection +{ + protected string|null $model; + protected array|null $select = null; + protected string|null $view; + + /** + * Collection constructor + * + * @throws \Exception + */ + public function __construct( + protected Api $api, + protected BaseCollection|array|null $data, + array $schema + ) { + $this->model = $schema['model'] ?? null; + $this->view = $schema['view'] ?? null; + + if ($data === null) { + if (($schema['default'] ?? null) instanceof Closure === false) { + throw new Exception(message: 'Missing collection data'); + } + + $this->data = $schema['default']->call($this->api); + } + + if ( + isset($schema['type']) === true && + $this->data instanceof $schema['type'] === false + ) { + throw new Exception(message: 'Invalid collection type'); + } + } + + /** + * @return $this + * @throws \Exception + */ + public function select($keys = null): static + { + if ($keys === false) { + return $this; + } + + if (is_string($keys)) { + $keys = Str::split($keys); + } + + if ($keys !== null && is_array($keys) === false) { + throw new Exception(message: 'Invalid select keys'); + } + + $this->select = $keys; + return $this; + } + + /** + * @throws \Kirby\Exception\NotFoundException + * @throws \Exception + */ + public function toArray(): array + { + $result = []; + + foreach ($this->data as $item) { + $model = $this->api->model($this->model, $item); + + if ($this->view !== null) { + $model = $model->view($this->view); + } + + if ($this->select !== null) { + $model = $model->select($this->select); + } + + $result[] = $model->toArray(); + } + + return $result; + } + + /** + * @throws \Kirby\Exception\NotFoundException + * @throws \Exception + */ + public function toResponse(): array + { + if ($query = $this->api->requestQuery('query')) { + $this->data = $this->data->query($query); + } + + if (!$this->data->pagination()) { + $this->data = $this->data->paginate([ + 'page' => $this->api->requestQuery('page', 1), + 'limit' => $this->api->requestQuery('limit', 100) + ]); + } + + $pagination = $this->data->pagination(); + + if ($select = $this->api->requestQuery('select')) { + $this->select($select); + } + + if ($view = $this->api->requestQuery('view')) { + $this->view($view); + } + + return [ + 'code' => 200, + 'data' => $this->toArray(), + 'pagination' => [ + 'page' => $pagination->page(), + 'total' => $pagination->total(), + 'offset' => $pagination->offset(), + 'limit' => $pagination->limit(), + ], + 'status' => 'ok', + 'type' => 'collection' + ]; + } + + /** + * @return $this + */ + public function view(string $view): static + { + $this->view = $view; + return $this; + } +} 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 new file mode 100644 index 0000000..fa3a000 --- /dev/null +++ b/public/kirby/src/Api/Model.php @@ -0,0 +1,227 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Model +{ + protected array $fields; + protected array|null $select; + protected array $views; + + /** + * Model constructor + * + * @throws \Exception + */ + public function __construct( + protected Api $api, + protected object|array|string|null $data, + array $schema + ) { + $this->fields = $schema['fields'] ?? []; + $this->select = $schema['select'] ?? null; + $this->views = $schema['views'] ?? []; + + if ( + $this->select === null && + array_key_exists('default', $this->views) + ) { + $this->view('default'); + } + + if ($data === null) { + if (($schema['default'] ?? null) instanceof Closure === false) { + throw new Exception(message: 'Missing model data'); + } + + $this->data = $schema['default']->call($this->api); + } + + if ( + isset($schema['type']) === true && + $this->data instanceof $schema['type'] === false + ) { + $class = match ($this->data) { + null => 'null', + default => $this->data::class, + }; + throw new Exception(sprintf('Invalid model type "%s" expected: "%s"', $class, $schema['type'])); + } + } + + /** + * @return $this + * @throws \Exception + */ + public function select($keys = null): static + { + if ($keys === false) { + return $this; + } + + if (is_string($keys)) { + $keys = Str::split($keys); + } + + if ($keys !== null && is_array($keys) === false) { + throw new Exception(message: 'Invalid select keys'); + } + + $this->select = $keys; + return $this; + } + + /** + * @throws \Exception + */ + public function selection(): array + { + $select = $this->select; + $select ??= array_keys($this->fields); + $selection = []; + + foreach ($select as $key => $value) { + if (is_int($key) === true) { + $selection[$value] = [ + 'view' => null, + 'select' => null + ]; + continue; + } + + if (is_string($value) === true) { + if ($value === 'any') { + throw new Exception(message: 'Invalid sub view: "any"'); + } + + $selection[$key] = [ + 'view' => $value, + 'select' => null + ]; + + continue; + } + + if (is_array($value) === true) { + $selection[$key] = [ + 'view' => null, + 'select' => $value + ]; + } + } + + return $selection; + } + + /** + * @throws \Kirby\Exception\NotFoundException + * @throws \Exception + */ + public function toArray(): array + { + $select = $this->selection(); + $result = []; + + foreach ($this->fields as $key => $resolver) { + if ( + array_key_exists($key, $select) === false || + $resolver instanceof Closure === false + ) { + continue; + } + + $value = $resolver->call($this->api, $this->data); + + if (is_object($value)) { + $value = $this->api->resolve($value); + } + + if ( + $value instanceof Collection || + $value instanceof self + ) { + $selection = $select[$key]; + + if ($subview = $selection['view']) { + $value->view($subview); + } + + if ($subselect = $selection['select']) { + $value->select($subselect); + } + + $value = $value->toArray(); + } + + $result[$key] = $value; + } + + ksort($result); + + return $result; + } + + /** + * @throws \Kirby\Exception\NotFoundException + * @throws \Exception + */ + public function toResponse(): array + { + $model = $this; + + if ($select = $this->api->requestQuery('select')) { + $model = $model->select($select); + } + + if ($view = $this->api->requestQuery('view')) { + $model = $model->view($view); + } + + return [ + 'code' => 200, + 'data' => $model->toArray(), + 'status' => 'ok', + 'type' => 'model' + ]; + } + + /** + * @return $this + * @throws \Exception + */ + public function view(string $name): static + { + if ($name === 'any') { + return $this->select(null); + } + + if (isset($this->views[$name]) === false) { + $name = 'default'; + + // try to fall back to the default view at least + if (isset($this->views[$name]) === false) { + throw new Exception(sprintf('The view "%s" does not exist', $name)); + } + } + + return $this->select($this->views[$name]); + } +} 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/Cache/ApcuCache.php b/public/kirby/src/Cache/ApcuCache.php new file mode 100644 index 0000000..98fe7de --- /dev/null +++ b/public/kirby/src/Cache/ApcuCache.php @@ -0,0 +1,83 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class ApcuCache extends Cache +{ + /** + * Returns whether the cache is ready to + * store values + */ + public function enabled(): bool + { + return apcu_enabled(); + } + + /** + * Determines if an item exists in the cache + */ + public function exists(string $key): bool + { + return apcu_exists($this->key($key)); + } + + /** + * Flushes the entire cache and returns + * whether the operation was successful + */ + public function flush(): bool + { + if (empty($this->options['prefix']) === false) { + return apcu_delete(new APCUIterator('!^' . preg_quote($this->options['prefix']) . '!')); + } + + return apcu_clear_cache(); + } + + /** + * Removes an item from the cache and returns + * whether the operation was successful + */ + public function remove(string $key): bool + { + return apcu_delete($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 = apcu_fetch($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(); + $expires = $this->expiration($minutes); + return apcu_store($key, $value, $expires); + } +} diff --git a/public/kirby/src/Cache/Cache.php b/public/kirby/src/Cache/Cache.php new file mode 100644 index 0000000..6c9fc88 --- /dev/null +++ b/public/kirby/src/Cache/Cache.php @@ -0,0 +1,237 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Cache +{ + /** + * Stores all options for the driver + */ + protected array $options = []; + + /** + * Sets all parameters which are needed to connect to the cache storage + */ + public function __construct(array $options = []) + { + $this->options = $options; + } + + /** + * Checks when the cache has been created; + * returns the creation timestamp on success + * and false if the item does not exist + */ + public function created(string $key): int|false + { + // get the Value object + $value = $this->retrieve($key); + + // check for a valid Value object + if ($value instanceof Value === false) { + return false; + } + + // return the expires timestamp + return $value->created(); + } + + /** + * Returns whether the cache is ready to + * store values + */ + public function enabled(): bool + { + // TODO: Make this method abstract in a future + // release to ensure that cache drivers override it; + // until then, we assume that the cache is enabled + return true; + } + + /** + * Determines if an item exists in the cache + */ + public function exists(string $key): bool + { + return $this->expired($key) === false; + } + + /** + * Calculates the expiration timestamp + */ + protected function expiration(int $minutes = 0): int + { + // 0 = keep forever + if ($minutes === 0) { + return 0; + } + + // calculate the time + return time() + ($minutes * 60); + } + + /** + * Checks when an item in the cache expires; + * returns the expiry timestamp on success, null if the + * item never expires and false if the item does not exist + */ + public function expires(string $key): int|false|null + { + // get the Value object + $value = $this->retrieve($key); + + // check for a valid Value object + if ($value instanceof Value === false) { + return false; + } + + // return the expires timestamp + return $value->expires(); + } + + /** + * Checks if an item in the cache is expired + */ + public function expired(string $key): bool + { + $expires = $this->expires($key); + + if ($expires === null) { + return false; + } + + if (is_int($expires) === false) { + return true; + } + + return time() >= $expires; + } + + /** + * Flushes the entire cache and returns + * whether the operation was successful; + * this needs to be defined by the driver + */ + abstract public function flush(): bool; + + /** + * Gets an item from the cache + * + * ```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'); + * ``` + */ + public function get(string $key, $default = null) + { + // get the Value + $value = $this->retrieve($key); + + // check for a valid cache value + if ($value instanceof Value === false) { + return $default; + } + + // remove the item if it is expired + if ($value->expires() > 0 && time() >= $value->expires()) { + $this->remove($key); + return $default; + } + + // return the pure value + return $value->value(); + } + + /** + * Returns a value by either getting it from the cache + * or via the callback function which then is stored in + * the cache for future retrieval. This method cannot be + * used for `null` as value to be cached. + * @since 3.8.0 + */ + public function getOrSet( + string $key, + Closure $result, + int $minutes = 0 + ) { + $value = $this->get($key); + $result = $value ?? $result(); + + if ($value === null) { + $this->set($key, $result, $minutes); + } + + return $result; + } + + /** + * Adds the prefix to the key if given + */ + protected function key(string $key): string + { + if (empty($this->options['prefix']) === false) { + $key = $this->options['prefix'] . '/' . $key; + } + + return $key; + } + + /** + * Alternate version for Cache::created($key) + */ + public function modified(string $key): int|false + { + return static::created($key); + } + + /** + * Returns all passed cache options + */ + public function options(): array + { + return $this->options; + } + + /** + * Removes an item from the cache and returns + * whether the operation was successful; + * this needs to be defined by the driver + */ + abstract public function remove(string $key): bool; + + /** + * Internal method to retrieve the raw cache value; + * needs to return a Value object or null if not found; + * this needs to be defined by the driver + */ + abstract public function retrieve(string $key): Value|null; + + /** + * Writes an item to the cache for a given number of minutes and + * returns whether the operation was successful; + * this needs to be defined by the driver + * + * ```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 new file mode 100644 index 0000000..9f6cace --- /dev/null +++ b/public/kirby/src/Cache/FileCache.php @@ -0,0 +1,226 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class FileCache extends Cache +{ + /** + * Full root including prefix + */ + protected string $root; + + /** + * Sets all parameters which are needed for the file cache + * + * @param array $options 'root' (required) + * 'prefix' (default: none) + * 'extension' (file extension for cache files, default: none) + */ + public function __construct(array $options) + { + parent::__construct([ + 'root' => null, + 'prefix' => null, + 'extension' => null, + ...$options + ]); + + // build the full root including prefix + $this->root = $this->options['root']; + + if (empty($this->options['prefix']) === false) { + $this->root .= '/' . $this->options['prefix']; + } + + // try to create the directory + Dir::make($this->root, true); + } + + /** + * Returns whether the cache is ready to + * store values + */ + public function enabled(): bool + { + return is_writable($this->root) === true; + } + + /** + * Returns the full root including prefix + */ + public function root(): string + { + return $this->root; + } + + /** + * Returns the full path to a file for a given key + */ + protected function file(string $key): string + { + // strip out invalid characters in each path segment + // split by slash or backslash + $keyParts = []; + foreach (preg_split('#([\/\\\\])#', $key, 0, PREG_SPLIT_DELIM_CAPTURE) as $part) { + switch ($part) { + case '/': + // forward slashes don't need special treatment + break; + + case '\\': + // backslashes get their own marker in the path + // to differentiate the cache key from one with forward slashes + $keyParts[] = '_backslash'; + break; + + case '': + // empty part means two slashes in a row; + // special marker like for backslashes + $keyParts[] = '_empty'; + break; + + default: + // an actual path segment: + // check if the segment only contains safe characters; + // underscores are *not* safe to guarantee uniqueness + // as they are used in the special cases + if (preg_match('/^[a-zA-Z0-9-]+$/', $part) === 1) { + $keyParts[] = $part; + } else { + $keyParts[] = Str::slug($part) . '_' . sha1($part); + } + } + } + + $file = $this->root . '/' . implode('/', $keyParts); + + if (isset($this->options['extension'])) { + return $file . '.' . $this->options['extension']; + } + + return $file; + } + + /** + * 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 + { + $file = $this->file($key); + + return F::write($file, (new Value($value, $minutes))->toJson()); + } + + /** + * 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 + { + $file = $this->file($key); + $value = F::read($file); + + return $value ? Value::fromJson($value) : null; + } + + /** + * Checks when the cache has been created; + * returns the creation timestamp on success + * and false if the item does not exist + */ + public function created(string $key): int|false + { + // use the modification timestamp + // as indicator when the cache has been created/overwritten + clearstatcache(); + + // get the file for this cache key + $file = $this->file($key); + return file_exists($file) ? filemtime($file) : false; + } + + /** + * Removes an item from the cache and returns + * whether the operation was successful + */ + public function remove(string $key): bool + { + $file = $this->file($key); + + if (is_file($file) === true && F::remove($file) === true) { + $this->removeEmptyDirectories(dirname($file)); + return true; + } + + return false; + } + + /** + * Removes empty directories safely by checking each directory + * up to the root directory + */ + protected function removeEmptyDirectories(string $dir): void + { + try { + // ensure the path doesn't end with a slash for the next comparison + $dir = rtrim($dir, '/\/'); + + // checks all directory segments until reaching the root directory + while (Str::startsWith($dir, $this->root()) === true && $dir !== $this->root()) { + $files = scandir($dir); + + if ($files === false) { + $files = []; // @codeCoverageIgnore + } + + $files = array_diff($files, ['.', '..']); + + if ($files === [] && Dir::remove($dir) === true) { + // continue with the next level up + $dir = dirname($dir); + } else { + // no need to continue with the next level up as `$dir` was not deleted + break; + } + } + } catch (Exception) { // @codeCoverageIgnore + // silently stops the process + } + } + + /** + * Flushes the entire cache and returns + * whether the operation was successful + */ + public function flush(): bool + { + if ( + Dir::remove($this->root) === true && + Dir::make($this->root) === true + ) { + return true; + } + + return false; // @codeCoverageIgnore + } +} diff --git a/public/kirby/src/Cache/MemCached.php b/public/kirby/src/Cache/MemCached.php new file mode 100644 index 0000000..06f79dd --- /dev/null +++ b/public/kirby/src/Cache/MemCached.php @@ -0,0 +1,105 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class MemCached extends Cache +{ + /** + * Store for the memcache connection + */ + protected MemcachedExt $connection; + + /** + * Stores whether the connection was successful + */ + protected bool $enabled; + + /** + * Sets all parameters which are needed to connect to Memcached + * + * @param array $options 'host' (default: localhost) + * 'port' (default: 11211) + * 'prefix' (default: null) + */ + public function __construct(array $options = []) + { + parent::__construct([ + 'host' => 'localhost', + 'port' => 11211, + 'prefix' => null, + ...$options + ]); + + $this->connection = new MemcachedExt(); + $this->enabled = $this->connection->addServer( + $this->options['host'], + $this->options['port'] + ); + } + + /** + * Returns whether the cache is ready to + * store values + */ + public function enabled(): bool + { + return $this->enabled; + } + + /** + * 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(); + $expires = $this->expiration($minutes); + return $this->connection->set($key, $value, $expires); + } + + /** + * 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); + } + + /** + * Removes an item from the cache and returns + * whether the operation was successful + */ + public function remove(string $key): bool + { + return $this->connection->delete($this->key($key)); + } + + /** + * Flushes the entire cache and returns + * whether the operation was successful; + * WARNING: Memcached only supports flushing the whole cache at once! + */ + public function flush(): bool + { + return $this->connection->flush(); + } +} diff --git a/public/kirby/src/Cache/MemoryCache.php b/public/kirby/src/Cache/MemoryCache.php new file mode 100644 index 0000000..ecf52ad --- /dev/null +++ b/public/kirby/src/Cache/MemoryCache.php @@ -0,0 +1,77 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class MemoryCache extends Cache +{ + /** + * Cache data + */ + protected array $store = []; + + /** + * Returns whether the cache is ready to + * store values + */ + public function enabled(): bool + { + return true; + } + + /** + * 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 + { + $this->store[$key] = new Value($value, $minutes); + return true; + } + + /** + * 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 + { + return $this->store[$key] ?? null; + } + + /** + * Removes an item from the cache and returns + * whether the operation was successful + */ + public function remove(string $key): bool + { + if (isset($this->store[$key])) { + unset($this->store[$key]); + return true; + } + + return false; + } + + /** + * Flushes the entire cache and returns + * whether the operation was successful + */ + public function flush(): bool + { + $this->store = []; + return true; + } +} diff --git a/public/kirby/src/Cache/NullCache.php b/public/kirby/src/Cache/NullCache.php new file mode 100644 index 0000000..48ed80e --- /dev/null +++ b/public/kirby/src/Cache/NullCache.php @@ -0,0 +1,65 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class NullCache extends Cache +{ + /** + * Returns whether the cache is ready to + * store values + */ + public function enabled(): bool + { + return false; + } + + /** + * 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 + { + return true; + } + + /** + * 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 + { + return null; + } + + /** + * Removes an item from the cache and returns + * whether the operation was successful + */ + public function remove(string $key): bool + { + return true; + } + + /** + * Flushes the entire cache and returns + * whether the operation was successful + */ + public function flush(): bool + { + return true; + } +} 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 new file mode 100644 index 0000000..2fc64a5 --- /dev/null +++ b/public/kirby/src/Cache/Value.php @@ -0,0 +1,137 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Value +{ + /** + * Cached value + */ + protected mixed $value; + + /** + * the number of minutes until the value expires + * @todo Rename this property to $expiry to reflect + * both minutes and absolute timestamps + */ + protected int $minutes; + + /** + * Creation timestamp + */ + protected int $created; + + /** + * Constructor + * + * @param int $minutes the number of minutes until the value expires + * or an absolute UNIX timestamp + * @param int|null $created the UNIX timestamp when the value has been created + * (defaults to the current time) + */ + public function __construct($value, int $minutes = 0, int|null $created = null) + { + $this->value = $value; + $this->minutes = $minutes; + $this->created = $created ?? time(); + } + + /** + * Returns the creation date as UNIX timestamp + */ + public function created(): int + { + return $this->created; + } + + /** + * Returns the expiration date as UNIX timestamp or + * null if the value never expires + */ + public function expires(): int|null + { + // 0 = keep forever + if ($this->minutes === 0) { + return null; + } + + if ($this->minutes > 1000000000) { + // absolute timestamp + return $this->minutes; + } + + return $this->created + ($this->minutes * 60); + } + + /** + * Creates a value object from an array + */ + public static function fromArray(array $array): static + { + return new static( + $array['value'] ?? null, + $array['minutes'] ?? 0, + $array['created'] ?? null + ); + } + + /** + * Creates a value object from a JSON string; + * returns null on error + */ + public static function fromJson(string $json): static|null + { + try { + $array = json_decode($json, true); + + if (is_array($array) === true) { + return static::fromArray($array); + } + + return null; + } catch (Throwable) { + return null; + } + } + + /** + * Converts the object to a JSON string + */ + public function toJson(): string + { + return json_encode($this->toArray()); + } + + /** + * Converts the object to an array + */ + public function toArray(): array + { + return [ + 'created' => $this->created, + 'minutes' => $this->minutes, + 'value' => $this->value, + ]; + } + + /** + * Returns the pure value + */ + public function value() + { + return $this->value; + } +} diff --git a/public/kirby/src/Cms/Api.php b/public/kirby/src/Cms/Api.php new file mode 100644 index 0000000..d7945b4 --- /dev/null +++ b/public/kirby/src/Cms/Api.php @@ -0,0 +1,260 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Api extends BaseApi +{ + protected App $kirby; + + public function __construct(array $props) + { + $this->kirby = $props['kirby']; + parent::__construct($props); + } + + /** + * Execute an API call for the given path, + * request method and optional request data + */ + public function call( + string|null $path = null, + string $method = 'GET', + array $requestData = [] + ): mixed { + $this->setRequestMethod($method); + $this->setRequestData($requestData); + + $this->kirby->setCurrentLanguage($this->language()); + + $allowImpersonation = $this->kirby()->option('api.allowImpersonation', false); + + $translation = $this->kirby->user(null, $allowImpersonation)?->language(); + $translation ??= $this->kirby->panelLanguage(); + $this->kirby->setCurrentTranslation($translation); + + return parent::call($path, $method, $requestData); + } + + /** + * Creates a new instance while + * merging initial and new properties + */ + public function clone(array $props = []): static + { + return parent::clone([ + 'kirby' => $this->kirby, + ...$props + ]); + } + + /** + * @throws \Kirby\Exception\NotFoundException if the field type cannot be found or the field cannot be loaded + */ + public function fieldApi( + ModelWithContent $model, + string $name, + string|null $path = null + ): mixed { + $field = Form::for($model)->field($name); + + $fieldApi = $this->clone([ + 'data' => [...$this->data(), 'field' => $field], + 'routes' => $field->api(), + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + + /** + * Returns the file object for the given + * parent path and filename + * + * @param string $path Path to file's parent model + * @throws \Kirby\Exception\NotFoundException if the file cannot be found + */ + public function file( + string $path, + string $filename + ): File|null { + return Find::file($path, $filename); + } + + /** + * Returns the all readable files for the parent + * + * @param string $path Path to file's parent model + * @throws \Kirby\Exception\NotFoundException if the file cannot be found + */ + public function files(string $path): Files + { + return $this->parent($path)->files()->filter('isAccessible', true); + } + + /** + * Returns the model's object for the given path + * + * @param string $path Path to parent model + * @throws \Kirby\Exception\InvalidArgumentException if the model type is invalid + * @throws \Kirby\Exception\NotFoundException if the model cannot be found + */ + public function parent(string $path): ModelWithContent|null + { + return Find::parent($path); + } + + /** + * Returns the Kirby instance + */ + public function kirby(): App + { + return $this->kirby; + } + + /** + * Returns the language request header + */ + public function language(): string|null + { + return + $this->requestQuery('language') ?? + $this->requestHeaders('x-language'); + } + + /** + * Returns the page object for the given id + * + * @param string $id Page's id + * @throws \Kirby\Exception\NotFoundException if the page cannot be found + */ + public function page(string $id): Page|null + { + return Find::page($id); + } + + /** + * Returns the subpages for the given + * parent. The subpages can be filtered + * by status (draft, listed, unlisted, published, all) + */ + public function pages( + string|null $parentId = null, + string|null $status = null + ): Pages { + $parent = $parentId === null ? $this->site() : $this->page($parentId); + $pages = match ($status) { + 'all' => $parent->childrenAndDrafts(), + 'draft', 'drafts' => $parent->drafts(), + 'listed' => $parent->children()->listed(), + 'unlisted' => $parent->children()->unlisted(), + 'published' => $parent->children(), + default => $parent->children() + }; + + return $pages->filter('isAccessible', true); + } + + /** + * Search for direct subpages of the + * given parent + */ + public function searchPages(string|null $parent = null): Pages + { + $pages = $this->pages($parent, $this->requestQuery('status')); + + if ($this->requestMethod() === 'GET') { + return $pages->search($this->requestQuery('q')); + } + + return $pages->query($this->requestBody()); + } + + /** + * @throws \Kirby\Exception\NotFoundException if the section type cannot be found or the section cannot be loaded + */ + public function sectionApi( + ModelWithContent $model, + string $name, + string|null $path = null + ): mixed { + if (!$section = $model->blueprint()?->section($name)) { + throw new NotFoundException( + message: 'The section "' . $name . '" could not be found' + ); + } + + $sectionApi = $this->clone([ + 'data' => [...$this->data(), 'section' => $section], + 'routes' => $section->api(), + ]); + + return $sectionApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + + /** + * Returns the current Session instance + * + * @param array $options Additional options, see the session component + */ + public function session(array $options = []): Session + { + return $this->kirby->session(['detect' => true, ...$options]); + } + + /** + * Returns the site object + */ + public function site(): Site + { + return $this->kirby->site(); + } + + /** + * Returns the user object for the given id or + * returns the current authenticated user if no + * id is passed + * + * @throws \Kirby\Exception\NotFoundException if the user for the given id cannot be found + */ + public function user(string|null $id = null): User|null + { + try { + return Find::user($id); + } catch (NotFoundException $e) { + if ($id === null) { + return null; + } + + throw $e; + } + } + + /** + * Returns the users collection + */ + public function users(): Users + { + return $this->kirby->users(); + } +} diff --git a/public/kirby/src/Cms/App.php b/public/kirby/src/Cms/App.php new file mode 100644 index 0000000..b7b29f4 --- /dev/null +++ b/public/kirby/src/Cms/App.php @@ -0,0 +1,1727 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class App +{ + use AppCaches; + use AppErrors; + use AppPlugins; + use AppTranslations; + use AppUsers; + + public const CLASS_ALIAS = 'kirby'; + + protected static App|null $instance = null; + protected static string|null $version = null; + + public array $data = []; + + protected Api|null $api = null; + protected Collections|null $collections = null; + 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 bool|null $multilang = null; + protected string|null $nonce = null; + protected array $options; + protected string|null $path = null; + protected Request|null $request = null; + protected Responder|null $response = null; + protected Roles|null $roles = null; + protected Ingredients $roots; + protected array|null $routes = null; + protected Router|null $router = null; + protected AutoSession|null $sessionHandler = null; + protected Site|null $site = null; + protected System|null $system = null; + protected Ingredients $urls; + protected Visitor|null $visitor = null; + + protected array $propertyData; + + /** + * Creates a new App instance + * + * @param bool $setInstance If false, the instance won't be set globally + */ + public function __construct(array $props = [], bool $setInstance = true) + { + $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'] ?? []); + + try { + // stuff from config and additional options + $this->optionsFromConfig(); + $this->optionsFromProps($props['options'] ?? []); + $this->optionsFromEnvironment($props); + } finally { + // register the Whoops error handler inside of a + // try-finally block to ensure it's still registered + // even if there is a problem loading the configurations + $this->handleErrors(); + } + + $this->propertyData = $props; + + // a custom request setup must come before defining the path + $this->setRequest($props['request'] ?? null); + + // set the path to make it available for the url bakery + $this->setPath($props['path'] ?? null); + + // create all urls after the config, so possible + // options can be taken into account + $this->bakeUrls($props['urls'] ?? []); + + // configurable properties + $this->setLanguages($props['languages'] ?? null); + $this->setRoles($props['roles'] ?? null); + $this->setUser($props['user'] ?? null); + $this->setUsers($props['users'] ?? null); + + // set the singleton + if (static::$instance === null || $setInstance === true) { + static::$instance = ModelWithContent::$kirby = $this; + } + + // setup the I18n class with the translation loader + $this->i18n(); + + // load all extensions + $this->extensionsFromSystem(); + $this->extensionsFromProps($props); + $this->extensionsFromPlugins(); + $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'); + + // execute a ready callback from the config + $this->optionsFromReadyCallback(); + + // bake config + $this->bakeOptions(); + } + + /** + * Improved `var_dump` output + * + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + 'languages' => $this->languages(), + 'options' => $this->options(), + 'request' => $this->request(), + 'roots' => $this->roots(), + 'site' => $this->site(), + 'urls' => $this->urls(), + 'version' => static::version(), + ]; + } + + /** + * Returns the Api instance + * + * @unstable + */ + public function api(): Api + { + if ($this->api !== null) { + return $this->api; + } + + $root = $this->root('kirby') . '/config/api'; + $extensions = $this->extensions['api'] ?? []; + $routes = (include $root . '/routes.php')($this); + + return $this->api = new Api([ + 'debug' => $this->option('debug', false), + 'authentication' => $extensions['authentication'] ?? include $root . '/authentication.php', + 'data' => $extensions['data'] ?? [], + 'collections' => [ + ...$extensions['collections'] ?? [], + ...include $root . '/collections.php' + ], + 'models' => [ + ...$extensions['models'] ?? [], + ...include $root . '/models.php' + ], + 'routes' => [ + ...$routes, + ...$extensions['routes'] ?? [] + ], + 'kirby' => $this, + ]); + } + + /** + * Applies a hook to the given value + * + * @param string $name Full event name + * @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|null $modify = null + ): mixed { + return $this->events->apply($name, $args, $modify); + } + + /** + * Normalizes and globally sets the configured options + * + * @return $this + */ + protected function bakeOptions(): static + { + // convert the old plugin option syntax to the new one + foreach ($this->options as $key => $value) { + // detect option keys with the `vendor.plugin.option` format + if (preg_match('/^([a-z0-9-]+\.[a-z0-9-]+)\.(.*)$/i', $key, $matches) === 1) { + [, $plugin, $option] = $matches; + + // verify that it's really a plugin option + if (isset(static::$plugins[str_replace('.', '/', $plugin)]) !== true) { + continue; + } + + // ensure that the target option array exists + // (which it will if the plugin has any options) + if (isset($this->options[$plugin]) !== true) { + $this->options[$plugin] = []; // @codeCoverageIgnore + } + + // 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] + ); + unset($this->options[$key]); + } + } + + Config::$data = $this->options; + return $this; + } + + /** + * Sets the directory structure + * + * @return $this + */ + protected function bakeRoots(array|null $roots = null): static + { + $roots = [...$this->core->roots(), ...$roots ?? []]; + $this->roots = Ingredients::bake($roots); + return $this; + } + + /** + * Sets the Url structure + * + * @return $this + */ + protected function bakeUrls(array|null $urls = null): static + { + $urls = [...$this->core->urls(), ...$urls ?? []]; + $this->urls = Ingredients::bake($urls); + return $this; + } + + /** + * Returns all available blueprints for this installation + */ + public function blueprints(string $type = 'pages'): array + { + $blueprints = []; + + foreach ($this->extensions('blueprints') as $name => $blueprint) { + if (dirname($name) === $type) { + $name = basename($name); + $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); + + return array_values($blueprints); + } + + /** + * Calls any Kirby route + */ + public function call(string|null $path = null, string|null $method = null): mixed + { + $path ??= $this->path(); + $method ??= $this->request()->method(); + return $this->router()->call($path, $method); + } + + /** + * Creates an instance with the same + * initial properties + * + * @param bool $setInstance If false, the instance won't be set globally + */ + public function clone(array $props = [], bool $setInstance = true): static + { + $props = array_replace_recursive($this->propertyData, $props); + + $clone = new static($props, $setInstance); + $clone->data = $this->data; + + return $clone; + } + + /** + * Returns a specific user-defined collection + * by name. All relevant dependencies are + * automatically injected + * + * @return \Kirby\Toolkit\Collection|null + * @todo 6.0 Add return type declaration + */ + public function collection(string $name, array $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()) + + ]); + } + + /** + * Returns all user-defined collections + */ + public function collections(): Collections + { + return $this->collections ??= new Collections(); + } + + /** + * Returns a core component + */ + public function component(string $name): mixed + { + return $this->extensions['components'][$name] ?? null; + } + + /** + * Returns the content extension + */ + public function contentExtension(): string + { + return $this->options['content']['extension'] ?? 'txt'; + } + + /** + * Returns files that should be ignored when scanning folders + */ + public function contentIgnore(): array + { + return $this->options['content']['ignore'] ?? Dir::$ignore; + } + + /** + * Generates a non-guessable token based on model + * data and a configured salt + * + * @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(object|null $model, string $value): string + { + $default = $this->root('content'); + + if ($model !== null && method_exists($model, 'id') === true) { + $default .= '/' . $model->id(); + } + + $salt = $this->option('content.salt', $default); + + if ($salt instanceof Closure) { + $salt = $salt($model); + } + + return hash_hmac('sha1', $value, $salt); + } + + /** + * Calls a page controller by name + * and with the given arguments + */ + public function controller( + string $name, + array $arguments = [], + string $contentType = 'html' + ): array { + $name = strtolower($name); + $data = []; + + // always use the site controller as defaults, if available + // (unless the controller is a snippet controller) + if (strpos($name, '/') === false) { + $site = $this->controllerLookup('site', $contentType); + $site ??= $this->controllerLookup('site'); + $data = (array)$site?->call($this, $arguments) ?? []; + } + + // try to find a specific representation controller + $controller = $this->controllerLookup($name, $contentType); + // no luck for a specific representation controller? + // let's try the html controller instead + $controller ??= $this->controllerLookup($name); + + return [ + ...$data, + ...(array)$controller?->call($this, $arguments) ?? [] + ]; + } + + /** + * Try to find a controller by name + */ + protected function controllerLookup( + string $name, + string $contentType = 'html' + ): Controller|null { + if ($contentType !== null && $contentType !== 'html') { + $name .= '.' . $contentType; + } + + // controller from site root + $controller = Controller::load( + file: $this->root('controllers') . '/' . $name . '.php', + in: $this->root('controllers') + ); + + // controller from extension + $controller ??= $this->extension('controllers', $name); + + if ($controller instanceof Controller) { + return $controller; + } + + if ($controller !== null) { + return new Controller($controller); + } + + return null; + } + + /** + * Get access to object that lists + * all parts of Kirby core + */ + public function core(): Core + { + return $this->core; + } + + /** + * Checks/returns a CSRF token + * @since 3.7.0 + * + * @param string|null $check Pass a token here to compare it to the one in the session + * @return string|bool Either the token or a boolean check result + */ + public function csrf(string|null $check = null): string|bool + { + $session = $this->session(); + + // no arguments, generate/return a token + // (check explicitly if there have been no arguments at all; + // checking for null introduces a security issue because null could come + // from user input or bugs in the calling code!) + if (func_num_args() === 0) { + $token = $session->get('kirby.csrf'); + + if (is_string($token) !== true) { + $token = bin2hex(random_bytes(32)); + $session->set('kirby.csrf', $token); + } + + return $token; + } + + // argument has been passed, check the token + if ( + is_string($check) === true && + is_string($session->get('kirby.csrf')) === true + ) { + return hash_equals($session->get('kirby.csrf'), $check) === true; + } + + return false; + } + + /** + * Returns the current language, if set by `static::setCurrentLanguage` + */ + public function currentLanguage(): Language|null + { + return $this->language ??= $this->defaultLanguage(); + } + + /** + * Returns the default language object + */ + public function defaultLanguage(): Language|null + { + return $this->defaultLanguage ??= $this->languages()->default(); + } + + /** + * Destroy the instance singleton and + * purge other static props + * + * @internal + */ + public static function destroy(): void + { + static::$plugins = []; + static::$instance = null; + } + + /** + * Detect the preferred language from the visitor object + */ + public function detectedLanguage(): Language|null + { + $languages = $this->languages(); + $visitor = $this->visitor(); + + foreach ($visitor->acceptedLanguages() as $acceptedLang) { + $acceptedCode = $acceptedLang->code(); + $acceptedLocale = $acceptedLang->locale(); + + $match = fn (Language $language, int $precision) => + Str::substr($language->locale(LC_ALL), 0, $precision) === + Str::substr($acceptedLocale, 0, $precision); + + // Find exact locale matches (e.g. en_GB => en_GB) + if ($language = $languages->filter(fn ($language) => $match($language, 5))?->first()) { + return $language; + } + + // Find exact code matches + if ($language = $languages->findBy('code', $acceptedCode)) { + return $language; + } + + // Find broad locale matches (e.g. en_GB => en) + if ($language = $languages->filter(fn ($language) => $match($language, 2))?->first()) { + return $language; + } + } + + return $this->defaultLanguage(); + } + + /** + * Returns the Email singleton + */ + public function email(mixed $preset = [], array $props = []): BaseEmail + { + $debug = $props['debug'] ?? false; + $props = (new Email($preset, $props))->toArray(); + + return ($this->component('email'))($this, $props, $debug); + } + + /** + * Returns the environment object with access + * to the detected host, base url and dedicated options + */ + public function environment(): Environment + { + return $this->environment ??= new Environment(); + } + + /** + * Finds any file in the content directory + */ + public function file( + string $path, + mixed $parent = null, + bool $drafts = true + ): File|null { + // find by global UUID + if (Uuid::is($path, 'file') === true) { + // prefer files of parent, when parent given + return Uuid::for($path, $parent?->files())->model(); + } + + $parent ??= $this->site(); + $id = dirname($path); + $filename = basename($path); + + if ($parent instanceof User) { + return $parent->file($filename); + } + + if ($parent instanceof File) { + $parent = $parent->parent(); + } + + if ($id === '.') { + return $parent->file($filename) ?? $this->site()->file($filename); + } + + if ($page = $this->page($id, $parent, $drafts)) { + return $page->file($filename); + } + + if ($page = $this->page($id, null, $drafts)) { + return $page->file($filename); + } + + return null; + } + + /** + * Return an image from any page + * specified by the path + * + * Example: + * image('some/page/myimage.jpg') ?> + * + * @todo merge with App::file() + */ + public function image(string|null $path = null): File|null + { + if ($path === null) { + return $this->site()->page()->image(); + } + + $uri = dirname($path); + $filename = basename($path); + + if ($uri === '.') { + $uri = null; + } + + $parent = match ($uri) { + '/' => $this->site(), + null => $this->site()->page(), + default => $this->site()->page($uri) + }; + + return $parent?->image($filename); + } + + /** + * Returns the current App instance + * + * @param bool $lazy If `true`, the instance is only returned if already existing + * @psalm-return ($lazy is false ? static : static|null) + */ + public static function instance( + self|null $instance = null, + bool $lazy = false + ): static|null { + if ($instance !== null) { + return static::$instance = $instance; + } + + if ($lazy === true) { + return static::$instance; + } + + return static::$instance ?? new static(); + } + + /** + * Takes almost any kind of input and + * tries to convert it into a valid response + * + * @unstable + */ + public function io(mixed $input): Response + { + // use the current response configuration + $response = $this->response(); + + // any direct exception will be turned into an error page + if ($input instanceof Throwable) { + $message = $input->getMessage(); + $code = match (true) { + $input instanceof Exception => $input->getHttpCode(), + default => $input->getCode() + }; + + if ($code < 400 || $code > 599) { + $code = 500; + } + + if ($errorPage = $this->site()->errorPage()) { + return $response->code($code)->send($errorPage->render([ + 'errorCode' => $code, + 'errorMessage' => $message, + 'errorType' => $input::class + ])); + } + + return $response + ->code($code) + ->type('text/html') + ->send($message); + } + + // Empty input + if (empty($input) === true) { + return $this->io(new NotFoundException()); + } + + // (Modified) global response configuration, e.g. in routes + if ($input instanceof Responder) { + // return the passed object unmodified (without injecting headers + // from the global object) to allow a complete response override + // https://github.com/getkirby/kirby/pull/4144#issuecomment-1034766726 + return $input->send(); + } + + // Responses + if ($input instanceof Response) { + return $response->send($input); + } + + // Pages + if ($input instanceof Page) { + try { + $html = $input->render(); + } catch (ErrorPageException|NotFoundException $e) { + return $this->io($e); + } + + if ( + $input->isErrorPage() === true && + $response->code() === null + ) { + $response->code(404); + } + + return $response->send($html); + } + + // Files + if ($input instanceof File) { + return $response->redirect($input->mediaUrl(), 307)->send(); + } + + // Simple HTML response + if (is_string($input) === true) { + return $response->send($input); + } + + // array to json conversion + if (is_array($input) === true) { + return $response->json($input)->send(); + } + + throw new InvalidArgumentException( + message: 'Unexpected input' + ); + } + + /** + * Renders a single KirbyTag with the given attributes + * + * @param string|array $type Tag type or array with all tag arguments + * (the key of the first element becomes the type) + */ + public function kirbytag( + string|array $type, + string|null $value = null, + array $attr = [], + array $data = [] + ): string { + if (is_array($type) === true) { + $kirbytag = $type; + $type = key($kirbytag); + $value = current($kirbytag); + $attr = $kirbytag; + + // check data attribute and separate from attr data if exists + if (isset($attr['data']) === true) { + $data = $attr['data']; + unset($attr['data']); + } + } + + $data['kirby'] ??= $this; + $data['site'] ??= $data['kirby']->site(); + $data['parent'] ??= $data['site']->page(); + + return (new KirbyTag($type, $value, $attr, $data, $this->options))->render(); + } + + /** + * Parses and resolves KirbyTags in text + */ + 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 = KirbyTags::parse($text, $data, $options); + $text = $this->apply('kirbytags:after', compact('text', 'data', 'options')); + + return $text; + } + + /** + * Parses KirbyTags first and Markdown afterwards + */ + 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'] ?? []); + + if ($this->option('smartypants', false) !== false) { + $text = $this->smartypants($text); + } + + $text = $this->apply('kirbytext:after', compact('text')); + + return $text; + } + + /** + * Returns the language by code or shortcut (`default`, `current`). + * Passing `null` is an alias for passing `current` + */ + public function language(string|null $code = null): Language|null + { + if ($this->multilang() === false) { + return null; + } + + return match ($code ?? 'current') { + 'default' => $this->defaultLanguage(), + 'current' => $this->currentLanguage(), + default => $this->languages()->find($code) + }; + } + + /** + * Returns the current language code + */ + public function languageCode(string|null $languageCode = null): string|null + { + return $this->language($languageCode)?->code(); + } + + /** + * Returns all available site languages + */ + public function languages(bool $clone = true): Languages + { + if ($clone === false) { + $this->multilang = null; + $this->defaultLanguage = null; + } + + if ($this->languages !== null) { + return match($clone) { + true => clone $this->languages, + false => $this->languages + }; + } + + return $this->languages = Languages::load(); + } + + /** + * Access Kirby's part loader + */ + public function load(): Loader + { + return new Loader($this); + } + + /** + * Parses Markdown + */ + public function markdown(string|null $text = null, array|null $options = null): string + { + // merge global options with local options + $options = [ + ...$this->options['markdown'] ?? [], + ...$options ?? [] + ]; + + return ($this->component('markdown'))($this, $text, $options); + } + + /** + * Yields all models (site, pages, files and users) of this site + * @since 4.0.0 + * + * @return \Generator|\Kirby\Cms\ModelWithContent[] + */ + public function models(): Generator + { + $site = $this->site(); + + yield from $site->files(); + yield $site; + + foreach ($site->index(true) as $page) { + yield from $page->files(); + yield $page; + } + + foreach ($this->users() as $user) { + yield from $user->files(); + yield $user; + } + } + + /** + * Check for a multilang setup + */ + public function multilang(): bool + { + return $this->multilang ??= $this->languages()->count() !== 0; + } + + /** + * Returns the nonce, which is used + * in the panel for inline scripts + * @since 3.3.0 + */ + public function nonce(): string + { + return $this->nonce ??= base64_encode(random_bytes(20)); + } + + /** + * Load a specific configuration option + */ + public function option(string $key, mixed $default = null): mixed + { + return A::get($this->options, $key, $default); + } + + /** + * Returns all configuration options + */ + public function options(): array + { + return $this->options; + } + + /** + * Load all options from files in site/config + */ + protected function optionsFromConfig(): array + { + // create an empty config container + Config::$data = []; + + // load the main config options + $root = $this->root('config'); + $options = F::load($root . '/config.php', [], allowOutput: false); + + // merge into one clean options array + return $this->options = array_replace_recursive(Config::$data, $options); + } + + /** + * Load all options for the current + * server environment + */ + protected function optionsFromEnvironment(array $props = []): array + { + $root = $this->root('config'); + + // first load `config/env.php` to access its `url` option + $envOptions = F::load($root . '/env.php', [], allowOutput: false); + + // use the option from the main `config.php`, + // but allow the `env.php` to override it + $globalUrl = $envOptions['url'] ?? $this->options['url'] ?? null; + + // create the URL setup based on hostname and server IP address + $this->environment = new Environment([ + 'allowed' => $globalUrl, + 'cli' => $props['cli'] ?? null, + ], $props['server'] ?? null); + + // 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 + ); + + // reload the environment if the host/address config has overridden + // the `url` option; this ensures that the base URL is correct + $envUrl = $this->options['url'] ?? null; + if ($envUrl !== $globalUrl) { + $this->environment->detect([ + 'allowed' => $envUrl, + 'cli' => $props['cli'] ?? null + ], $props['server'] ?? null); + } + + return $this->options; + } + + /** + * Inject options from Kirby instance props + */ + protected function optionsFromProps(array $options = []): array + { + return $this->options = array_replace_recursive( + $this->options, + $options + ); + } + + /** + * Merge last-minute options from ready callback + */ + protected function optionsFromReadyCallback(): array + { + if ( + isset($this->options['ready']) === true && + is_callable($this->options['ready']) === true + ) { + // fetch last-minute options from the callback + $options = (array)$this->options['ready']($this); + + // inject all last-minute options recursively + $this->options = array_replace_recursive($this->options, $options); + + // update the system with changed options + if ( + isset($options['debug']) === true || + isset($options['whoops']) === true || + isset($options['editor']) === true + ) { + $this->handleErrors(); + } + + if (isset($options['debug']) === true) { + $this->api = null; + } + + 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 + ) { + $this->i18n(); + } + } + } + + return $this->options; + } + + /** + * Returns any page from the content folder + */ + public function page( + string|null $id = null, + Page|Site|null $parent = null, + bool $drafts = true + ): Page|null { + if ($id === null) { + return null; + } + + $parent ??= $this->site(); + + if ($page = $parent->find($id)) { + /** + * We passed a single $id, we can be sure that the result is + * @var \Kirby\Cms\Page $page + */ + return $page; + } + + if ($drafts === true && $draft = $parent->draft($id)) { + return $draft; + } + + return null; + } + + /** + * Returns the request path + */ + public function path(): string + { + if (is_string($this->path) === true) { + return $this->path; + } + + $current = $this->request()->path()->toString(); + $index = $this->environment()->baseUri()->path()->toString(); + $path = Str::afterStart($current, $index); + + return $this->setPath($path)->path; + } + + /** + * Returns the Response object for the + * current request + */ + public function render( + string|null $path = null, + string|null $method = null + ): Response|null { + if ((filter_var($_ENV['KIRBY_RENDER'] ?? true, FILTER_VALIDATE_BOOLEAN)) === false) { + return null; + } + + return $this->io($this->call($path, $method)); + } + + /** + * Returns the Request singleton + */ + public function request(): Request + { + if ($this->request !== null) { + return $this->request; + } + + $env = $this->environment(); + + return $this->request = new Request([ + 'cli' => $env->cli(), + 'url' => $env->requestUri() + ]); + } + + /** + * Path resolver for the router + * + * @unstable + * @throws \Kirby\Exception\NotFoundException if the home page cannot be found + */ + public function resolve( + string|null $path = null, + string|null $language = null + ): mixed { + // set the current translation + $this->setCurrentTranslation($language); + + // set the current locale + $this->setCurrentLanguage($language); + + // directly prevent path with incomplete content representation + if (Str::endsWith($path, '.') === true) { + return null; + } + + // the site is needed a couple times here + $site = $this->site(); + + // use the home page + if ($path === null) { + if ($homePage = $site->homePage()) { + return $homePage; + } + + throw new NotFoundException( + message: 'The home page does not exist' + ); + } + + // search for the page by path + $page = $site->find($path); + + // search for a draft if the page cannot be found + if (!$page && $draft = $site->draft($path)) { + if ( + $this->user() || + $draft->renderVersionFromRequest() !== null + ) { + $page = $draft; + } + } + + // try to resolve content representations if the path has an extension + $extension = F::extension($path); + + // no content representation? then return the page + if (empty($extension) === true) { + return $page; + } + + // only try to return a representation + // when the page has been found + if ($page) { + // if extension is the default content type, + // redirect to page URL without extension + if ($extension === 'html') { + return Response::redirect($page->url(), 301); + } + + try { + $response = $this->response(); + $output = $page->render([], $extension); + + // attach a MIME type based on the representation + // only if no custom MIME type was set + if ($response->type() === null) { + $response->type($extension); + } + + return $response->body($output); + } catch (NotFoundException) { + return null; + } + } + + // 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 clean URLs to files for pages and drafts + if ($page = $site->findPageOrDraft($id)) { + return $this->resolveFile($page->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; + } + + /** + * Response configuration + */ + public function response(): Responder + { + return $this->response ??= new Responder(); + } + + /** + * Returns a system root + */ + public function root(string $type = 'index'): string|null + { + return $this->roots->__get($type); + } + + /** + * Returns the directory structure + */ + public function roots(): Ingredients + { + return $this->roots; + } + + /** + * Returns the currently active route + */ + public function route(): Route|null + { + return $this->router()->route(); + } + + /** + * Returns the Router singleton + */ + public function router(): Router + { + if ($this->router !== null) { + return $this->router; + } + + $routes = $this->routes(); + + if ($this->multilang() === true) { + foreach ($routes as $index => $route) { + if (empty($route['language']) === false) { + unset($routes[$index]); + } + } + } + + $hooks = [ + 'beforeEach' => function ($route, $path, $method) { + $this->trigger('route:before', compact('route', 'path', 'method')); + }, + 'afterEach' => function ($route, $path, $method, $result, $final) { + return $this->apply('route:after', compact('route', 'path', 'method', 'result', 'final'), 'result'); + } + ]; + + return $this->router = new Router($routes, $hooks); + } + + /** + * Returns all defined routes + */ + public function routes(): array + { + if (is_array($this->routes) === true) { + return $this->routes; + } + + $registry = $this->extensions('routes'); + $system = $this->core->routes(); + $routes = [...$system['before'], ...$registry, ...$system['after']]; + + return $this->routes = $routes; + } + + /** + * Returns the current session object + * + * @param array $options Additional options, see the session component + */ + public function session(array $options = []): Session + { + $session = $this->sessionHandler()->get($options); + + // disable caching for sessions that use the `Authorization` header; + // cookie sessions are already covered by the `Cookie` class + if ($session->mode() === 'manual') { + $this->response()->cache(false); + $this->response()->header('Cache-Control', 'no-store, private', true); + } + + return $session; + } + + /** + * Returns the session handler + */ + public function sessionHandler(): AutoSession + { + return $this->sessionHandler ??= new AutoSession( + ($this->component('session::store'))($this), + $this->option('session', []) + ); + } + + /** + * Load and set the current language if it exists + * Otherwise fall back to the default language + */ + public function setCurrentLanguage( + string|null $languageCode = null + ): Language|null { + if ($this->multilang() === false) { + Locale::set($this->option('locale', 'en_US.utf-8')); + return $this->language = null; + } + + $this->language = $this->language($languageCode); + $this->language ??= $this->defaultLanguage(); + + Locale::set($this->language->locale()); + + // add language slug rules to Str class + Str::$language = $this->language->rules(); + + return $this->language; + } + + /** + * Create your own set of languages + * + * @return $this + */ + protected function setLanguages(array|null $languages = null): static + { + if ($languages !== null) { + $objects = []; + + foreach ($languages as $props) { + $objects[] = new Language($props); + } + + $this->languages = new Languages($objects); + } + + return $this; + } + + /** + * Sets the request path that is + * used for the router + * + * @return $this + */ + protected function setPath(string|null $path = null): static + { + $this->path = $path !== null ? trim($path, '/') : null; + return $this; + } + + /** + * Sets the request + * + * @return $this + */ + protected function setRequest(array|null $request = null): static + { + if ($request !== null) { + $this->request = new Request($request); + } + + return $this; + } + + /** + * Create your own set of roles + * + * @return $this + */ + protected function setRoles(array|null $roles = null): static + { + if ($roles !== null) { + $this->roles = Roles::factory($roles); + } + + return $this; + } + + /** + * Sets a custom Site object + * + * @return $this + */ + public function setSite(Site|array|null $site = null): static + { + if (is_array($site) === true) { + $site = new Site($site); + } + + $this->site = $site; + return $this; + } + + /** + * Initializes and returns the Site object + */ + public function site(): Site + { + return $this->site ??= new Site([ + 'errorPageId' => $this->options['error'] ?? 'error', + 'homePageId' => $this->options['home'] ?? 'home', + 'url' => $this->url('index'), + ]); + } + + /** + * Applies the smartypants rule on the text + */ + public function smartypants(string|null $text = null): string + { + $options = $this->option('smartypants', []); + + if ($options === false) { + return $text; + } + + if (is_array($options) === false) { + $options = []; + } + + if ($this->multilang() === true) { + $languageSmartypants = $this->language()->smartypants() ?? []; + + if ($languageSmartypants !== []) { + $options = [...$options, ...$languageSmartypants]; + } + } + + return ($this->component('smartypants'))($this, $text, $options); + } + + /** + * Uses the snippet component to create + * and return a template snippet + * + * @param array|object $data Variables or an object that becomes `$item` + * @param bool $return On `false`, directly echo the snippet + * @psalm-return ($return is true ? string : null) + */ + public function snippet( + string|array|null $name, + array|object $data = [], + bool $return = true, + bool $slots = false + ): Snippet|string|null { + if (is_object($data) === true) { + $data = ['item' => $data]; + } + + $snippet = ($this->component('snippet'))( + $this, + $name, + [...$this->data, ...$data], + $slots + ); + + if ($return === true || $slots === true) { + return $snippet; + } + + echo $snippet; + 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 + */ + public function system(): System + { + return $this->system ??= new System($this); + } + + /** + * Uses the template component to initialize + * and return the Template object + */ + public function template( + string $name, + string $type = 'html', + string $defaultType = 'html' + ): Template { + return ($this->component('template'))($this, $name, $type, $defaultType); + } + + /** + * Thumbnail creator + */ + public function thumb(string $src, string $dst, array $options = []): string + { + return ($this->component('thumb'))($this, $src, $dst, $options); + } + + /** + * Trigger a hook by name + * + * @param string $name Full event name + * @param array $args Associative array of named arguments + */ + public function trigger( + string $name, + array $args = [] + ): void { + $this->events->trigger($name, $args); + } + + /** + * Returns a system url + * + * @param bool $object If set to `true`, the URL is converted to an object + * @psalm-return ($object is false ? string|null : \Kirby\Http\Uri) + */ + public function url( + string $type = 'index', + bool $object = false + ): string|Uri|null { + $url = $this->urls->__get($type); + + if ($object === true) { + if (Url::isAbsolute($url)) { + return Url::toObject($url); + } + + // index URL was configured without host, use the current host + return Uri::current([ + 'path' => $url, + 'query' => null + ]); + } + + return $url; + } + + /** + * Returns the url structure + */ + public function urls(): Ingredients + { + return $this->urls; + } + + /** + * Returns the current version number from + * the composer.json (Keep that up to date! :)) + * + * @throws \Kirby\Exception\LogicException if the Kirby version cannot be detected + */ + public static function version(): string|null + { + try { + return static::$version ??= Data::read(dirname(__DIR__, 2) . '/composer.json')['version'] ?? null; + } catch (Throwable) { + throw new LogicException( + message: 'The Kirby version cannot be detected. The composer.json is probably missing or not readable.' + ); + } + } + + /** + * Creates a hash of the version number + */ + public static function versionHash(): string + { + return md5(static::version()); + } + + /** + * Returns the visitor object + */ + public function visitor(): Visitor + { + return $this->visitor ??= new Visitor(); + } +} diff --git a/public/kirby/src/Cms/AppCaches.php b/public/kirby/src/Cms/AppCaches.php new file mode 100644 index 0000000..8ea7b5d --- /dev/null +++ b/public/kirby/src/Cms/AppCaches.php @@ -0,0 +1,131 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait AppCaches +{ + protected array $caches = []; + + /** + * Returns a cache instance by key + */ + public function cache(string $key): Cache + { + if (isset($this->caches[$key]) === true) { + return $this->caches[$key]; + } + + // get the options for this cache type + $options = $this->cacheOptions($key); + + if ($options['active'] === false) { + // use a dummy cache that does nothing + return $this->caches[$key] = new NullCache(); + } + + $type = strtolower($options['type']); + $types = $this->extensions['cacheTypes'] ?? []; + + if (array_key_exists($type, $types) === false) { + throw new InvalidArgumentException( + key: 'cache.type.invalid', + data: ['type' => $type] + ); + } + + $className = $types[$type]; + + // initialize the cache class + $cache = new $className($options); + + // check if it is a usable cache object + if ($cache instanceof Cache === false) { + throw new InvalidArgumentException( + key: 'cache.type.invalid', + data: ['type' => $type] + ); + } + + return $this->caches[$key] = $cache; + } + + /** + * Returns the cache options by key + */ + protected function cacheOptions(string $key): array + { + $options = $this->option($this->cacheOptionsKey($key), null); + $options ??= $this->core()->caches()[$key] ?? false; + + if ($options === false) { + return [ + 'active' => false + ]; + } + + $prefix = + str_replace(['/', ':'], '_', $this->system()->indexUrl()) . + '/' . + str_replace(['/', '.'], ['_', '/'], $key); + + $defaults = [ + 'active' => true, + 'type' => 'file', + 'extension' => 'cache', + 'root' => $this->root('cache'), + 'prefix' => $prefix + ]; + + if ($options === true) { + return $defaults; + } + + return [...$defaults, ...$options]; + } + + /** + * Takes care of converting prefixed plugin cache setups + * to the right cache key, while leaving regular cache + * setups untouched. + */ + protected function cacheOptionsKey(string $key): string + { + $prefixedKey = 'cache.' . $key; + + if (isset($this->options[$prefixedKey])) { + return $prefixedKey; + } + + // plain keys without dots don't need further investigation + // since they can never be from a plugin. + if (str_contains($key, '.') === false) { + return $prefixedKey; + } + + // try to extract the plugin name + $parts = explode('.', $key); + $pluginName = implode('/', array_slice($parts, 0, 2)); + $pluginPrefix = implode('.', array_slice($parts, 0, 2)); + $cacheName = implode('.', array_slice($parts, 2)); + + // check if such a plugin exists + if ($this->plugin($pluginName)) { + return empty($cacheName) === true ? $pluginPrefix . '.cache' : $pluginPrefix . '.cache.' . $cacheName; + } + + return $prefixedKey; + } +} diff --git a/public/kirby/src/Cms/AppErrors.php b/public/kirby/src/Cms/AppErrors.php new file mode 100644 index 0000000..1dc565e --- /dev/null +++ b/public/kirby/src/Cms/AppErrors.php @@ -0,0 +1,228 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait AppErrors +{ + /** + * Allows to disable Whoops globally in CI; + * can be overridden by explicitly setting + * the `whoops` option to `true` or `false` + */ + public static bool $enableWhoops = true; + + /** + * Whoops instance cache + */ + protected Whoops $whoops; + + /** + * Registers the PHP error handler for CLI usage + */ + protected function handleCliErrors(): void + { + $this->setWhoopsHandler(new PlainTextHandler()); + } + + /** + * Registers the PHP error handler + * based on the environment + */ + protected function handleErrors(): void + { + // no matter the environment, exit early if + // Whoops was disabled globally + // (but continue if the option was explicitly + // set to `true` in the config) + if ( + static::$enableWhoops === false && + $this->option('whoops') !== true + ) { + return; + } + + if ($this->environment()->cli() === true) { + $this->handleCliErrors(); + return; + } + + if ($this->visitor()->prefersJson() === true) { + $this->handleJsonErrors(); + return; + } + + $this->handleHtmlErrors(); + } + + /** + * Registers the PHP error handler for HTML output + */ + protected function handleHtmlErrors(): void + { + $handler = null; + + if ($this->option('debug') === true) { + if ($this->option('whoops', true) !== false) { + $handler = new PrettyPageHandler(); + $handler->setPageTitle('Kirby CMS Debugger'); + $handler->addResourcePath(dirname(__DIR__, 2) . '/assets'); + $handler->addCustomCss('whoops.css'); + + if ($editor = $this->option('editor')) { + $handler->setEditor($editor); + } + + if ($blocklist = $this->option('whoops.blocklist')) { + foreach ($blocklist as $superglobal => $vars) { + foreach ($vars as $var) { + $handler->blacklist($superglobal, $var); + } + } + } + } + } else { + $handler = new CallbackHandler(function ($exception, $inspector, $run) { + $fatal = $this->option('fatal'); + + if ($fatal instanceof Closure) { + echo $fatal($this, $exception); + } else { + include $this->root('kirby') . '/views/fatal.php'; + } + + return Handler::QUIT; + }); + } + + if ($handler !== null) { + $this->setWhoopsHandler($handler); + } else { + $this->unsetWhoopsHandler(); + } + } + + /** + * Registers the PHP error handler for JSON output + */ + protected function handleJsonErrors(): void + { + $handler = new CallbackHandler(function ($exception, $inspector, $run) { + if ($exception instanceof Exception) { + $httpCode = $exception->getHttpCode(); + $code = $exception->getCode(); + $details = $exception->getDetails(); + } elseif ($exception instanceof Throwable) { + $httpCode = 500; + $code = $exception->getCode(); + $details = null; + } else { + $httpCode = 500; + $code = 500; + $details = null; + } + + if ($this->option('debug') === true) { + echo Response::json([ + 'status' => 'error', + 'exception' => $exception::class, + 'code' => $code, + 'message' => $exception->getMessage(), + 'details' => $details, + 'file' => F::relativepath( + $exception->getFile(), + $this->environment()->get('DOCUMENT_ROOT', '') + ), + 'line' => $exception->getLine(), + ], $httpCode); + } else { + echo Response::json([ + 'status' => 'error', + 'code' => $code, + 'details' => $details, + 'message' => I18n::translate('error.unexpected'), + ], $httpCode); + } + + return Handler::QUIT; + }); + + $this->setWhoopsHandler($handler); + $this->whoops()->sendHttpCode(false); + } + + /** + * Enables Whoops with the specified handler + */ + protected function setWhoopsHandler(callable|HandlerInterface $handler): void + { + $whoops = $this->whoops(); + $whoops->clearHandlers(); + $whoops->pushHandler($handler); + $whoops->pushHandler($this->getAdditionalWhoopsHandler()); + $whoops->register(); // will only do something if not already registered + } + + /** + * Whoops callback handler for additional error handling + * (`system.exception` hook and output to error log) + */ + protected function getAdditionalWhoopsHandler(): CallbackHandler + { + return new CallbackHandler(function ($exception, $inspector, $run) { + $isLogged = true; + + // allow hook to modify whether the exception should be logged + $isLogged = $this->apply( + 'system.exception', + compact('exception', 'isLogged'), + 'isLogged' + ); + + if ($isLogged !== false) { + error_log($exception); + } + + return Handler::DONE; + }); + } + + /** + * Clears the Whoops handlers and disables Whoops + */ + protected function unsetWhoopsHandler(): void + { + $whoops = $this->whoops(); + $whoops->clearHandlers(); + $whoops->unregister(); // will only do something if currently registered + } + + /** + * Returns the Whoops error handler instance + */ + protected function whoops(): Whoops + { + return $this->whoops ??= new Whoops(); + } +} diff --git a/public/kirby/src/Cms/AppPlugins.php b/public/kirby/src/Cms/AppPlugins.php new file mode 100644 index 0000000..b5eef6b --- /dev/null +++ b/public/kirby/src/Cms/AppPlugins.php @@ -0,0 +1,963 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait AppPlugins +{ + /** + * A list of all registered plugins + */ + protected static array $plugins = []; + + /** + * The extension registry + */ + protected array $extensions = [ + // load options first to make them available for the rest + 'options' => [], + + // other plugin types + 'api' => [], + 'areas' => [], + 'assetMethods' => [], + 'authChallenges' => [], + 'blockMethods' => [], + 'blockModels' => [], + 'blocksMethods' => [], + 'blueprints' => [], + 'cacheTypes' => [], + 'collections' => [], + 'commands' => [], + 'components' => [], + 'controllers' => [], + 'collectionFilters' => [], + 'collectionMethods' => [], + 'fieldMethods' => [], + 'fileMethods' => [], + 'filePreviews' => [], + 'fileTypes' => [], + 'filesMethods' => [], + 'fields' => [], + 'hooks' => [], + 'layoutMethods' => [], + 'layoutColumnMethods' => [], + 'layoutsMethods' => [], + 'pages' => [], + 'pageMethods' => [], + 'pagesMethods' => [], + 'pageModels' => [], + 'permissions' => [], + 'routes' => [], + 'sections' => [], + 'siteMethods' => [], + 'snippets' => [], + 'structureMethods' => [], + 'structureObjectMethods' => [], + 'tags' => [], + 'templates' => [], + 'thirdParty' => [], + 'translations' => [], + 'userMethods' => [], + 'userModels' => [], + 'usersMethods' => [], + 'validators' => [], + ]; + + /** + * Flag when plugins have been loaded + * to not load them again + */ + protected bool $pluginsAreLoaded = false; + + /** + * Register all given extensions + * + * @param \Kirby\Plugin\Plugin|null $plugin The plugin which defined those extensions + */ + public function extend( + array $extensions, + Plugin|null $plugin = null + ): array { + foreach ($this->extensions as $type => $registered) { + if (isset($extensions[$type]) === true) { + $this->{'extend' . $type}($extensions[$type], $plugin); + } + } + + return $this->extensions; + } + + /** + * Registers API extensions + */ + protected function extendApi(array|bool $api): array + { + if (is_array($api) === true) { + if (($api['routes'] ?? []) instanceof Closure) { + $api['routes'] = $api['routes']($this); + } + + return $this->extensions['api'] = A::merge( + $this->extensions['api'], + $api, + A::MERGE_APPEND + ); + } + + return $this->extensions['api']; + } + + /** + * Registers additional custom Panel areas + */ + protected function extendAreas(array $areas): array + { + foreach ($areas as $id => $area) { + $this->extensions['areas'][$id] ??= []; + $this->extensions['areas'][$id][] = $area; + } + + return $this->extensions['areas']; + } + + /** + * Registers additional asset methods + */ + protected function extendAssetMethods(array $methods): array + { + return $this->extensions['assetMethods'] = Asset::$methods = [ + ...Asset::$methods, + ...$methods + ]; + } + + /** + * Registers additional authentication challenges + */ + protected function extendAuthChallenges(array $challenges): array + { + return $this->extensions['authChallenges'] = Auth::$challenges = [ + ...Auth::$challenges, + ...$challenges + ]; + } + + /** + * Registers additional block methods + */ + protected function extendBlockMethods(array $methods): array + { + return $this->extensions['blockMethods'] = Block::$methods = [ + ...Block::$methods, + ...$methods + ]; + } + + /** + * Registers additional block models + */ + protected function extendBlockModels(array $models): array + { + return $this->extensions['blockModels'] = Block::extendModels($models); + } + + /** + * Registers additional blocks methods + */ + protected function extendBlocksMethods(array $methods): array + { + return $this->extensions['blockMethods'] = Blocks::$methods = [ + ...Blocks::$methods, + ...$methods + ]; + } + + /** + * Registers additional blueprints + */ + protected function extendBlueprints(array $blueprints): array + { + return $this->extensions['blueprints'] = [ + ...$this->extensions['blueprints'], + ...$blueprints + ]; + } + + /** + * Registers additional cache types + */ + protected function extendCacheTypes(array $cacheTypes): array + { + return $this->extensions['cacheTypes'] = [ + ...$this->extensions['cacheTypes'], + ...$cacheTypes + ]; + } + + /** + * Registers additional CLI commands + */ + protected function extendCommands(array $commands): array + { + return $this->extensions['commands'] = [ + ...$this->extensions['commands'], + ...$commands + ]; + } + + /** + * Registers additional collection filters + */ + protected function extendCollectionFilters(array $filters): array + { + return $this->extensions['collectionFilters'] = ToolkitCollection::$filters = [ + ...ToolkitCollection::$filters, + ...$filters + ]; + } + + /** + * Registers additional collection methods + */ + protected function extendCollectionMethods(array $methods): array + { + return $this->extensions['collectionMethods'] = Collection::$methods = [ + ...Collection::$methods, + ...$methods + ]; + } + + /** + * Registers additional collections + */ + protected function extendCollections(array $collections): array + { + return $this->extensions['collections'] = [ + ...$this->extensions['collections'], + ...$collections + ]; + } + + /** + * Registers core components + */ + protected function extendComponents(array $components): array + { + return $this->extensions['components'] = [ + ...$this->extensions['components'], + ...$components + ]; + } + + /** + * Registers additional controllers + */ + protected function extendControllers(array $controllers): array + { + return $this->extensions['controllers'] = [ + ...$this->extensions['controllers'], + ...$controllers + ]; + } + + /** + * Registers additional file methods + */ + protected function extendFileMethods(array $methods): array + { + 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'], + ]; + } + + /** + * Registers additional custom file types and mimes + */ + protected function extendFileTypes(array $fileTypes): array + { + // normalize array + foreach ($fileTypes as $ext => $file) { + $extension = $file['extension'] ?? $ext; + $type = $file['type'] ?? null; + $mime = $file['mime'] ?? null; + $resizable = $file['resizable'] ?? false; + $viewable = $file['viewable'] ?? false; + + if (is_string($type) === true) { + if (isset(F::$types[$type]) === false) { + F::$types[$type] = []; + } + + 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) { + Mime::$types[$extension] = array_unique([ + ...(array)Mime::$types[$extension], + ...(array)$mime + ]); + } else { + Mime::$types[$extension] = $mime; + } + } + + if ( + $resizable === true && + in_array($extension, Image::$resizableTypes, true) === false + ) { + Image::$resizableTypes[] = $extension; + } + + if ( + $viewable === true && + in_array($extension, Image::$viewableTypes, true) === false + ) { + Image::$viewableTypes[] = $extension; + } + } + + return $this->extensions['fileTypes'] = [ + 'type' => F::$types, + 'mime' => Mime::$types, + 'resizable' => Image::$resizableTypes, + 'viewable' => Image::$viewableTypes + ]; + } + + /** + * Registers additional files methods + */ + protected function extendFilesMethods(array $methods): array + { + return $this->extensions['filesMethods'] = Files::$methods = [ + ...Files::$methods, + ...$methods + ]; + } + + /** + * Registers additional field methods + */ + protected function extendFieldMethods(array $methods): array + { + return $this->extensions['fieldMethods'] = Field::$methods = [ + ...Field::$methods, + ...array_change_key_case($methods) + ]; + } + + /** + * Registers Panel fields + */ + protected function extendFields(array $fields): array + { + return $this->extensions['fields'] = FormField::$types = [ + ...FormField::$types, + ...$fields + ]; + } + + /** + * Registers hooks + */ + protected function extendHooks(array $hooks): array + { + foreach ($hooks as $name => $callbacks) { + $this->extensions['hooks'][$name] ??= []; + + if (is_array($callbacks) === false) { + $callbacks = [$callbacks]; + } + + foreach ($callbacks as $callback) { + $this->extensions['hooks'][$name][] = $callback; + } + } + + return $this->extensions['hooks']; + } + + /** + * Registers markdown component + */ + protected function extendMarkdown(Closure $markdown): Closure + { + return $this->extensions['markdown'] = $markdown; + } + + /** + * Registers additional layout methods + */ + protected function extendLayoutMethods(array $methods): array + { + return $this->extensions['layoutMethods'] = Layout::$methods = [ + ...Layout::$methods, + ...$methods + ]; + } + + /** + * Registers additional layout column methods + */ + protected function extendLayoutColumnMethods(array $methods): array + { + return $this->extensions['layoutColumnMethods'] = LayoutColumn::$methods = [ + ...LayoutColumn::$methods, + ...$methods + ]; + } + + /** + * Registers additional layouts methods + */ + protected function extendLayoutsMethods(array $methods): array + { + return $this->extensions['layoutsMethods'] = Layouts::$methods = [ + ...Layouts::$methods, + ...$methods + ]; + } + + /** + * Registers additional options + */ + protected function extendOptions( + array $options, + Plugin|null $plugin = null + ): array { + if ($plugin !== null) { + $options = [$plugin->prefix() => $options]; + } + + return $this->extensions['options'] = $this->options = A::merge( + $options, + $this->options, + A::MERGE_REPLACE + ); + } + + /** + * Registers additional page methods + */ + protected function extendPageMethods(array $methods): array + { + return $this->extensions['pageMethods'] = Page::$methods = [ + ...Page::$methods, + ...$methods + ]; + } + + /** + * Registers additional pages methods + */ + protected function extendPagesMethods(array $methods): array + { + return $this->extensions['pagesMethods'] = Pages::$methods = [ + ...Pages::$methods, + ...$methods + ]; + } + + /** + * Registers additional page models + */ + protected function extendPageModels(array $models): array + { + return $this->extensions['pageModels'] = Page::extendModels($models); + } + + /** + * Registers pages + */ + protected function extendPages(array $pages): array + { + return $this->extensions['pages'] = [ + ...$this->extensions['pages'], + ...$pages + ]; + } + + /** + * Registers additional permissions + */ + protected function extendPermissions( + array $permissions, + Plugin|null $plugin = null + ): array { + if ($plugin !== null) { + $permissions = [$plugin->prefix() => $permissions]; + } + + return $this->extensions['permissions'] = Permissions::$extendedActions = [ + ...Permissions::$extendedActions, + ...$permissions + ]; + } + + /** + * Registers additional routes + */ + protected function extendRoutes(array|Closure $routes): array + { + if ($routes instanceof Closure) { + $routes = $routes($this); + } + + return $this->extensions['routes'] = [ + ...$this->extensions['routes'], + ...$routes + ]; + } + + /** + * Registers Panel sections + */ + protected function extendSections(array $sections): array + { + return $this->extensions['sections'] = Section::$types = [ + ...Section::$types, + ...$sections + ]; + } + + /** + * Registers additional site methods + */ + protected function extendSiteMethods(array $methods): array + { + return $this->extensions['siteMethods'] = Site::$methods = [ + ...Site::$methods, + ...$methods + ]; + } + + /** + * Registers SmartyPants component + */ + protected function extendSmartypants(Closure $smartypants): Closure + { + return $this->extensions['smartypants'] = $smartypants; + } + + /** + * Registers additional snippets + */ + protected function extendSnippets(array $snippets): array + { + return $this->extensions['snippets'] = [ + ...$this->extensions['snippets'], + ...$snippets + ]; + } + + /** + * Registers additional structure methods + */ + protected function extendStructureMethods(array $methods): array + { + return $this->extensions['structureMethods'] = Structure::$methods = [ + ...Structure::$methods, + ...$methods + ]; + } + + /** + * Registers additional structure object methods + */ + protected function extendStructureObjectMethods(array $methods): array + { + return $this->extensions['structureObjectMethods'] = StructureObject::$methods = [ + ...StructureObject::$methods, + ...$methods + ]; + } + + /** + * Registers additional KirbyTags + */ + protected function extendTags(array $tags): array + { + return $this->extensions['tags'] = KirbyTag::$types = [ + ...KirbyTag::$types, + ...array_change_key_case($tags) + ]; + } + + /** + * Registers additional templates + */ + protected function extendTemplates(array $templates): array + { + return $this->extensions['templates'] = [ + ...$this->extensions['templates'], + ...$templates + ]; + } + + /** + * Registers translations + */ + protected function extendTranslations(array $translations): array + { + return $this->extensions['translations'] = array_replace_recursive( + $this->extensions['translations'], + $translations + ); + } + + /** + * Add third party extensions to the registry + * so they can be used as plugins for plugins + * for example. + */ + protected function extendThirdParty(array $extensions): array + { + return $this->extensions['thirdParty'] = array_replace_recursive( + $this->extensions['thirdParty'], + $extensions + ); + } + + /** + * Registers additional user methods + */ + protected function extendUserMethods(array $methods): array + { + return $this->extensions['userMethods'] = User::$methods = [ + ...User::$methods, + ...$methods + ]; + } + + /** + * Registers additional user models + */ + protected function extendUserModels(array $models): array + { + return $this->extensions['userModels'] = User::extendModels($models); + } + + /** + * Registers additional users methods + */ + protected function extendUsersMethods(array $methods): array + { + return $this->extensions['usersMethods'] = Users::$methods = [ + ...Users::$methods, + ...$methods + ]; + } + + /** + * Registers additional custom validators + */ + protected function extendValidators(array $validators): array + { + return $this->extensions['validators'] = V::$validators = [ + ...V::$validators, + ...$validators + ]; + } + + /** + * Returns a given extension by type and name + * + * @param string $type i.e. `'hooks'` + * @param string $name i.e. `'page.delete:before'` + */ + public function extension( + string $type, + string $name, + mixed $fallback = null + ): mixed { + return $this->extensions($type)[$name] ?? $fallback; + } + + /** + * Returns the extensions registry + */ + public function extensions(string|null $type = null): array + { + if ($type === null) { + return $this->extensions; + } + + return $this->extensions[$type] ?? []; + } + + /** + * Load extensions from site folders. + * This is only used for models for now, but + * could be extended later + */ + protected function extensionsFromFolders(): void + { + $models = []; + + foreach (glob($this->root('models') . '/*.php') as $model) { + $name = F::name($model); + $class = str_replace(['.', '-', '_'], '', $name) . 'Page'; + + // load the model class + F::loadOnce($model, allowOutput: false); + + if (class_exists($class) === true) { + $models[$name] = $class; + } + } + + $this->extendPageModels($models); + } + + /** + * Register extensions that could be located in + * the options array. I.e. hooks and routes can be + * setup from the config. + */ + protected function extensionsFromOptions(): void + { + // register routes and hooks from options + $this->extend([ + 'api' => $this->options['api'] ?? [], + 'routes' => $this->options['routes'] ?? [], + 'hooks' => $this->options['hooks'] ?? [] + ]); + } + + /** + * Apply all plugin extensions + */ + protected function extensionsFromPlugins(): void + { + // register all their extensions + foreach ($this->plugins() as $plugin) { + $extends = $plugin->extends(); + + if (empty($extends) === false) { + $this->extend($extends, $plugin); + } + } + } + + /** + * Apply all passed extensions + */ + protected function extensionsFromProps(array $props): void + { + $this->extend($props); + } + + /** + * Apply all default extensions + */ + protected function extensionsFromSystem(): void + { + // Always start with fresh fields and sections + // from the core and add plugins on top of that + FormField::$types = []; + Section::$types = []; + + // mixins + FormField::$mixins = $this->core->fieldMixins(); + Section::$mixins = $this->core->sectionMixins(); + + // aliases + KirbyTag::$aliases = $this->core->kirbyTagAliases(); + Field::$aliases = $this->core->fieldMethodAliases(); + + // blueprint presets + PageBlueprint::$presets = $this->core->blueprintPresets(); + + $this->extendAuthChallenges($this->core->authChallenges()); + $this->extendCacheTypes($this->core->cacheTypes()); + $this->extendComponents($this->core->components()); + $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()); + $this->extendTemplates($this->core->templates()); + } + + /** + * Checks if a native component was extended + * @since 3.7.0 + */ + public function isNativeComponent(string $component): bool + { + return $this->component($component) === $this->nativeComponent($component); + } + + /** + * Returns the native implementation + * of a core component + */ + public function nativeComponent(string $component): Closure|false + { + return $this->core->components()[$component] ?? false; + } + + /** + * Kirby plugin factory and getter + * + * @param array|null $extends If null is passed it will be used as getter. Otherwise as factory. + * @throws \Kirby\Exception\DuplicateException + */ + public static function plugin( + string $name, + array|null $extends = null, + array $info = [], + string|null $root = null, + string|null $version = null, + Closure|string|array|null $license = null, + ): Plugin|null { + if ($extends === null) { + return static::$plugins[$name] ?? null; + } + + $plugin = new Plugin( + 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 + ); + + $name = $plugin->name(); + + if (isset(static::$plugins[$name]) === true) { + throw new DuplicateException( + message: 'The plugin "' . $name . '" has already been registered' + ); + } + + return static::$plugins[$name] = $plugin; + } + + /** + * Loads and returns all plugins in the site/plugins directory + * Loading only happens on the first call. + * + * @param array|null $plugins Can be used to overwrite the plugins registry + */ + public function plugins(array|null $plugins = null): array + { + // overwrite the existing plugins registry + if ($plugins !== null) { + $this->pluginsAreLoaded = true; + return static::$plugins = $plugins; + } + + // don't load plugins twice + if ($this->pluginsAreLoaded === true) { + return static::$plugins; + } + + // load all plugins from site/plugins + $this->pluginsLoader(); + + // mark plugins as loaded to stop doing it twice + $this->pluginsAreLoaded = true; + return static::$plugins; + } + + /** + * Loads all plugins from site/plugins + * + * @return array Array of loaded directories + */ + protected function pluginsLoader(): array + { + $root = $this->root('plugins'); + $loaded = []; + + foreach (Dir::read($root) as $dirname) { + if ( + str_starts_with($dirname, '.') || + str_starts_with($dirname, '_') + ) { + continue; + } + + $dir = $root . '/' . $dirname; + + if (is_dir($dir) !== true) { + continue; + } + + $entry = $dir . '/index.php'; + $script = $dir . '/index.js'; + $styles = $dir . '/index.css'; + + if (is_file($entry) === true) { + F::loadOnce($entry, allowOutput: false); + } elseif (is_file($script) === true || is_file($styles) === true) { + // if no PHP file is present but an index.js or index.css, + // register as anonymous plugin (without actual extensions) + // to be picked up by the Panel\Document class when + // rendering the Panel view + static::plugin( + name: 'plugins/' . $dirname, + extends: [], + root: $dir + ); + } else { + continue; + } + + $loaded[] = $dir; + } + + return $loaded; + } +} diff --git a/public/kirby/src/Cms/AppTranslations.php b/public/kirby/src/Cms/AppTranslations.php new file mode 100644 index 0000000..c7bdd6a --- /dev/null +++ b/public/kirby/src/Cms/AppTranslations.php @@ -0,0 +1,185 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait AppTranslations +{ + protected Translations|null $translations = null; + + /** + * Setup internationalization + */ + protected function i18n(): void + { + I18n::$load = function ($locale): array { + $data = $this->translation($locale)?->data() ?? []; + + // inject translations from the current language + if ( + $this->multilang() === true && + $language = $this->languages()->find($locale) + ) { + $data = [...$data, ...$language->translations()]; + } + + + return $data; + }; + + // the actual locale is set using $app->setCurrentTranslation() + I18n::$locale = function (): string { + if ($this->multilang() === true) { + return $this->defaultLanguage()->code(); + } + + return 'en'; + }; + + I18n::$fallback = function (): array { + if ($this->multilang() === true) { + // first try to fall back to the configured default language + $defaultCode = $this->defaultLanguage()->code(); + $fallback = [$defaultCode]; + + // if the default language is specified with a country code + // (e.g. `en-us`), also try with just the language code + if (preg_match('/^([a-z]{2})-[a-z]+$/i', $defaultCode, $matches) === 1) { + $fallback[] = $matches[1]; + } + + // fall back to the complete English translation + // as a last resort + $fallback[] = 'en'; + + return $fallback; + } + + return ['en']; + }; + + I18n::$translations = []; + + // add slug rules based on config option + if ($slugs = $this->option('slugs')) { + // two ways that the option can be defined: + // "slugs" => "de" or "slugs" => ["language" => "de"] + if ($slugs = $slugs['language'] ?? $slugs ?? null) { + Str::$language = Language::loadRules($slugs); + } + } + } + + /** + * Returns the language code that will be used + * for the Panel if no user is logged in or if + * no language is configured for the user + */ + public function panelLanguage(): string + { + $translation = $this->request()->get('translation'); + + if ($translation !== null && $this->translations()->find($translation)) { + return $translation; + } + + if ($this->multilang() === true) { + $defaultCode = $this->defaultLanguage()->code(); + + // extract the language code from a language that + // contains the country code (e.g. `en-us`) + if (preg_match('/^([a-z]{2})-[a-z]+$/i', $defaultCode, $matches) === 1) { + $defaultCode = $matches[1]; + } + } else { + $defaultCode = 'en'; + } + + return $this->option('panel.language', $defaultCode); + } + + /** + * Set the current translation + */ + public function setCurrentTranslation(string|null $translationCode = null): void + { + I18n::$locale = $translationCode ?? 'en'; + } + + /** + * Load a specific translation by locale + * + * @param string|null $locale Locale name or `null` for the current locale + */ + public function translation(string|null $locale = null): Translation + { + $locale ??= I18n::locale(); + $locale = basename($locale); + + // prefer loading them from the translations collection + if ($this->translations instanceof Translations) { + if ($translation = $this->translations()->find($locale)) { + return $translation; + } + } + + // get injected translation data from plugins etc. + $inject = $this->extensions['translations'][$locale] ?? []; + + // inject current language translations + if ($language = $this->language($locale)) { + $inject = [...$inject, ...$language->translations()]; + } + + // load from disk instead + return Translation::load( + $locale, + $this->root('i18n:translations') . '/' . $locale . '.json', + $inject + ); + } + + /** + * Returns all available translations + */ + public function translations(): Translations + { + if ($this->translations instanceof Translations) { + return $this->translations; + } + + $translations = $this->extensions['translations'] ?? []; + + // injects languages translations + if ($languages = $this->languages()) { + foreach ($languages as $language) { + $languageCode = $language->code(); + $languageTranslations = $language->translations(); + + // merges language translations with extensions translations + if (empty($languageTranslations) === false) { + $translations[$languageCode] = [ + ...$translations[$languageCode] ?? [], + ...$languageTranslations + ]; + } + } + } + + 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 new file mode 100644 index 0000000..a82af8c --- /dev/null +++ b/public/kirby/src/Cms/AppUsers.php @@ -0,0 +1,157 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait AppUsers +{ + protected Auth|null $auth = null; + protected User|string|null $user = null; + protected Users|null $users = null; + + /** + * Returns the Authentication layer class + */ + public function auth(): Auth + { + return $this->auth ??= new Auth($this); + } + + /** + * Become any existing user or disable the current user + * + * @param string|null $who User ID or email address, + * `null` to use the actual user again, + * `'kirby'` for a virtual admin user or + * `'nobody'` to disable the actual user + * @param Closure|null $callback Optional action function that will be run with + * the permissions of the impersonated user; the + * impersonation will be reset afterwards + * @return mixed If called without callback: User that was impersonated; + * if called with callback: Return value from the callback + * @throws \Throwable + */ + public function impersonate( + string|null $who = null, + Closure|null $callback = null + ): mixed { + $auth = $this->auth(); + + $userBefore = $auth->currentUserFromImpersonation(); + $userAfter = $auth->impersonate($who); + + if ($callback === null) { + return $userAfter; + } + + try { + return $callback($userAfter); + } catch (Throwable $e) { + throw $e; + } finally { + // ensure that the impersonation is *always* reset + // to the original value, even if an error occurred + $auth->impersonate($userBefore?->id()); + } + } + + /** + * 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 + * + * @return $this + */ + protected function setUser(User|string|null $user = null): static + { + $this->user = $user; + return $this; + } + + /** + * Create your own set of app users + * + * @return $this + */ + protected function setUsers(array|null $users = null): static + { + if ($users !== null) { + $this->users = Users::factory($users); + } + + return $this; + } + + /** + * Returns a specific user by id + * or the current user if no id is given + * + * @param bool $allowImpersonation If set to false, only the actually + * logged in user will be returned + * (when `$id` is passed as `null`) + */ + public function user( + string|null $id = null, + bool $allowImpersonation = true + ): User|null { + if ($id !== null) { + return $this->users()->find($id); + } + + if ($allowImpersonation === true && is_string($this->user) === true) { + return $this->auth()->impersonate($this->user); + } + + try { + return $this->auth()->user(null, $allowImpersonation); + } catch (Throwable) { + return null; + } + } + + /** + * Returns all users + */ + public function users(): Users + { + return $this->users ??= Users::load( + $this->root('accounts'), + ); + } +} diff --git a/public/kirby/src/Cms/Auth.php b/public/kirby/src/Cms/Auth.php new file mode 100644 index 0000000..66e73bc --- /dev/null +++ b/public/kirby/src/Cms/Auth.php @@ -0,0 +1,954 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Auth +{ + /** + * Available auth challenge classes + * from the core and plugins + */ + public static array $challenges = []; + + /** + * Currently impersonated user + */ + protected User|null $impersonate = null; + + /** + * Cache of the auth status object + */ + protected Status|null $status = null; + + /** + * Instance of the currently logged in user or + * `false` if the user was not yet determined + */ + protected User|false|null $user = false; + + /** + * Exception that was thrown while + * determining the current user + */ + protected Throwable|null $userException = null; + + /** + * @codeCoverageIgnore + */ + public function __construct( + protected App $kirby + ) { + } + + /** + * Creates an authentication challenge + * (one-time auth code) + * @since 3.5.0 + * + * @param bool $long If `true`, a long session will be created + * @param 'login'|'password-reset'|'2fa' $mode Purpose of the code + * + * @throws \Kirby\Exception\LogicException If there is no suitable authentication challenge (only in debug mode) + * @throws \Kirby\Exception\NotFoundException If the user does not exist (only in debug mode) + * @throws \Kirby\Exception\PermissionException If the rate limit is exceeded + */ + public function createChallenge( + string $email, + bool $long = false, + string $mode = 'login' + ): Status { + $email = Idn::decodeEmail($email); + + $session = $this->kirby->session([ + 'createMode' => 'cookie', + 'long' => $long === true + ]); + + $timeout = $this->kirby->option('auth.challenge.timeout', 10 * 60); + + // catch every exception to hide them from attackers + // unless auth debugging is enabled + try { + $this->checkRateLimit($email); + + // rate-limit the number of challenges for DoS/DDoS protection + $this->track($email, false); + + // try to find the provided user + $user = $this->kirby->users()->find($email); + if ($user === null) { + $this->kirby->trigger('user.login:failed', compact('email')); + + throw new NotFoundException( + key: 'user.notFound', + data: ['name' => $email] + ); + } + + // try to find an enabled challenge that is available for that user + $challenge = null; + foreach ($this->enabledChallenges() as $name) { + $class = static::$challenges[$name] ?? null; + if ( + $class && + class_exists($class) === true && + is_subclass_of($class, Challenge::class) === true && + $class::isAvailable($user, $mode) === true + ) { + $challenge = $name; + $code = $class::create($user, compact('mode', 'timeout')); + + $session->set('kirby.challenge.type', $challenge); + + if ($code !== null) { + $session->set( + 'kirby.challenge.code', + password_hash($code, PASSWORD_DEFAULT) + ); + } + + break; + } + } + + // if no suitable challenge was found, `$challenge === null` at this point + if ($challenge === null) { + 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, 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 + // to make automated attacks harder and to + // avoid leaking whether the user exists + usleep(random_int(50000, 300000)); + + // clear the status cache + $this->status = null; + + return $this->status($session, false); + } + + /** + * Returns the csrf token if it exists and if it is valid + */ + public function csrf(): string|false + { + // get the csrf from the header + $fromHeader = $this->kirby->request()->csrf(); + + // check for a predefined csrf or use the one from session + $fromSession = $this->csrfFromSession(); + + // compare both tokens + if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) { + return false; + } + + return $fromSession; + } + + /** + * Returns either predefined csrf or the one from session + * @since 3.6.0 + */ + public function csrfFromSession(): string + { + $isDev = $this->kirby->option('panel.dev', false) !== false; + $fallback = $isDev ? 'dev' : $this->kirby->csrf(); + return $this->kirby->option('api.csrf', $fallback); + } + + /** + * Returns the logged in user by checking + * for a basic authentication header with + * valid credentials + * + * @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' + ); + } + + // if logging in with password is disabled, basic auth cannot be possible either + $loginMethods = $this->kirby->system()->loginMethods(); + if (isset($loginMethods['password']) !== true) { + throw new PermissionException( + 'Login with password is not enabled' + ); + } + + // if any login method requires 2FA, basic auth without 2FA would be a weakness + foreach ($loginMethods as $method) { + if (isset($method['2fa']) === true && $method['2fa'] === true) { + throw new PermissionException( + 'Basic authentication cannot be used with 2FA' + ); + } + } + + $request = $this->kirby->request(); + $auth ??= $request->auth(); + + if (!$auth || $auth->type() !== 'basic') { + 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' + ); + } + + return $this->validatePassword($auth->username(), $auth->password()); + } + + /** + * Returns the currently impersonated user + */ + public function currentUserFromImpersonation(): User|null + { + return $this->impersonate; + } + + /** + * Returns the logged in user by checking + * the current session and finding a valid + * valid user id in there + */ + public function currentUserFromSession( + Session|array|null $session = null + ): User|null { + $session = $this->session($session); + + $id = $session->data()->get('kirby.userId'); + + // if no user is logged in, return immediately + if (is_string($id) !== true) { + return null; + } + + // a user is logged in, ensure it exists + $user = $this->kirby->users()->find($id); + if ($user === null) { + return null; + } + + 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 + // or when the user didn't have a password set + $user->logout(); + return null; + } + + // invalidate the session if the password + // changed since the login + if ($loginTimestamp < $passwordTimestamp) { + $user->logout(); + return null; + } + } + + // in case the session needs to be updated, do it now + // for better performance + $session->commit(); + return $user; + } + + /** + * Returns the list of enabled challenges in the + * configured order + * @since 3.5.1 + */ + public function enabledChallenges(): array + { + return A::wrap( + $this->kirby->option('auth.challenges', ['totp', 'email']) + ); + } + + /** + * Become any existing user or disable the current user + * + * @param string|null $who User ID or email address, + * `null` to use the actual user again, + * `'kirby'` for a virtual admin user or + * `'nobody'` to disable the actual user + * @throws \Kirby\Exception\NotFoundException if the given user cannot be found + */ + public function impersonate(string|null $who = null): User|null + { + // clear the status cache + $this->status = null; + + return $this->impersonate = match ($who) { + null => null, + 'kirby' => new User([ + 'email' => 'kirby@getkirby.com', + 'id' => 'kirby', + 'role' => 'admin', + ]), + 'nobody' => new User([ + 'email' => 'nobody@getkirby.com', + 'id' => 'nobody', + 'role' => 'nobody', + ]), + default => $this->kirby->users()->find($who) ?? throw new NotFoundException(message: 'The user "' . $who . '" cannot be found'), + }; + } + + /** + * Returns the hashed ip of the visitor + * which is used to track invalid logins + */ + public function ipHash(): string + { + $hash = hash('sha256', $this->kirby->visitor()->ip()); + + // only use the first 50 chars to ensure privacy + return substr($hash, 0, 50); + } + + /** + * Check if logins are blocked for the current ip or email + */ + public function isBlocked(string $email): bool + { + $ip = $this->ipHash(); + $log = $this->log(); + $trials = $this->kirby->option('auth.trials', 10); + + if ($entry = ($log['by-ip'][$ip] ?? null)) { + if ($entry['trials'] >= $trials) { + return true; + } + } + + if ($this->kirby->users()->find($email)) { + if ($entry = ($log['by-email'][$email] ?? null)) { + if ($entry['trials'] >= $trials) { + return true; + } + } + } + + return false; + } + + /** + * Login a user by email and password + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the email was invalid + * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) + */ + public function login( + string $email, + #[SensitiveParameter] + string $password, + bool $long = false + ): User { + // session options + $options = [ + 'createMode' => 'cookie', + 'long' => $long === true + ]; + + // validate the user and log in to the session + $user = $this->validatePassword($email, $password); + $user->loginPasswordless($options); + + // clear the status cache + $this->status = null; + + return $user; + } + + /** + * Login a user by email, password and auth challenge + * @since 3.5.0 + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the email was invalid + * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) + */ + public function login2fa( + string $email, + #[SensitiveParameter] + string $password, + bool $long = false + ): Status { + $this->validatePassword($email, $password); + return $this->createChallenge($email, $long, '2fa'); + } + + /** + * Sets a user object as the current user in the cache + * @internal + */ + public function setUser(User $user): void + { + // stop impersonating + $this->impersonate = null; + $this->user = $user; + + // clear the status cache + $this->status = null; + } + + /** + * Returns the authentication status object + * @since 3.5.1 + * + * @param bool $allowImpersonation If set to false, only the actually + * logged in user will be returned + */ + public function status( + Session|array|null $session = null, + bool $allowImpersonation = true + ): Status { + // try to return from cache + if ( + $this->status && + $session === null && + $allowImpersonation === true + ) { + return $this->status; + } + + $sessionObj = $this->session($session); + + $props = ['kirby' => $this->kirby]; + if ($user = $this->user($sessionObj, $allowImpersonation)) { + // a user is currently logged in + $props['email'] = $user->email(); + $props['status'] = match (true) { + $allowImpersonation === true && + $this->impersonate !== null => 'impersonated', + default => 'active' + }; + + } 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 { + // no active authentication + $props['status'] = 'inactive'; + } + + $status = new Status($props); + + // only cache the default object + if ($session === null && $allowImpersonation === true) { + $this->status = $status; + } + + return $status; + } + + /** + * Ensures that the rate limit was not exceeded + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded + */ + protected function checkRateLimit(string $email): void + { + // check for blocked ips + if ($this->isBlocked($email) === true) { + $this->kirby->trigger('user.login:failed', compact('email')); + + throw new PermissionException( + details: ['reason' => 'rate-limited'], + fallback: 'Rate limit exceeded' + ); + } + } + + /** + * Validates the user credentials and returns the user object on success; + * otherwise logs the failed attempt + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the email was invalid + * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`) + */ + public function validatePassword( + string $email, + #[SensitiveParameter] + string $password + ): User { + $email = Idn::decodeEmail($email); + + try { + $this->checkRateLimit($email); + + // validate the user and its password + if ($user = $this->kirby->users()->find($email)) { + if ($user->validatePassword($password) === true) { + return $user; + } + } + + throw new NotFoundException( + key: 'user.notFound', + data: ['name' => $email] + ); + } catch (Throwable $e) { + $details = $e instanceof Exception ? $e->getDetails() : []; + + // log invalid login trial unless the rate limit is already active + if (($details['reason'] ?? null) !== 'rate-limited') { + try { + $this->track($email); + } catch (Throwable) { + // $e is overwritten with the exception + // from the track method if there's one + } + } + + // sleep for a random amount of milliseconds + // to make automated attacks harder + usleep(random_int(10000, 2000000)); + + // 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')); + } + } + + /** + * Returns the absolute path to the logins log + */ + public function logfile(): string + { + return $this->kirby->root('accounts') . '/.logins'; + } + + /** + * Read all tracked logins + */ + public function log(): array + { + try { + $log = Data::read($this->logfile(), 'json'); + $read = true; + } catch (Throwable) { + $log = []; + $read = false; + } + + // ensure that the category arrays are defined + $log['by-ip'] ??= []; + $log['by-email'] ??= []; + + // remove all elements on the top level with different keys (old structure) + $log = array_intersect_key($log, array_flip(['by-ip', 'by-email'])); + + // remove entries that are no longer needed + $originalLog = $log; + $time = time() - $this->kirby->option('auth.timeout', 3600); + + foreach ($log as $category => $entries) { + $log[$category] = array_filter( + $entries, + fn ($entry) => $entry['time'] > $time + ); + } + + // write new log to the file system if it changed + if ($read === false || $log !== $originalLog) { + if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) { + F::remove($this->logfile()); + } else { + Data::write($this->logfile(), $log, 'json'); + } + } + + return $log; + } + + /** + * Logout the current user + */ + public function logout(): void + { + // stop impersonating; + // ensures that we log out the actually logged in user + $this->impersonate = null; + + // logout the current user if it exists + $this->user()?->logout(); + + // clear the pending challenge + $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'); + + // clear the status cache + $this->status = null; + } + + /** + * Clears the cached user data after logout + */ + public function flush(): void + { + $this->impersonate = null; + $this->status = null; + $this->user = null; + } + + /** + * Tracks a login + * + * @param bool $triggerHook If `false`, no user.login:failed hook is triggered + */ + public function track( + string|null $email, + bool $triggerHook = true + ): bool { + if ($triggerHook === true) { + $this->kirby->trigger('user.login:failed', compact('email')); + } + + $ip = $this->ipHash(); + $log = $this->log(); + $time = time(); + + if (isset($log['by-ip'][$ip]) === true) { + $log['by-ip'][$ip] = [ + 'time' => $time, + 'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1 + ]; + } else { + $log['by-ip'][$ip] = [ + 'time' => $time, + 'trials' => 1 + ]; + } + + if ($email !== null && $this->kirby->users()->find($email)) { + if (isset($log['by-email'][$email]) === true) { + $log['by-email'][$email] = [ + 'time' => $time, + 'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1 + ]; + } else { + $log['by-email'][$email] = [ + 'time' => $time, + 'trials' => 1 + ]; + } + } + + return Data::write($this->logfile(), $log, 'json'); + } + + /** + * Returns the current authentication type + * + * @param bool $allowImpersonation If set to false, 'impersonate' won't + * be returned as authentication type + * even if an impersonation is active + */ + public function type(bool $allowImpersonation = true): string + { + $basicAuth = $this->kirby->option('api.basicAuth', false); + $request = $this->kirby->request(); + + if ( + $basicAuth === true && + + // only get the auth object if the option is enabled + // to avoid triggering `$responder->usesAuth()` if + // the option is disabled + $request->auth() && + $request->auth()->type() === 'basic' + ) { + return 'basic'; + } + + if ($allowImpersonation === true && $this->impersonate !== null) { + return 'impersonate'; + } + + return 'session'; + } + + /** + * Validates the currently logged in user + * + * @param bool $allowImpersonation If set to false, only the actually + * logged in user will be returned + * + * @throws \Throwable If an authentication error occurred + */ + public function user( + Session|array|null $session = null, + bool $allowImpersonation = true + ): User|null { + if ($allowImpersonation === true && $this->impersonate !== null) { + return $this->impersonate; + } + + // return from cache + if ($this->user === null) { + // throw the same Exception again if one was captured before + if ($this->userException !== null) { + throw $this->userException; + } + + return null; + } + + if ($this->user !== false) { + return $this->user; + } + + try { + if ($this->type() === 'basic') { + return $this->user = $this->currentUserFromBasicAuth(); + } + + return $this->user = $this->currentUserFromSession($session); + } catch (Throwable $e) { + $this->user = null; + + // capture the Exception for future calls + $this->userException = $e; + + throw $e; + } + } + + /** + * Verifies an authentication code that was + * requested with the `createChallenge()` method; + * if successful, the user is automatically logged in + * @since 3.5.0 + * + * @param string $code User-provided auth code to verify + * @return \Kirby\Cms\User User object of the logged-in user + * + * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded, the challenge timed out, the code + * is incorrect or if any other error occurred with debug mode off + * @throws \Kirby\Exception\NotFoundException If the user from the challenge doesn't exist + * @throws \Kirby\Exception\InvalidArgumentException If no authentication challenge is active + * @throws \Kirby\Exception\LogicException If the authentication challenge is invalid + */ + public function verifyChallenge( + #[SensitiveParameter] + string $code + ): User { + 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) + $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' + ); + } + + // check if we have an active challenge + $email = $session->get('kirby.challenge.email'); + $challenge = $session->get('kirby.challenge.type'); + if (is_string($email) !== true || is_string($challenge) !== true) { + // if the challenge timed out on the previous request, the + // challenge data was already deleted from the session, so we can + // set `challengeDestroyed` to `true` in this response as well; + // however we must only base this on the email, not the type + // (otherwise "faked" challenges would be leaked) + $challengeDestroyed = is_string($email) !== true; + + 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] + ); + } + + // rate-limiting + $this->checkRateLimit($email); + + if ( + isset(static::$challenges[$challenge]) === true && + class_exists(static::$challenges[$challenge]) === true && + is_subclass_of(static::$challenges[$challenge], Challenge::class) === true + ) { + $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 LogicException( + 'Invalid authentication challenge: ' . $challenge + ); + } catch (Throwable $e) { + $details = $e instanceof Exception ? $e->getDetails() : []; + + if ( + empty($email) === false && + ($details['reason'] ?? null) !== 'rate-limited' + ) { + $this->track($email); + } + + // sleep for a random amount of milliseconds + // to make automated attacks harder and to + // avoid leaking whether the user exists + usleep(random_int(10000, 2000000)); + + // specifically copy over the marker for a destroyed challenge + // 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' + ); + + // keep throwing the original error in debug mode, + // otherwise hide it to avoid leaking security-relevant information + $this->fail($e, $fallback); + } + } + + /** + * Throws an exception only in debug mode, otherwise falls back + * to a public error without sensitive information + * + * @throws \Throwable Either the passed `$exception` or the `$fallback` + * (no exception if debugging is disabled and no fallback was passed) + */ + protected function fail( + Throwable $exception, + Throwable|null $fallback = null + ): void { + $debug = $this->kirby->option('auth.debug', 'log'); + + // throw the original exception only in debug mode + if ($debug === true) { + throw $exception; + } + + // otherwise hide the real error and only print it to the error log + // unless disabled by setting `auth.debug` to `false` + if ($debug === 'log') { + error_log($exception); // @codeCoverageIgnore + } + + // only throw an error in production if requested by the calling method + if ($fallback !== null) { + throw $fallback; + } + } + + /** + * Creates a session object from the passed options + */ + protected function session(Session|array|null $session = null): Session + { + // use passed session options or session object if set + if (is_array($session) === true) { + return $this->kirby->session($session); + } + + // try session in header or cookie + if ($session instanceof Session === false) { + return $this->kirby->session(['detect' => true]); + } + + return $session; + } +} diff --git a/public/kirby/src/Cms/Auth/Challenge.php b/public/kirby/src/Cms/Auth/Challenge.php new file mode 100644 index 0000000..cee81b9 --- /dev/null +++ b/public/kirby/src/Cms/Auth/Challenge.php @@ -0,0 +1,65 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Challenge +{ + /** + * Checks whether the challenge is available + * for the passed user and purpose + * + * @param \Kirby\Cms\User $user User the code will be generated for + * @param 'login'|'password-reset'|'2fa' $mode Purpose of the code + */ + abstract public static function isAvailable(User $user, string $mode): bool; + + /** + * Generates a random one-time auth code and returns that code + * for later verification + * + * @param \Kirby\Cms\User $user User to generate the code for + * @param array $options Details of the challenge request: + * - 'mode': Purpose of the code ('login', 'password-reset' or '2fa') + * - 'timeout': Number of seconds the code will be valid for + * @return string|null The generated and sent code or `null` in case + * there was no code to generate by this algorithm + */ + abstract public static function create(User $user, array $options): string|null; + + /** + * Verifies the provided code against the created one; + * default implementation that checks the code that was + * returned from the `create()` method + * + * @param \Kirby\Cms\User $user User to check the code for + * @param string $code Code to verify + */ + public static function verify( + User $user, + #[SensitiveParameter] + string $code + ): bool { + $hash = $user->kirby()->session()->get('kirby.challenge.code'); + if (is_string($hash) !== true) { + return false; + } + + // normalize the formatting in the user-provided code + $code = str_replace(' ', '', $code); + + return password_verify($code, $hash); + } +} diff --git a/public/kirby/src/Cms/Auth/EmailChallenge.php b/public/kirby/src/Cms/Auth/EmailChallenge.php new file mode 100644 index 0000000..a7b49cf --- /dev/null +++ b/public/kirby/src/Cms/Auth/EmailChallenge.php @@ -0,0 +1,86 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class EmailChallenge extends Challenge +{ + /** + * Checks whether the challenge is available + * for the passed user and purpose + * + * @param \Kirby\Cms\User $user User the code will be generated for + * @param 'login'|'password-reset'|'2fa' $mode Purpose of the code + */ + public static function isAvailable(User $user, string $mode): bool + { + return true; + } + + /** + * Generates a random one-time auth code and returns that code + * for later verification + * + * @param \Kirby\Cms\User $user User to generate the code for + * @param array $options Details of the challenge request: + * - 'mode': Purpose of the code ('login', 'password-reset' or '2fa') + * - 'timeout': Number of seconds the code will be valid for + * @return string The generated and sent code + */ + public static function create(User $user, array $options): string + { + $code = Str::random(6, 'num'); + + // insert a space in the middle for easier readability + $formatted = substr($code, 0, 3) . ' ' . substr($code, 3, 3); + + // use the login templates for 2FA + $mode = 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' => $from, + 'fromName' => $name, + 'to' => $user, + 'subject' => $subject, + 'template' => 'auth/' . $mode, + 'data' => [ + 'user' => $user, + 'site' => $kirby->system()->title(), + 'code' => $formatted, + 'timeout' => round($options['timeout'] / 60) + ] + ]); + + return $code; + } +} diff --git a/public/kirby/src/Cms/Auth/Status.php b/public/kirby/src/Cms/Auth/Status.php new file mode 100644 index 0000000..99ad9e8 --- /dev/null +++ b/public/kirby/src/Cms/Auth/Status.php @@ -0,0 +1,176 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Status implements Stringable +{ + /** + * Type of the active challenge + */ + protected string|null $challenge = null; + + /** + * Challenge type to use as a fallback + * when $challenge is `null` + */ + protected string|null $challengeFallback = null; + + /** + * Email address of the current/pending user + */ + protected string|null $email; + + /** + * Kirby instance for user lookup + */ + protected App $kirby; + + /** + * Purpose of the challenge: + * `login|password-reset|2fa` + */ + protected string|null $mode; + + /** + * Authentication status: + * `active|impersonated|pending|inactive` + */ + protected string $status; + + /** + * Class constructor + */ + public function __construct(array $props) + { + 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->challengeFallback = $props['challengeFallback'] ?? null; + $this->email = $props['email'] ?? null; + $this->mode = $props['mode'] ?? null; + $this->status = $props['status']; + } + + /** + * Returns the authentication status + */ + public function __toString(): string + { + return $this->status(); + } + + /** + * Returns the type of the active challenge + * + * @param bool $automaticFallback If set to `false`, no faked challenge is returned; + * WARNING: never send the resulting `null` value to the + * user to avoid leaking whether the pending user exists + */ + public function challenge(bool $automaticFallback = true): string|null + { + // never return a challenge type if the status doesn't match + if ($this->status() !== 'pending') { + return null; + } + + if ($automaticFallback === false) { + return $this->challenge; + } + + return $this->challenge ?? $this->challengeFallback; + } + + /** + * Creates a new instance while + * merging initial and new properties + */ + public function clone(array $props = []): static + { + return new static(array_replace_recursive([ + 'kirby' => $this->kirby, + 'challenge' => $this->challenge, + 'challengeFallback' => $this->challengeFallback, + 'email' => $this->email, + 'status' => $this->status, + ], $props)); + } + + /** + * Returns the email address of the current/pending user + */ + public function email(): string|null + { + 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 + * + * @return string `active|impersonated|pending|inactive` + */ + public function status(): string + { + return $this->status; + } + + /** + * Returns an array with all public status data + */ + public function toArray(): array + { + return [ + 'challenge' => $this->challenge(), + 'email' => $this->email(), + 'mode' => $this->mode(), + 'status' => $this->status() + ]; + } + + /** + * Returns the currently logged in user + */ + public function user(): User|null + { + // for security, only return the user if they are + // already logged in + if (in_array($this->status(), ['active', 'impersonated'], true) !== true) { + return null; + } + + return $this->kirby->user($this->email()); + } +} diff --git a/public/kirby/src/Cms/Auth/TotpChallenge.php b/public/kirby/src/Cms/Auth/TotpChallenge.php new file mode 100644 index 0000000..67142db --- /dev/null +++ b/public/kirby/src/Cms/Auth/TotpChallenge.php @@ -0,0 +1,65 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class TotpChallenge extends Challenge +{ + /** + * Checks whether the challenge is available + * for the passed user and purpose + * + * @param \Kirby\Cms\User $user User the code will be generated for + * @param 'login'|'password-reset'|'2fa' $mode Purpose of the code + */ + public static function isAvailable(User $user, string $mode): bool + { + // user needs to have a TOTP secret set up + return $user->secret('totp') !== null; + } + + /** + * Generates a random one-time auth code and returns that code + * for later verification + * + * @param \Kirby\Cms\User $user User to generate the code for + * @param array $options Details of the challenge request: + * - 'mode': Purpose of the code ('login', 'password-reset' or '2fa') + * - 'timeout': Number of seconds the code will be valid for + * @todo set return type to `null` once support for PHP 8.1 is dropped + */ + public static function create(User $user, array $options): string|null + { + // the user's app will generate the code, we only verify it + return null; + } + + /** + * Verifies the provided code against the created one + * + * @param \Kirby\Cms\User $user User to check the code for + * @param string $code Code to verify + */ + public static function verify(User $user, string $code): bool + { + // verify if code is current, previous or next TOTP code + $secret = $user->secret('totp'); + $totp = new Totp($secret); + return $totp->verify($code); + } +} diff --git a/public/kirby/src/Cms/Block.php b/public/kirby/src/Cms/Block.php new file mode 100644 index 0000000..e0734d4 --- /dev/null +++ b/public/kirby/src/Cms/Block.php @@ -0,0 +1,223 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Blocks> + */ +class Block extends Item implements Stringable +{ + use HasMethods; + use HasModels; + + public const ITEMS_CLASS = Blocks::class; + + protected Content $content; + protected bool $isHidden; + protected string $type; + + /** + * Proxy for content fields + */ + public function __call(string $method, array $args = []): mixed + { + // block methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $args); + } + + return $this->content()->get($method); + } + + /** + * Creates a new block object + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(array $params) + { + parent::__construct($params); + + // @deprecated import old builder format + // @todo block.converter remove eventually + // @codeCoverageIgnoreStart + $params = BlockConverter::builderBlock($params); + $params = BlockConverter::editorBlock($params); + // @codeCoverageIgnoreEnd + + if (isset($params['type']) === false) { + throw new InvalidArgumentException( + message: 'The block type is missing' + ); + } + + // make sure the content is always defined as array to keep + // at least a bit of backward compatibility with older fields + if (is_array($params['content'] ?? null) === false) { + $params['content'] = []; + } + + $this->isHidden = $params['isHidden'] ?? false; + $this->type = $params['type']; + + // create the content object + $this->content = new Content($params['content'], $this->parent); + } + + /** + * Converts the object to a string + */ + public function __toString(): string + { + return $this->toHtml(); + } + + /** + * Returns the content object + */ + public function content(): Content + { + return $this->content; + } + + /** + * Controller for the block snippet + */ + public function controller(): array + { + return [ + 'block' => $this, + 'content' => $this->content(), + // deprecated block data + 'data' => $this, + 'id' => $this->id(), + 'prev' => $this->prev(), + 'next' => $this->next() + ]; + } + + /** + * Converts the block to HTML and then + * uses the Str::excerpt method to create + * a non-formatted, shortened excerpt from it + */ + public function excerpt(mixed ...$args): string + { + return Str::excerpt($this->toHtml(), ...$args); + } + + /** + * Constructs a block object with registering blocks models + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function factory(array $params): static + { + return static::model($params['type'] ?? 'default', $params); + } + + /** + * Checks if the block is empty + */ + public function isEmpty(): bool + { + return empty($this->content()->toArray()); + } + + /** + * Checks if the block is hidden + * from being rendered in the frontend + */ + public function isHidden(): bool + { + return $this->isHidden; + } + + /** + * Checks if the block is not empty + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Returns the sibling collection that filtered by block status + */ + protected function siblingsCollection(): Blocks + { + return $this->siblings->filter('isHidden', $this->isHidden()); + } + + /** + * Returns the block type + */ + public function type(): string + { + return $this->type; + } + + /** + * The result is being sent to the editor + * via the API in the panel + */ + public function toArray(): array + { + return [ + 'content' => $this->content()->toArray(), + 'id' => $this->id(), + 'isHidden' => $this->isHidden(), + 'type' => $this->type(), + ]; + } + + /** + * Converts the block to html first + * and then places that inside a field + * object. This can be used further + * with all available field methods + */ + public function toField(): Field + { + return new Field($this->parent(), $this->id(), $this->toHtml()); + } + + /** + * Converts the block to HTML + */ + public function toHtml(): string + { + try { + $kirby = $this->parent()->kirby(); + return (string)$kirby->snippet( + 'blocks/' . $this->type(), + $this->controller(), + true + ); + } catch (Throwable $e) { + if ($kirby->option('debug') === true) { + return '

Block error: "' . $e->getMessage() . '" in block type: "' . $this->type() . '"

'; + } + + return ''; + } + } +} diff --git a/public/kirby/src/Cms/BlockConverter.php b/public/kirby/src/Cms/BlockConverter.php new file mode 100644 index 0000000..f8569b9 --- /dev/null +++ b/public/kirby/src/Cms/BlockConverter.php @@ -0,0 +1,285 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier GmbH + * @license https://getkirby.com/license + */ +class BlockConverter +{ + public static function builderBlock(array $params): array + { + if (isset($params['_key']) === false) { + return $params; + } + + $ignore = array_flip(['field', 'options', 'parent', 'siblings', 'params', '_key', '_uid']); + $params['content'] = array_diff_key($params, $ignore); + $params['type'] = $params['_key']; + + unset($params['_uid']); + + return $params; + } + + public static function editorBlock(array $params): array + { + if (static::isEditorBlock($params) === false) { + return $params; + } + + $method = 'editor' . $params['type']; + + if (method_exists(static::class, $method) === true) { + $params = static::$method($params); + } else { + $params = static::editorCustom($params); + } + + return $params; + } + + public static function editorBlocks(array $blocks = []): array + { + if ($blocks === []) { + return $blocks; + } + + if (static::isEditorBlock($blocks[0]) === false) { + return $blocks; + } + + $list = []; + $listStart = null; + + foreach ($blocks as $index => $block) { + if (in_array($block['type'], ['ul', 'ol'], true) === true) { + $prev = $blocks[$index - 1] ?? null; + $next = $blocks[$index + 1] ?? null; + + // new list starts here + if (!$prev || $prev['type'] !== $block['type']) { + $listStart = $index; + } + + // add the block to the list + $list[] = $block; + + // list ends here + if (!$next || $next['type'] !== $block['type']) { + $blocks[$listStart] = [ + 'content' => [ + 'text' => + '<' . $block['type'] . '>' . + implode(array_map( + fn ($item) => '
  • ' . $item['content'] . '
  • ', + $list + )) . + '', + ], + 'type' => 'list' + ]; + + $start = $listStart + 1; + $end = $listStart + count($list); + + for ($x = $start; $x <= $end; $x++) { + $blocks[$x] = false; + } + + $listStart = null; + $list = []; + } + } else { + $blocks[$index] = static::editorBlock($block); + } + } + + return array_filter($blocks); + } + + public static function editorBlockquote(array $params): array + { + return [ + 'content' => [ + 'text' => $params['content'] + ], + 'type' => 'quote' + ]; + } + + public static function editorCode(array $params): array + { + return [ + 'content' => [ + 'language' => $params['attrs']['language'] ?? null, + 'code' => $params['content'] + ], + 'type' => 'code' + ]; + } + + public static function editorCustom(array $params): array + { + return [ + 'content' => [ + ...$params['attrs'] ?? [], + 'body' => $params['content'] ?? null + ], + 'type' => $params['type'] ?? 'unknown' + ]; + } + + public static function editorH1(array $params): array + { + return static::editorHeading($params, 'h1'); + } + + public static function editorH2(array $params): array + { + return static::editorHeading($params, 'h2'); + } + + public static function editorH3(array $params): array + { + return static::editorHeading($params, 'h3'); + } + + public static function editorH4(array $params): array + { + return static::editorHeading($params, 'h4'); + } + + public static function editorH5(array $params): array + { + return static::editorHeading($params, 'h5'); + } + + public static function editorH6(array $params): array + { + return static::editorHeading($params, 'h6'); + } + + public static function editorHr(array $params): array + { + return [ + 'content' => [], + 'type' => 'line' + ]; + } + + public static function editorHeading(array $params, string $level = 'h1'): array + { + return [ + 'content' => [ + 'level' => $level, + 'text' => $params['content'] + ], + 'type' => 'heading' + ]; + } + + public static function editorImage(array $params): array + { + // internal image + if (isset($params['attrs']['id']) === true) { + return [ + 'content' => [ + 'alt' => $params['attrs']['alt'] ?? null, + 'caption' => $params['attrs']['caption'] ?? null, + 'image' => $params['attrs']['id'] ?? $params['attrs']['src'] ?? null, + 'location' => 'kirby', + 'ratio' => $params['attrs']['ratio'] ?? null, + ], + 'type' => 'image' + ]; + } + + return [ + 'content' => [ + 'alt' => $params['attrs']['alt'] ?? null, + 'caption' => $params['attrs']['caption'] ?? null, + 'src' => $params['attrs']['src'] ?? null, + 'location' => 'web', + 'ratio' => $params['attrs']['ratio'] ?? null, + ], + 'type' => 'image' + ]; + } + + public static function editorKirbytext(array $params): array + { + return [ + 'content' => [ + 'text' => $params['content'] + ], + 'type' => 'markdown' + ]; + } + + public static function editorOl(array $params): array + { + return [ + 'content' => [ + 'text' => $params['content'] + ], + 'type' => 'list' + ]; + } + + public static function editorParagraph(array $params): array + { + return [ + 'content' => [ + 'text' => '

    ' . $params['content'] . '

    ' + ], + 'type' => 'text' + ]; + } + + public static function editorUl(array $params): array + { + return [ + 'content' => [ + 'text' => $params['content'] + ], + 'type' => 'list' + ]; + } + + public static function editorVideo(array $params): array + { + return [ + 'content' => [ + 'caption' => $params['attrs']['caption'] ?? null, + 'url' => $params['attrs']['src'] ?? null + ], + 'type' => 'video' + ]; + } + + public static function isEditorBlock(array $params): bool + { + if (isset($params['attrs']) === true) { + return true; + } + + if (is_string($params['content'] ?? null) === true) { + return true; + } + + return false; + } +} diff --git a/public/kirby/src/Cms/Blocks.php b/public/kirby/src/Cms/Blocks.php new file mode 100644 index 0000000..1c817b2 --- /dev/null +++ b/public/kirby/src/Cms/Blocks.php @@ -0,0 +1,172 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\Block> + */ +class Blocks extends Items +{ + public const ITEM_CLASS = Block::class; + + /** + * All registered blocks methods + */ + public static array $methods = []; + + /** + * Return HTML when the collection is + * converted to a string + */ + public function __toString(): string + { + return $this->toHtml(); + } + + /** + * Converts the blocks to HTML and then + * uses the Str::excerpt method to create + * a non-formatted, shortened excerpt from it + */ + public function excerpt(mixed ...$args): string + { + return Str::excerpt($this->toHtml(), ...$args); + } + + /** + * Wrapper around the factory to + * catch blocks from layouts + */ + public static function factory( + array|null $items = null, + array $params = [] + ): static { + $items = static::extractFromLayouts($items); + + // @deprecated old editor format + // @todo block.converter remove eventually + // @codeCoverageIgnoreStart + $items = BlockConverter::editorBlocks($items); + // @codeCoverageIgnoreEnd + + return parent::factory($items, $params); + } + + /** + * Pull out blocks from layouts + */ + protected static function extractFromLayouts(array $input): array + { + if ($input === []) { + return []; + } + + if ( + // no columns = no layout + array_key_exists('columns', $input[0]) === false || + // @deprecated checks if this is a block for the builder plugin + // @todo block.converter remove eventually + array_key_exists('_key', $input[0]) === true + ) { + return $input; + } + + $blocks = []; + + foreach ($input as $layout) { + foreach (($layout['columns'] ?? []) as $column) { + foreach (($column['blocks'] ?? []) as $block) { + $blocks[] = $block; + } + } + } + + return $blocks; + } + + /** + * Checks if a given block type exists in the collection + * @since 3.6.0 + */ + public function hasType(string $type): bool + { + return $this->filterBy('type', $type)->count() > 0; + } + + /** + * Parse and sanitize various block formats + */ + public static function parse(array|string|null $input): array + { + if ( + empty($input) === false && + is_array($input) === false + ) { + try { + $input = Json::decode((string)$input); + } catch (Throwable) { + // @deprecated try to import the old YAML format + // @todo block.converter remove eventually + // @codeCoverageIgnoreStart + try { + $yaml = Yaml::decode((string)$input); + $first = A::first($yaml); + + // check for valid yaml + if ( + $yaml === [] || + ( + isset($first['_key']) === false && + isset($first['type']) === false + ) + ) { + throw new Exception(message: 'Invalid YAML'); + } + + $input = $yaml; + } catch (Throwable) { + // the next 2 lines remain after removing block.converter + // @codeCoverageIgnoreEnd + $parser = new Parsley((string)$input, new BlockSchema()); + $input = $parser->blocks(); + // @codeCoverageIgnoreStart + } + // @codeCoverageIgnoreEnd + } + } + + if (empty($input) === true) { + return []; + } + + return $input; + } + + /** + * Convert all blocks to HTML + */ + public function toHtml(): string + { + $html = A::map($this->data, fn ($block) => $block->toHtml()); + + return implode($html); + } +} diff --git a/public/kirby/src/Cms/Blueprint.php b/public/kirby/src/Cms/Blueprint.php new file mode 100644 index 0000000..6a62014 --- /dev/null +++ b/public/kirby/src/Cms/Blueprint.php @@ -0,0 +1,901 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Blueprint +{ + public static array $presets = []; + public static array $loaded = []; + + protected array $fields = []; + protected ModelWithContent $model; + protected array $props; + protected array $sections = []; + protected array $tabs = []; + + protected array|null $fileTemplates = null; + + /** + * Creates a new blueprint object with the given props + * + * @throws \Kirby\Exception\InvalidArgumentException If the blueprint model is missing + */ + public function __construct(array $props) + { + if (empty($props['model']) === true) { + throw new InvalidArgumentException( + message: 'A blueprint model is required' + ); + } + + if ($props['model'] instanceof ModelWithContent === false) { + throw new InvalidArgumentException( + message: 'Invalid blueprint model' + ); + } + + $this->model = $props['model']; + + // the model should not be included in the props array + unset($props['model']); + + // extend the blueprint in general + $props = static::extend($props); + + // apply any blueprint preset + $props = $this->preset($props); + + // normalize the name + $props['name'] ??= 'default'; + + // normalize and translate the title + $props['title'] ??= ucfirst($props['name']); + $props['title'] = $this->i18n($props['title']); + + // convert all shortcuts + $props = $this->convertFieldsToSections('main', $props); + $props = $this->convertSectionsToColumns('main', $props); + $props = $this->convertColumnsToTabs('main', $props); + + // normalize all tabs + $props['tabs'] = $this->normalizeTabs($props['tabs'] ?? []); + + $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 + * + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->props ?? []; + } + + /** + * Gathers what file templates are allowed in + * this model based on the blueprint + */ + public function acceptedFileTemplates(string|null $inSection = null): array + { + // get cached results for the current file model + // (except when collecting for a specific section) + if ($inSection === null && $this->fileTemplates !== null) { + return $this->fileTemplates; // @codeCoverageIgnore + } + + $templates = []; + + // collect all allowed file templates from blueprint… + foreach ($this->sections() as $section) { + // if collecting for a specific section, skip all others + if ($inSection !== null && $section->name() !== $inSection) { + continue; + } + + $templates = match ($section->type()) { + 'files' => [...$templates, $section->template() ?? 'default'], + 'fields' => [ + ...$templates, + ...$this->acceptedFileTemplatesFromFields($section->fields()) + ], + default => $templates + }; + } + + // no caching for when collecting for specific section + if ($inSection !== null) { + return $templates; // @codeCoverageIgnore + } + + return $this->fileTemplates = $templates; + } + + /** + * Gathers the allowed file templates from model's fields + */ + protected function acceptedFileTemplatesFromFields(array $fields): array + { + $templates = []; + + foreach ($fields as $field) { + // fields with uploads settings + if (isset($field['uploads']) === true && is_array($field['uploads']) === true) { + $templates = [ + ...$templates, + ...$this->acceptedFileTemplatesFromFieldUploads($field['uploads']) + ]; + continue; + } + + // structure and object fields + if (isset($field['fields']) === true && is_array($field['fields']) === true) { + $templates = [ + ...$templates, + ...$this->acceptedFileTemplatesFromFields($field['fields']), + ]; + continue; + } + + // layout and blocks fields + if (isset($field['fieldsets']) === true && is_array($field['fieldsets']) === true) { + $templates = [ + ...$templates, + ...$this->acceptedFileTemplatesFromFieldsets($field['fieldsets']) + ]; + continue; + } + } + + return $templates; + } + + /** + * Gathers the allowed file templates from fieldsets + */ + protected function acceptedFileTemplatesFromFieldsets(array $fieldsets): array + { + $templates = []; + + foreach ($fieldsets as $fieldset) { + foreach (($fieldset['tabs'] ?? []) as $tab) { + $templates = [ + ...$templates, + ...$this->acceptedFileTemplatesFromFields($tab['fields'] ?? []) + ]; + } + } + + return $templates; + } + + /** + * Extracts templates from field uploads settings + */ + protected function acceptedFileTemplatesFromFieldUploads(array $uploads): array + { + // only if the `uploads` parent is this model + if ($target = $uploads['parent'] ?? null) { + if ($this->model->id() !== $target) { + return []; + } + } + + return [($uploads['template'] ?? 'default')]; + } + + /** + * 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 + */ + protected function convertColumnsToTabs( + string $tabName, + array $props + ): array { + if (isset($props['columns']) === false) { + return $props; + } + + // wrap everything in a main tab + $props['tabs'] = [ + $tabName => [ + 'columns' => $props['columns'] + ] + ]; + + unset($props['columns']); + + return $props; + } + + /** + * Converts all field definitions, that are not + * wrapped in a fields section into a generic + * fields section. + */ + protected function convertFieldsToSections( + string $tabName, + array $props + ): array { + if (isset($props['fields']) === false) { + return $props; + } + + // wrap all fields in a section + $props['sections'] = [ + $tabName . '-fields' => [ + 'type' => 'fields', + 'fields' => $props['fields'] + ] + ]; + + unset($props['fields']); + + return $props; + } + + /** + * Converts all sections that are not wrapped in + * columns, into a single generic column. + */ + protected function convertSectionsToColumns( + string $tabName, + array $props + ): array { + if (isset($props['sections']) === false) { + return $props; + } + + // wrap everything in one big column + $props['columns'] = [ + [ + 'width' => '1/1', + 'sections' => $props['sections'] + ] + ]; + + unset($props['sections']); + + return $props; + } + + /** + * Extends the props with props from a given + * mixin, when an extends key is set or the + * props is just a string + * + * @param array|string $props + */ + public static function extend($props): array + { + if (is_string($props) === true) { + $props = [ + 'extends' => $props + ]; + } + + if ($extends = $props['extends'] ?? null) { + foreach (A::wrap($extends) as $extend) { + try { + $mixin = static::find($extend); + $mixin = static::extend($mixin); + $props = A::merge($mixin, $props, A::MERGE_REPLACE); + } catch (Exception) { + // keep the props unextended if the snippet wasn't found + } + } + + // remove the extends flag + unset($props['extends']); + } + + return $props; + } + + /** + * Create a new blueprint for a model + */ + public static function factory( + string $name, + string|null $fallback, + ModelWithContent $model + ): static|null { + try { + $props = static::load($name); + } catch (Exception) { + $props = $fallback !== null ? static::load($fallback) : null; + } + + if ($props === null) { + return null; + } + + // inject the parent model + $props['model'] = $model; + + return new static($props); + } + + /** + * Returns a single field definition by name + */ + public function field(string $name): array|null + { + return $this->fields[$name] ?? null; + } + + /** + * Returns all field definitions + */ + public function fields(): array + { + return $this->fields; + } + + /** + * Find a blueprint by name + * + * @throws \Kirby\Exception\NotFoundException If the blueprint cannot be found + */ + public static function find(string $name): array + { + if (isset(static::$loaded[$name]) === true) { + return static::$loaded[$name]; + } + + $kirby = App::instance(); + $root = $kirby->root('blueprints'); + $file = $root . '/' . $name . '.yml'; + + // first try to find the blueprint in the `site/blueprints` root, + // then check in the plugin extensions which includes some default + // core blueprints (e.g. page, file, site and block defaults) + // as well as blueprints provided by plugins + if (F::exists($file, $root) !== true) { + $file = $kirby->extension('blueprints', $name); + } + + // callback option can be return array or blueprint file path + if (is_callable($file) === true) { + $file = $file($kirby); + } + + // now ensure that we always return the data array + if (is_string($file) === true && F::exists($file) === true) { + return static::$loaded[$name] = Data::read($file); + } + + if (is_array($file) === true) { + return static::$loaded[$name] = $file; + } + + // neither a valid file nor array data + throw new NotFoundException( + key: 'blueprint.notFound', + data: ['name' => $name] + ); + } + + /** + * Used to translate any label, heading, etc. + */ + protected function i18n(mixed $value, mixed $fallback = null): mixed + { + return I18n::translate($value, $fallback) ?? $value; + } + + /** + * Checks if this is the default blueprint + */ + public function isDefault(): bool + { + return $this->name() === 'default'; + } + + /** + * Loads a blueprint from file or array + */ + public static function load(string $name): array + { + $props = static::find($name); + + // inject the filename as name if no name is set + $props['name'] ??= $name; + + // normalize the title + $title = $props['title'] ?? ucfirst($props['name']); + + // translate the title + $props['title'] = I18n::translate($title) ?? $title; + + return $props; + } + + /** + * Returns the parent model + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * Returns the blueprint name + */ + public function name(): string + { + return $this->props['name']; + } + + /** + * Normalizes all required props in a column setup + */ + protected function normalizeColumns(string $tabName, array $columns): array + { + foreach ($columns as $columnKey => $columnProps) { + // unset/remove column if its property is not array + if (is_array($columnProps) === false) { + unset($columns[$columnKey]); + continue; + } + + $columnProps = $this->convertFieldsToSections( + $tabName . '-col-' . $columnKey, + $columnProps + ); + + // inject getting started info, if the sections are empty + if (empty($columnProps['sections']) === true) { + $columnProps['sections'] = [ + $tabName . '-info-' . $columnKey => [ + 'label' => 'Column (' . ($columnProps['width'] ?? '1/1') . ')', + 'type' => 'info', + 'text' => 'No sections yet' + ] + ]; + } + + $columns[$columnKey] = [ + ...$columnProps, + 'width' => $columnProps['width'] ?? '1/1', + 'sections' => $this->normalizeSections( + $tabName, + $columnProps['sections'] ?? [] + ) + ]; + } + + return $columns; + } + + public static function helpList(array $items): string + { + $md = []; + + foreach ($items as $item) { + $md[] = '- *' . $item . '*'; + } + + return PHP_EOL . implode(PHP_EOL, $md); + } + + /** + * Normalize field props for a single field + * + * @throws \Kirby\Exception\InvalidArgumentException If the filed name is missing or the field type is invalid + */ + public static function fieldProps(array|string $props): array + { + $props = static::extend($props); + + if (isset($props['name']) === false) { + 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( + message: 'Invalid field type ("' . $type . '")' + ); + } + + // support for nested fields + if (isset($props['fields']) === true) { + $props['fields'] = static::fieldsProps($props['fields']); + } + + // groups don't need all the crap + if ($type === 'group') { + $fields = $props['fields']; + + if (isset($props['when']) === true) { + $fields = array_map( + fn ($field) => array_replace_recursive(['when' => $props['when']], $field), + $fields + ); + } + + return [ + 'fields' => $fields, + 'name' => $name, + 'type' => $type + ]; + } + + // add some useful defaults + return [ + ...$props, + 'label' => $props['label'] ?? ucfirst($name), + 'name' => $name, + 'type' => $type, + 'width' => $props['width'] ?? '1/1', + ]; + } + + /** + * Creates an error field with the given error message + */ + public static function fieldError(string $name, string $message): array + { + return [ + 'label' => 'Error', + 'name' => $name, + 'text' => strip_tags($message), + 'theme' => 'negative', + 'type' => 'info', + ]; + } + + /** + * Normalizes all fields and adds automatic labels, + * types and widths. + */ + public static function fieldsProps($fields): array + { + if (is_array($fields) === false) { + $fields = []; + } + + foreach ($fields as $fieldName => $fieldProps) { + // extend field from string + if (is_string($fieldProps) === true) { + $fieldProps = [ + 'extends' => $fieldProps, + 'name' => $fieldName + ]; + } + + // use the name as type definition + if ($fieldProps === true) { + $fieldProps = []; + } + + // unset / remove field if its property is false + if ($fieldProps === false) { + unset($fields[$fieldName]); + continue; + } + + // inject the name + $fieldProps['name'] = $fieldName; + + // create all props + try { + $fieldProps = static::fieldProps($fieldProps); + } catch (Throwable $e) { + $fieldProps = static::fieldError($fieldName, $e->getMessage()); + } + + // resolve field groups + if ($fieldProps['type'] === 'group') { + if ( + empty($fieldProps['fields']) === false && + is_array($fieldProps['fields']) === true + ) { + $index = array_search($fieldName, array_keys($fields)); + $fields = [ + ...array_slice($fields, 0, $index), + ...$fieldProps['fields'] ?? [], + ...array_slice($fields, $index + 1) + ]; + } else { + unset($fields[$fieldName]); + } + } else { + $fields[$fieldName] = $fieldProps; + } + } + + return $fields; + } + + /** + * Normalizes blueprint options. This must be used in the + * constructor of an extended class, if you want to make use of it. + */ + protected function normalizeOptions( + array|string|bool|null $options, + array $defaults, + array $aliases = [] + ): array { + // return defaults when options are not defined or set to true + if ($options === true) { + return $defaults; + } + + // set all options to false + if ($options === false) { + return array_map(fn () => false, $defaults); + } + + // extend options if possible + $options = static::extend($options); + + foreach ($options as $key => $value) { + $alias = $aliases[$key] ?? null; + + if ($alias !== null) { + $options[$alias] ??= $value; + unset($options[$key]); + } + } + + return [...$defaults, ...$options]; + } + + /** + * Normalizes all required keys in sections + */ + protected function normalizeSections( + string $tabName, + array $sections + ): array { + foreach ($sections as $sectionName => $sectionProps) { + // unset / remove section if its property is false + if ($sectionProps === false) { + unset($sections[$sectionName]); + continue; + } + + // fallback to default props when true is passed + if ($sectionProps === true) { + $sectionProps = []; + } + + // inject all section extensions + $sectionProps = static::extend($sectionProps); + + $sections[$sectionName] = $sectionProps = [ + ...$sectionProps, + 'name' => $sectionName, + 'type' => $type = $sectionProps['type'] ?? $sectionName + ]; + + if (empty($type) === true || is_string($type) === false) { + $sections[$sectionName] = [ + 'name' => $sectionName, + 'label' => 'Invalid section type for section "' . $sectionName . '"', + 'type' => 'info', + 'text' => 'The following section types are available: ' . static::helpList(array_keys(Section::$types)) + ]; + } elseif (isset(Section::$types[$type]) === false) { + $sections[$sectionName] = [ + 'name' => $sectionName, + 'label' => 'Invalid section type ("' . $type . '")', + 'type' => 'info', + 'text' => 'The following section types are available: ' . static::helpList(array_keys(Section::$types)) + ]; + } + + if ($sectionProps['type'] === 'fields') { + $fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []); + + // inject guide fields guide + if ($fields === []) { + $fields = [ + $tabName . '-info' => [ + 'label' => 'Fields', + 'text' => 'No fields yet', + 'type' => 'info' + ] + ]; + } else { + foreach ($fields as $fieldName => $fieldProps) { + if (isset($this->fields[$fieldName]) === true) { + $this->fields[$fieldName] = $fields[$fieldName] = [ + 'type' => 'info', + 'label' => $fieldProps['label'] ?? 'Error', + 'text' => 'The field name "' . $fieldName . '" already exists in your blueprint.', + 'theme' => 'negative' + ]; + } else { + $this->fields[$fieldName] = $fieldProps; + } + } + } + + $sections[$sectionName]['fields'] = $fields; + } + } + + // store all normalized sections + $this->sections = [...$this->sections, ...$sections]; + + return $sections; + } + + /** + * Normalizes all required keys in tabs + */ + protected function normalizeTabs($tabs): array + { + if (is_array($tabs) === false) { + $tabs = []; + } + + foreach ($tabs as $tabName => $tabProps) { + // unset / remove tab if its property is false + if ($tabProps === false) { + unset($tabs[$tabName]); + continue; + } + + // inject all tab extensions + $tabProps = static::extend($tabProps); + + // inject a preset if available + $tabProps = $this->preset($tabProps); + + $tabProps = $this->convertFieldsToSections($tabName, $tabProps); + $tabProps = $this->convertSectionsToColumns($tabName, $tabProps); + + $tabs[$tabName] = [ + ...$tabProps, + 'columns' => $this->normalizeColumns($tabName, $tabProps['columns'] ?? []), + 'icon' => $tabProps['icon'] ?? null, + 'label' => $this->i18n($tabProps['label'] ?? ucfirst($tabName)), + 'link' => $this->model->panel()->url(true) . '/?tab=' . $tabName, + 'name' => $tabName, + ]; + } + + return $this->tabs = $tabs; + } + + /** + * Injects a blueprint preset + */ + protected function preset(array $props): array + { + if (isset($props['preset']) === false) { + return $props; + } + + if (isset(static::$presets[$props['preset']]) === false) { + return $props; + } + + $preset = static::$presets[$props['preset']]; + + if (is_string($preset) === true) { + $preset = F::load($preset, allowOutput: false); + } + + return $preset($props); + } + + /** + * Returns a single section by name + */ + public function section(string $name): Section|null + { + if (empty($this->sections[$name]) === true) { + return null; + } + + if ($this->sections[$name] instanceof Section) { + return $this->sections[$name]; //@codeCoverageIgnore + } + + // get all props + $props = $this->sections[$name]; + + // inject the blueprint model + $props['model'] = $this->model(); + + // create a new section object + return $this->sections[$name] = new Section($props['type'], $props); + } + + /** + * Returns all sections + */ + public function sections(): array + { + return A::map( + $this->sections, + fn ($section) => match (true) { + $section instanceof Section => $section, + default => $this->section($section['name']) + } + ); + } + + /** + * Returns a single tab by name + */ + public function tab(string|null $name = null): array|null + { + if ($name === null) { + return A::first($this->tabs); + } + + return $this->tabs[$name] ?? null; + } + + /** + * Returns all tabs + */ + public function tabs(): array + { + return array_values($this->tabs); + } + + /** + * Returns the blueprint title + */ + public function title(): string + { + return $this->props['title']; + } + + /** + * Converts the blueprint object to a plain array + */ + public function toArray(): array + { + return $this->props; + } +} diff --git a/public/kirby/src/Cms/Collection.php b/public/kirby/src/Cms/Collection.php new file mode 100644 index 0000000..b7284a1 --- /dev/null +++ b/public/kirby/src/Cms/Collection.php @@ -0,0 +1,403 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @template TValue + * @extends \Kirby\Toolkit\Collection + */ +class Collection extends BaseCollection +{ + use HasMethods; + + /** + * @var \Kirby\Cms\Pagination|null + */ + protected $pagination; + + /** + * Creates a new Collection with the given objects + * + * @param iterable $objects + * @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 + if ($this->hasMethod($key) === true) { + return $this->callMethod($key, $arguments); + } + } + + /** + * Internal setter for each object in the Collection; + * override from the Toolkit Collection is needed to + * make the CMS collections case-sensitive; + * child classes can override it again to add validation + * and custom behavior depending on the object type + * + * @param TValue $object + */ + public function __set(string $id, $object): void + { + $this->data[$id] = $object; + } + + /** + * Internal remover for each object in the Collection; + * override from the Toolkit Collection is needed to + * make the CMS collections case-sensitive + */ + public function __unset(string $id) + { + unset($this->data[$id]); + } + + /** + * Adds a single object or + * an entire second collection to the + * current collection + * + * @param \Kirby\Cms\Collection|array|TValue $object + * @return $this + */ + public function add($object): static + { + if ($object instanceof self) { + $this->data = [...$this->data, ...$object->data]; + } elseif ( + is_object($object) === true && + method_exists($object, 'id') === true + ) { + $this->__set($object->id(), $object); + } else { + $this->append($object); + } + + return $this; + } + + /** + * Appends an element to the data array + * + * ```php + * $collection->append($object); + * $collection->append('key', $object); + * ``` + * + * @param string|TValue ...$args + * @return $this + */ + public function append(...$args): static + { + if (count($args) === 1) { + // try to determine the key from the provided item + if ( + is_object($args[0]) === true && + is_callable([$args[0], 'id']) === true + ) { + return parent::append($args[0]->id(), $args[0]); + } + + return parent::append($args[0]); + } + + return parent::append(...$args); + } + + /** + * Find a single element by an attribute and its value + * + * @return TValue|null + */ + public function findBy(string $attribute, $value) + { + // $value: cast UUID object to string to allow uses + // like `$pages->findBy('related', $page->uuid())` + if ($value instanceof Uuid) { + $value = $value->toString(); + } + + return parent::findBy($attribute, $value); + } + + /** + * 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 bool $caseInsensitive Ignore upper/lowercase for group names + * @throws \Kirby\Exception\Exception + */ + 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( + message: 'Invalid grouping value for key: ' . $key + ); + } + + $value = (string)$value; + + // ignore upper/lowercase for group names + if ($caseInsensitive) { + $value = Str::lower($value); + } + + if (isset($groups->data[$value]) === false) { + // create a new entry for the group if it does not exist yet + $groups->data[$value] = new static([$key => $item]); + } else { + // add the item to an existing group + $groups->data[$value]->set($key, $item); + } + } + + return $groups; + } + + // 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|TValue $key + */ + public function has($key): bool + { + if (is_object($key) === true) { + $key = $key->id(); + } + + return parent::has($key); + } + + /** + * Correct position detection for objects. + * The method will automatically detect objects + * or ids and then search accordingly. + * + * @param string|TValue $needle + */ + public function indexOf($needle): int|false + { + if (is_string($needle) === true) { + return array_search($needle, $this->keys()); + } + + return array_search($needle->id(), $this->keys()); + } + + /** + * Returns a Collection without the given element(s) + * + * @param string|array|object ...$keys any number of keys, + * passed as individual arguments + */ + public function not(string|array|object ...$keys): static + { + $collection = $this->clone(); + + foreach ($keys as $key) { + if (is_array($key) === true) { + return $this->not(...$key); + } + + if ($key instanceof BaseCollection) { + $collection = $collection->not(...$key->keys()); + } elseif (is_object($key) === true) { + $key = $key->id(); + } + + unset($collection->{$key}); + } + + return $collection; + } + + /** + * Add pagination and return a sliced set of data. + * + * @return $this|static + */ + public function paginate(...$arguments): static + { + $this->pagination = Pagination::for($this, ...$arguments); + + // slice and clone the collection according to the pagination + return $this->slice( + $this->pagination->offset(), + $this->pagination->limit() + ); + } + + /** + * Get the previously added pagination object + */ + public function pagination(): Pagination|null + { + return $this->pagination; + } + + /** + * Returns the parent model + */ + public function parent(): object|null + { + return $this->parent; + } + + /** + * Prepends an element to the data array + * + * ```php + * $collection->prepend($object); + * $collection->prepend('key', $object); + * ``` + * + * @param string|TValue ...$args + * @return $this + */ + public function prepend(...$args): static + { + if (count($args) === 1) { + // try to determine the key from the provided item + if ( + is_object($args[0]) === true && + is_callable([$args[0], 'id']) === true + ) { + return parent::prepend($args[0]->id(), $args[0]); + } + + return parent::prepend($args[0]); + } + + return parent::prepend(...$args); + } + + /** + * Runs a combination of filter, sort, not, + * offset, limit, search and paginate on the collection. + * Any part of the query is optional. + */ + public function query(array $arguments = []): static + { + $paginate = $arguments['paginate'] ?? null; + $search = $arguments['search'] ?? null; + + unset($arguments['paginate']); + + $result = parent::query($arguments); + + if (empty($search) === false) { + $result = match (true) { + is_array($search) => $result->search( + $search['query'] ?? null, + $search['options'] ?? [] + ), + default => $result->search($search) + }; + } + + if (empty($paginate) === false) { + $result = $result->paginate($paginate); + } + + return $result; + } + + /** + * Removes an object + * + * @param string|TValue $key the name of the key + */ + public function remove(string|object $key): static + { + if (is_object($key) === true) { + $key = $key->id(); + } + + return parent::remove($key); + } + + /** + * Searches the collection + */ + public function search( + string|null $query = null, + string|array $params = [] + ): static { + return Search::collection($this, $query, $params); + } + + /** + * Converts all objects in the collection + * to an array. This can also take a callback + * function to further modify the array result. + */ + public function toArray(Closure|null $map = null): array + { + 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 new file mode 100644 index 0000000..ad67fbf --- /dev/null +++ b/public/kirby/src/Cms/Collections.php @@ -0,0 +1,131 @@ +collection()` + * method to provide easy access to registered collections + * + * @package Kirby Cms + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Collections +{ + /** + * Each collection is cached once it + * has been called, to avoid further + * processing on sequential calls to + * the same collection. + */ + protected array $cache = []; + + /** + * Store of all collections + */ + protected array $collections = []; + + /** + * Magic caller to enable something like + * `$collections->myCollection()` + * + * @return \Kirby\Toolkit\Collection|null + * @todo 6.0 Add return type declaration + */ + public function __call(string $name, array $arguments = []) + { + return $this->get($name, ...$arguments); + } + + /** + * Loads a collection by name if registered + * + * @return \Kirby\Toolkit\Collection|null + * @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 = []) + { + // if not yet loaded + $this->collections[$name] ??= $this->load($name); + + // if not yet cached + if (($this->cache[$name]['data'] ?? null) !== $data) { + $controller = new Controller($this->collections[$name]); + + $this->cache[$name] = [ + 'result' => $controller->call(null, $data), + 'data' => $data + ]; + } + + // return cloned object + if (is_object($this->cache[$name]['result']) === true) { + return clone $this->cache[$name]['result']; + } + + return $this->cache[$name]['result']; + } + + /** + * Checks if a collection exists + */ + public function has(string $name): bool + { + if (isset($this->collections[$name]) === true) { + return true; + } + + try { + $this->load($name); + return true; + } catch (NotFoundException) { + return false; + } + } + + /** + * Loads collection from php file in a + * given directory or from plugin extension. + * + * @throws \Kirby\Exception\NotFoundException + */ + public function load(string $name): mixed + { + $kirby = App::instance(); + + // first check for collection file in the `collections` root + $root = $kirby->root('collections'); + $file = $root . '/' . $name . '.php'; + + if (F::exists($file, $root) === true) { + $collection = F::load($file, allowOutput: false); + + if ($collection instanceof Closure) { + return $collection; + } + } + + // fallback to collections from plugins + $collections = $kirby->extensions('collections'); + + if ($collection = $collections[$name] ?? null) { + return $collection; + } + + throw new NotFoundException( + message: 'The collection cannot be found' + ); + } +} diff --git a/public/kirby/src/Cms/Core.php b/public/kirby/src/Cms/Core.php new file mode 100644 index 0000000..569189b --- /dev/null +++ b/public/kirby/src/Cms/Core.php @@ -0,0 +1,484 @@ +core()` + * + * I.e. `$kirby->core()->areas()` + * + * @package Kirby Cms + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Core +{ + /** + * Optional override for the auto-detected index root + */ + public static string|null $indexRoot = null; + + protected array $cache = []; + protected string $root; + + public function __construct(protected App $kirby) + { + $this->root = dirname(__DIR__, 2) . '/config'; + } + + /** + * Fetches the definition array of a particular area. + * + * This is a shortcut for `$kirby->core()->load()->area()` + * to give faster access to original area code in plugins. + */ + public function area(string $name): array|null + { + return $this->load()->area($name); + } + + /** + * Returns a list of all paths to area definition files + * + * They are located in `/kirby/config/areas` + */ + public function areas(): array + { + return [ + 'account' => $this->root . '/areas/account.php', + 'installation' => $this->root . '/areas/installation.php', + 'lab' => $this->root . '/areas/lab.php', + 'languages' => $this->root . '/areas/languages.php', + 'login' => $this->root . '/areas/login.php', + 'logout' => $this->root . '/areas/logout.php', + 'search' => $this->root . '/areas/search.php', + 'site' => $this->root . '/areas/site.php', + 'system' => $this->root . '/areas/system.php', + 'users' => $this->root . '/areas/users.php', + ]; + } + + /** + * Returns a list of all default auth challenge classes + */ + public function authChallenges(): array + { + return [ + 'email' => EmailChallenge::class, + 'totp' => TotpChallenge::class, + ]; + } + + /** + * Returns a list of all paths to blueprint presets + * + * They are located in `/kirby/config/presets` + */ + public function blueprintPresets(): array + { + return [ + 'pages' => $this->root . '/presets/pages.php', + 'page' => $this->root . '/presets/page.php', + 'files' => $this->root . '/presets/files.php', + ]; + } + + /** + * Returns a list of paths to core blueprints or + * the blueprint in array form + * + * Block blueprints are located in `/kirby/config/blocks` + */ + public function blueprints(): array + { + return [ + // blocks + 'blocks/code' => $this->root . '/blocks/code/code.yml', + 'blocks/gallery' => $this->root . '/blocks/gallery/gallery.yml', + 'blocks/heading' => $this->root . '/blocks/heading/heading.yml', + 'blocks/image' => $this->root . '/blocks/image/image.yml', + 'blocks/line' => $this->root . '/blocks/line/line.yml', + 'blocks/list' => $this->root . '/blocks/list/list.yml', + 'blocks/markdown' => $this->root . '/blocks/markdown/markdown.yml', + 'blocks/quote' => $this->root . '/blocks/quote/quote.yml', + 'blocks/table' => $this->root . '/blocks/table/table.yml', + 'blocks/text' => $this->root . '/blocks/text/text.yml', + 'blocks/video' => $this->root . '/blocks/video/video.yml', + + // file blueprints + 'files/default' => ['title' => 'File'], + + // page blueprints + 'pages/default' => ['title' => 'Page'], + + // site blueprints + 'site' => [ + 'title' => 'Site', + 'sections' => [ + 'pages' => [ + 'headline' => ['*' => 'pages'], + 'type' => 'pages' + ] + ] + ] + ]; + } + + /** + * Returns a list of all core caches + */ + public function caches(): array + { + return [ + 'changes' => true, + 'updates' => true, + 'uuid' => true, + ]; + } + + /** + * Returns a list of all cache driver classes + */ + public function cacheTypes(): array + { + return [ + 'apcu' => ApcuCache::class, + 'file' => FileCache::class, + 'memcached' => MemCached::class, + 'memory' => MemoryCache::class, + 'redis' => RedisCache::class + ]; + } + + /** + * Returns an array of all core component functions + * + * The component functions can be found in + * `/kirby/config/components.php` + */ + public function components(): array + { + return $this->cache['components'] ??= include $this->root . '/components.php'; + } + + /** + * Returns a map of all field method aliases + */ + public function fieldMethodAliases(): array + { + return [ + 'bool' => 'toBool', + 'esc' => 'escape', + 'excerpt' => 'toExcerpt', + 'float' => 'toFloat', + 'h' => 'html', + 'int' => 'toInt', + 'kt' => 'kirbytext', + 'kti' => 'kirbytextinline', + 'link' => 'toLink', + 'md' => 'markdown', + 'sp' => 'smartypants', + 'v' => 'isValid', + 'x' => 'xml' + ]; + } + + /** + * Returns an array of all field method functions + * + * Field methods are stored in `/kirby/config/methods.php` + */ + public function fieldMethods(): array + { + return $this->cache['fieldMethods'] ??= (include $this->root . '/methods.php')($this->kirby); + } + + /** + * Returns an array of paths for field mixins + * + * They are located in `/kirby/config/fields/mixins` + */ + public function fieldMixins(): array + { + return [ + 'datetime' => $this->root . '/fields/mixins/datetime.php', + 'filepicker' => $this->root . '/fields/mixins/filepicker.php', + 'layout' => $this->root . '/fields/mixins/layout.php', + 'min' => $this->root . '/fields/mixins/min.php', + 'options' => $this->root . '/fields/mixins/options.php', + 'pagepicker' => $this->root . '/fields/mixins/pagepicker.php', + 'picker' => $this->root . '/fields/mixins/picker.php', + 'upload' => $this->root . '/fields/mixins/upload.php', + 'userpicker' => $this->root . '/fields/mixins/userpicker.php', + ]; + } + + /** + * Returns an array of all paths and class names of panel fields + * + * Traditional panel fields are located in `/kirby/config/fields` + * + * The more complex field classes can be found in + * `/kirby/src/Form/Fields` + */ + public function fields(): array + { + return [ + 'blocks' => BlocksField::class, + 'checkboxes' => $this->root . '/fields/checkboxes.php', + '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', + 'hidden' => $this->root . '/fields/hidden.php', + 'info' => $this->root . '/fields/info.php', + 'layout' => LayoutField::class, + 'line' => $this->root . '/fields/line.php', + 'link' => $this->root . '/fields/link.php', + 'list' => $this->root . '/fields/list.php', + 'multiselect' => $this->root . '/fields/multiselect.php', + 'number' => $this->root . '/fields/number.php', + 'object' => $this->root . '/fields/object.php', + 'pages' => $this->root . '/fields/pages.php', + 'radio' => $this->root . '/fields/radio.php', + 'range' => $this->root . '/fields/range.php', + 'select' => $this->root . '/fields/select.php', + 'slug' => $this->root . '/fields/slug.php', + 'stats' => StatsField::class, + 'structure' => $this->root . '/fields/structure.php', + 'tags' => $this->root . '/fields/tags.php', + 'tel' => $this->root . '/fields/tel.php', + 'text' => $this->root . '/fields/text.php', + 'textarea' => $this->root . '/fields/textarea.php', + 'time' => $this->root . '/fields/time.php', + 'toggle' => $this->root . '/fields/toggle.php', + 'toggles' => $this->root . '/fields/toggles.php', + 'url' => $this->root . '/fields/url.php', + 'users' => $this->root . '/fields/users.php', + 'writer' => $this->root . '/fields/writer.php' + ]; + } + + /** + * 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 + */ + public function kirbyTagAliases(): array + { + return [ + 'youtube' => 'video', + 'vimeo' => 'video' + ]; + } + + /** + * Returns an array of all kirbytag definitions + * + * They are located in `/kirby/config/tags.php` + */ + public function kirbyTags(): array + { + return $this->cache['kirbytags'] ??= include $this->root . '/tags.php'; + } + + /** + * Loads a core part of Kirby + * + * The loader is set to not include plugins. + * This way, you can access original Kirby core code + * through this load method. + */ + public function load(): Loader + { + return new Loader($this->kirby, false); + } + + /** + * Returns all absolute paths to important directories + * + * Roots are resolved and baked in `\Kirby\Cms\App::bakeRoots()` + */ + public function roots(): array + { + return $this->cache['roots'] ??= [ + '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', + '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', + ]; + } + + /** + * Returns an array of all routes for Kirby’s router + * + * Routes are split into `before` and `after` routes. + * + * Plugin routes will be injected inbetween. + */ + public function routes(): array + { + return $this->cache['routes'] ??= (include $this->root . '/routes.php')($this->kirby); + } + + /** + * Returns a list of all paths to core block snippets + * + * They are located in `/kirby/config/blocks` + */ + public function snippets(): array + { + return [ + 'blocks/code' => $this->root . '/blocks/code/code.php', + 'blocks/gallery' => $this->root . '/blocks/gallery/gallery.php', + 'blocks/heading' => $this->root . '/blocks/heading/heading.php', + 'blocks/image' => $this->root . '/blocks/image/image.php', + 'blocks/line' => $this->root . '/blocks/line/line.php', + 'blocks/list' => $this->root . '/blocks/list/list.php', + 'blocks/markdown' => $this->root . '/blocks/markdown/markdown.php', + 'blocks/quote' => $this->root . '/blocks/quote/quote.php', + 'blocks/table' => $this->root . '/blocks/table/table.php', + 'blocks/text' => $this->root . '/blocks/text/text.php', + 'blocks/video' => $this->root . '/blocks/video/video.php', + ]; + } + + /** + * Returns a list of paths to section mixins + * + * They are located in `/kirby/config/sections/mixins` + */ + 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', + 'help' => $this->root . '/sections/mixins/help.php', + 'layout' => $this->root . '/sections/mixins/layout.php', + 'max' => $this->root . '/sections/mixins/max.php', + 'min' => $this->root . '/sections/mixins/min.php', + 'pagination' => $this->root . '/sections/mixins/pagination.php', + 'parent' => $this->root . '/sections/mixins/parent.php', + 'search' => $this->root . '/sections/mixins/search.php', + 'sort' => $this->root . '/sections/mixins/sort.php', + ]; + } + + /** + * Returns a list of all section definitions + * + * They are located in `/kirby/config/sections` + */ + public function sections(): array + { + return [ + 'fields' => $this->root . '/sections/fields.php', + 'files' => $this->root . '/sections/files.php', + 'info' => $this->root . '/sections/info.php', + 'pages' => $this->root . '/sections/pages.php', + 'stats' => $this->root . '/sections/stats.php', + ]; + } + + /** + * Returns a list of paths to all system templates + * + * They are located in `/kirby/config/templates` + */ + public function templates(): array + { + return [ + 'emails/auth/login' => $this->root . '/templates/emails/auth/login.php', + 'emails/auth/password-reset' => $this->root . '/templates/emails/auth/password-reset.php' + ]; + } + + /** + * Returns an array with all system URLs + * + * URLs are resolved and baked in `\Kirby\Cms\App::bakeUrls()` + */ + public function urls(): array + { + return $this->cache['urls'] ??= [ + 'index' => fn () => $this->kirby->environment()->baseUrl(), + 'base' => fn (array $urls) => rtrim($urls['index'], '/'), + 'current' => function (array $urls) { + $path = trim($this->kirby->path(), '/'); + + if (empty($path) === true) { + return $urls['index']; + } + + return $urls['base'] . '/' . $path; + }, + 'assets' => fn (array $urls) => $urls['base'] . '/assets', + 'api' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('api.slug', 'api'), + 'media' => fn (array $urls) => $urls['base'] . '/media', + 'panel' => fn (array $urls) => $urls['base'] . '/' . $this->kirby->option('panel.slug', 'panel') + ]; + } +} diff --git a/public/kirby/src/Cms/Email.php b/public/kirby/src/Cms/Email.php new file mode 100644 index 0000000..d096350 --- /dev/null +++ b/public/kirby/src/Cms/Email.php @@ -0,0 +1,246 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Email +{ + /** + * Options configured through the `email` CMS option + */ + protected array $options; + + /** + * Props for the email object; will be passed to the + * \Kirby\Email\Email class + */ + protected array $props; + + /** + * Class constructor + * + * @param string|array $preset Preset name from the config or a simple props array + * @param array $props Props array to override the $preset + */ + public function __construct(string|array $preset = [], array $props = []) + { + $this->options = App::instance()->option('email', []); + + // build a prop array based on preset and props + $this->props = [...$this->preset($preset), ...$props]; + + // add transport settings + $this->props['transport'] ??= $this->options['transport'] ?? []; + + // add predefined beforeSend option + $this->props['beforeSend'] ??= $this->options['beforeSend'] ?? null; + + // transform model objects to values + $this->transformUserSingle('from', 'fromName'); + $this->transformUserSingle('replyTo', 'replyToName'); + $this->transformUserMultiple('to'); + $this->transformUserMultiple('cc'); + $this->transformUserMultiple('bcc'); + $this->transformFile('attachments'); + + // load template for body text + $this->template(); + } + + /** + * Grabs a preset from the options; supports fixed + * prop arrays in case a preset is not needed + * + * @param string|array $preset Preset name or simple prop array + * @throws \Kirby\Exception\NotFoundException + */ + protected function preset(string|array $preset): array + { + // only passed props, not preset name + if (is_array($preset) === true) { + return $preset; + } + + // preset does not exist + if (isset($this->options['presets'][$preset]) !== true) { + throw new NotFoundException( + key: 'email.preset.notFound', + data: ['name' => $preset] + ); + } + + return $this->options['presets'][$preset]; + } + + /** + * Renders the email template(s) and sets the body props + * to the result + * + * @throws \Kirby\Exception\NotFoundException + */ + protected function template(): void + { + if (isset($this->props['template']) === true) { + // prepare data to be passed to template + $data = $this->props['data'] ?? []; + + // check if html/text templates exist + $html = $this->getTemplate($this->props['template'], 'html'); + $text = $this->getTemplate($this->props['template'], 'text'); + + if ($html->exists() === true) { + $this->props['body'] = ['html' => $html->render($data)]; + + if ($text->exists() === true) { + $this->props['body']['text'] = $text->render($data); + } + + // fallback to single email text template + } elseif ($text->exists() === true) { + $this->props['body'] = $text->render($data); + } else { + throw new NotFoundException( + message: 'The email template "' . $this->props['template'] . '" cannot be found' + ); + } + } + } + + /** + * Returns an email template by name and type + */ + protected function getTemplate(string $name, string|null $type = null): Template + { + return App::instance()->template('emails/' . $name, $type, 'text'); + } + + /** + * Returns the prop array + */ + public function toArray(): array + { + return $this->props; + } + + /** + * Transforms file object(s) to an array of file roots; + * supports simple strings, file objects or collections/arrays of either + * + * @param string $prop Prop to transform + */ + protected function transformFile(string $prop): void + { + $this->props[$prop] = $this->transformModel($prop, File::class, 'root'); + } + + /** + * Transforms Kirby models to a simplified collection + * + * @param string $prop Prop to transform + * @param string $class Fully qualified class name of the supported model + * @param string $contentValue Model method that returns the array value + * @param string|null $contentKey Optional model method that returns the array key; + * returns a simple value-only array if not given + * @return array Simple key-value or just value array with the transformed prop data + */ + protected function transformModel( + string $prop, + string $class, + string $contentValue, + string|null $contentKey = null + ): array { + $value = $this->props[$prop] ?? []; + + // ensure consistent input by making everything an iterable value + if (is_iterable($value) !== true) { + $value = [$value]; + } + + $result = []; + foreach ($value as $key => $item) { + if (is_string($item) === true) { + // value is already a string + if ($contentKey !== null && is_string($key) === true) { + $result[$key] = $item; + } else { + $result[] = $item; + } + } elseif ($item instanceof $class) { + // value is a model object, get value through content method(s) + if ($contentKey !== null) { + $result[(string)$item->$contentKey()] = (string)$item->$contentValue(); + } else { + $result[] = (string)$item->$contentValue(); + } + } else { + // invalid input + throw new InvalidArgumentException( + message: 'Invalid input for prop "' . $prop . '", expected string or "' . $class . '" object or collection' + ); + } + } + + return $result; + } + + /** + * Transforms an user object to the email address and name; + * supports simple strings, user objects or collections/arrays of either + * (note: only the first item in a collection/array will be used) + * + * @param string $addressProp Prop with the email address + * @param string $nameProp Prop with the name corresponding to the $addressProp + */ + protected function transformUserSingle( + string $addressProp, + string $nameProp + ): void { + $result = $this->transformModel($addressProp, User::class, 'name', 'email'); + + $address = array_keys($result)[0] ?? null; + $name = $result[$address] ?? null; + + // if the array is non-associative, the value is the address + if (is_int($address) === true) { + $address = $name; + $name = null; + } + + // always use the address as we have transformed that prop above + $this->props[$addressProp] = $address; + + // only use the name from the user if no custom name was set + $this->props[$nameProp] ??= $name; + } + + /** + * Transforms user object(s) to the email address(es) and name(s); + * supports simple strings, user objects or collections/arrays of either + * + * @param string $prop Prop to transform + */ + protected function transformUserMultiple(string $prop): void + { + $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 new file mode 100644 index 0000000..ab834b5 --- /dev/null +++ b/public/kirby/src/Cms/Event.php @@ -0,0 +1,278 @@ +trigger()` + * or `$kirby->apply()` methods are called. It collects all + * event information and handles calling the individual hooks. + * @since 3.4.0 + * + * @package Kirby Cms + * @author Lukas Bestle , + * Ahmet Bora + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Event implements Stringable +{ + /** + * The event type + * (e.g. `page` in `page.create:after`) + */ + protected string $type; + + /** + * The event action + * (e.g. `create` in `page.create:after`) + */ + protected string|null $action; + + /** + * The event state + * (e.g. `after` in `page.create:after`) + */ + protected string|null $state; + + /** + * Class constructor + * + * @param string $name Full event name (e.g. `page.create:after`) + * @param array $arguments Associative array of named event 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 + $regex = '/^(?.+?)(?:\.(?[^.]*?))?(?:\:(?.*))?$/'; + preg_match($regex, $name, $matches, PREG_UNMATCHED_AS_NULL); + + $this->name = $name; + $this->type = $matches['type']; + $this->action = $matches['action'] ?? null; + $this->state = $matches['state'] ?? null; + $this->arguments = $arguments; + } + + /** + * Magic caller for event arguments + */ + public function __call(string $method, array $arguments = []): mixed + { + return $this->argument($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Makes it possible to simply echo + * or stringify the entire object + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Returns the action of the event (e.g. `create`) + * or `null` if the event name does not include an action + */ + public function action(): string|null + { + return $this->action; + } + + /** + * Returns a specific event argument + */ + public function argument(string $name): mixed + { + return $this->arguments[$name] ?? null; + } + + /** + * Returns the arguments of the event + */ + public function arguments(): array + { + return $this->arguments; + } + + /** + * Calls a hook with the event data and returns + * the hook's return value + * + * @param object|null $bind Optional object to bind to the hook function + */ + public function call(object|null $bind, Closure $hook): mixed + { + // 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); + return $hook->call($bind, $data); + } + + /** + * Returns the full name of the event + */ + public function name(): string + { + return $this->name; + } + + /** + * Returns the full list of possible wildcard + * event names based on the current event name + */ + public function nameWildcards(): array + { + // if the event is already a wildcard event, + // no further variation is possible + if ( + $this->type === '*' || + $this->action === '*' || + $this->state === '*' + ) { + return []; + } + + if ($this->action !== null && $this->state !== null) { + // full $type.$action:$state event + + return [ + $this->type . '.*:' . $this->state, + $this->type . '.' . $this->action . ':*', + $this->type . '.*:*', + '*.' . $this->action . ':' . $this->state, + '*.' . $this->action . ':*', + '*:' . $this->state, + '*' + ]; + } + + if ($this->state !== null) { + // event without action: $type:$state + + return [ + $this->type . ':*', + '*:' . $this->state, + '*' + ]; + } + + if ($this->action !== null) { + // event without state: $type.$action + + return [ + $this->type . '.*', + '*.' . $this->action, + '*' + ]; + } + + // event with a simple name + return ['*']; + } + + /** + * Returns the state of the event (e.g. `after`) + */ + public function state(): string|null + { + return $this->state; + } + + /** + * Returns the event data as array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'arguments' => $this->arguments + ]; + } + + /** + * Returns the event name as string + */ + public function toString(): string + { + return $this->name; + } + + /** + * Returns the type of the event (e.g. `page`) + */ + public function type(): string + { + return $this->type; + } + + /** + * Updates a given argument with a new value + * + * @unstable + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function updateArgument(string $name, $value): void + { + if (array_key_exists($name, $this->arguments) !== true) { + 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 new file mode 100644 index 0000000..bd1a469 --- /dev/null +++ b/public/kirby/src/Cms/Fieldset.php @@ -0,0 +1,239 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Fieldsets> + */ +class Fieldset extends Item +{ + public const ITEMS_CLASS = Fieldsets::class; + + protected bool $disabled; + protected bool $editable; + protected array $fields = []; + protected string|null $icon; + protected string|null $label; + protected string|null $name; + protected string|bool|null $preview; + protected array $tabs; + protected bool $translate; + protected string $type; + protected bool $unset; + protected bool $wysiwyg; + + /** + * Creates a new Fieldset object + */ + public function __construct(array $params = []) + { + if (empty($params['type']) === true) { + throw new InvalidArgumentException( + message: 'The fieldset type is missing' + ); + } + + $this->type = $params['id'] = $params['type']; + + parent::__construct($params); + + $this->disabled = $params['disabled'] ?? false; + $this->editable = $params['editable'] ?? true; + $this->icon = $params['icon'] ?? null; + $params['title'] ??= $params['name'] ?? Str::ucfirst($this->type); + $this->name = $this->createName($params['title']); + $this->label = $this->createLabel($params['label'] ?? null); + $this->preview = $params['preview'] ?? null; + $this->tabs = $this->createTabs($params); + $this->translate = $params['translate'] ?? true; + $this->unset = $params['unset'] ?? false; + $this->wysiwyg = $params['wysiwyg'] ?? false; + + if ( + $this->translate === false && + $this->kirby()->multilang() === true && + $this->kirby()->language()->isDefault() === false + ) { + // disable and unset the fieldset if it's not translatable + $this->unset = true; + $this->disabled = true; + } + } + + protected function createFields(array $fields = []): array + { + $fields = Blueprint::fieldsProps($fields); + $fields = $this->form($fields)->fields()->toProps(); + + // collect all fields + $this->fields = [...$this->fields, ...$fields]; + + return $fields; + } + + protected function createName(array|string $name): string|null + { + return I18n::translate($name, $name); + } + + protected function createLabel(array|string|null $label = null): string|null + { + return I18n::translate($label, $label); + } + + protected function createTabs(array $params = []): array + { + $tabs = $params['tabs'] ?? []; + + // return a single tab if there are only fields + if (empty($tabs) === true) { + return [ + 'content' => [ + 'fields' => $this->createFields($params['fields'] ?? []), + ] + ]; + } + + // normalize tabs props + foreach ($tabs as $name => $tab) { + // unset/remove tab if its property is false + if ($tab === false) { + unset($tabs[$name]); + continue; + } + + $tab = Blueprint::extend($tab); + + $tab['fields'] = $this->createFields($tab['fields'] ?? []); + $tab['label'] ??= Str::ucfirst($name); + $tab['label'] = $this->createLabel($tab['label']); + $tab['name'] = $name; + + $tabs[$name] = $tab; + } + + return $tabs; + } + + public function disabled(): bool + { + return $this->disabled; + } + + public function editable(): bool + { + if ($this->editable === false) { + return false; + } + + if ($this->fields === []) { + return false; + } + + return true; + } + + public function fields(): array + { + return $this->fields; + } + + /** + * Creates a form for the given fields + */ + public function form(array $fields, array $input = []): Form + { + $form = new Form( + fields: $fields, + model: $this->parent, + ); + + $form->fill( + input: $input, + passthrough: false + ); + + return $form; + } + + public function icon(): string|null + { + return $this->icon; + } + + public function label(): string|null + { + return $this->label; + } + + public function model(): ModelWithContent + { + return $this->parent; + } + + public function name(): string + { + return $this->name; + } + + public function preview(): string|bool|null + { + return $this->preview; + } + + public function tabs(): array + { + return $this->tabs; + } + + public function translate(): bool + { + return $this->translate; + } + + public function type(): string + { + return $this->type; + } + + public function toArray(): array + { + return [ + 'disabled' => $this->disabled(), + 'editable' => $this->editable(), + 'icon' => $this->icon(), + 'label' => $this->label(), + 'name' => $this->name(), + 'preview' => $this->preview(), + 'tabs' => $this->tabs(), + 'translate' => $this->translate(), + 'type' => $this->type(), + 'unset' => $this->unset(), + 'wysiwyg' => $this->wysiwyg(), + ]; + } + + public function unset(): bool + { + return $this->unset; + } + + public function wysiwyg(): bool + { + return $this->wysiwyg; + } +} diff --git a/public/kirby/src/Cms/Fieldsets.php b/public/kirby/src/Cms/Fieldsets.php new file mode 100644 index 0000000..397ca41 --- /dev/null +++ b/public/kirby/src/Cms/Fieldsets.php @@ -0,0 +1,115 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\Fieldset> + */ +class Fieldsets extends Items +{ + public const ITEM_CLASS = Fieldset::class; + + /** + * All registered fieldsets methods + */ + public static array $methods = []; + + protected static function createFieldsets(array $params): array + { + $fieldsets = []; + $groups = []; + + foreach ($params as $type => $fieldset) { + if (is_int($type) === true && is_string($fieldset)) { + $type = $fieldset; + $fieldset = 'blocks/' . $type; + } + + if ($fieldset === false) { + continue; + } + + if ($fieldset === true) { + $fieldset = 'blocks/' . $type; + } + + $fieldset = Blueprint::extend($fieldset); + + // make sure the type is always set + $fieldset['type'] ??= $type; + + // extract groups + if ($fieldset['type'] === 'group') { + $result = static::createFieldsets($fieldset['fieldsets'] ?? []); + $fieldsets = [...$fieldsets, ...$result['fieldsets']]; + $label = $fieldset['label'] ?? Str::ucfirst($type); + + $groups[$type] = [ + 'label' => I18n::translate($label, $label), + 'name' => $type, + 'open' => $fieldset['open'] ?? true, + 'sets' => array_column($result['fieldsets'], 'type'), + ]; + } else { + $fieldsets[$fieldset['type']] = $fieldset; + } + } + + return [ + 'fieldsets' => $fieldsets, + 'groups' => $groups + ]; + } + + public static function factory( + array|null $items = null, + array $params = [] + ): static { + $items ??= App::instance()->option('blocks.fieldsets', [ + 'code' => 'blocks/code', + 'gallery' => 'blocks/gallery', + 'heading' => 'blocks/heading', + 'image' => 'blocks/image', + 'line' => 'blocks/line', + 'list' => 'blocks/list', + 'markdown' => 'blocks/markdown', + 'quote' => 'blocks/quote', + 'text' => 'blocks/text', + 'video' => 'blocks/video', + ]); + + $result = static::createFieldsets($items); + + return parent::factory( + $result['fieldsets'], + ['groups' => $result['groups']] + $params + ); + } + + public function groups(): array + { + return $this->options['groups'] ?? []; + } + + public function toArray(Closure|null $map = null): array + { + return A::map( + $this->data, + $map ?? fn ($fieldset) => $fieldset->toArray() + ); + } +} diff --git a/public/kirby/src/Cms/File.php b/public/kirby/src/Cms/File.php new file mode 100644 index 0000000..6bb33d8 --- /dev/null +++ b/public/kirby/src/Cms/File.php @@ -0,0 +1,668 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Files> + * @method \Kirby\Uuid\FileUuid uuid() + */ +class File extends ModelWithContent +{ + use FileActions; + use FileModifications; + use HasMethods; + use HasSiblings; + use IsFile; + + public const CLASS_ALIAS = 'file'; + + /** + * All registered file methods + * @todo Remove when support for PHP 8.2 is dropped + */ + public static array $methods = []; + + /** + * Cache for the initialized blueprint object + */ + protected FileBlueprint|null $blueprint = null; + + protected string $filename; + + protected string $id; + + /** + * The parent object + */ + protected Page|Site|User|null $parent = null; + + /** + * The absolute path to the file + */ + protected string|null $root; + + protected string|null $template; + + /** + * The public file Url + */ + protected string|null $url; + + /** + * Creates a new File object + */ + public function __construct(array $props) + { + if (isset($props['filename'], $props['parent']) === false) { + throw new InvalidArgumentException( + message: 'The filename and parent are required' + ); + } + + $this->filename = $props['filename']; + $this->parent = $props['parent']; + $this->template = $props['template'] ?? null; + // Always set the root to null, to invoke + // auto root detection + $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); + } + + /** + * Magic caller for file methods + * and content fields. (in this order) + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // asset method proxy + if (method_exists($this->asset(), $method)) { + return $this->asset()->$method(...$arguments); + } + + // file methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // content fields + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + */ + public function __debugInfo(): array + { + return [ + ...$this->toArray(), + 'content' => $this->content(), + 'siblings' => $this->siblings(), + ]; + } + + /** + * Returns the url to api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + return $this->parent()->apiUrl($relative) . '/files/' . $this->filename(); + } + + /** + * Returns the FileBlueprint object for the file + */ + public function blueprint(): FileBlueprint + { + return $this->blueprint ??= FileBlueprint::factory( + 'files/' . $this->template(), + 'files/default', + $this + ); + } + + /** + * Returns an array with all blueprints that are available for the file + * by comparing files sections and files fields of the parent model + */ + public function blueprints(string|null $inSection = null): array + { + // get cached results for the current file model + // (except when collecting for a specific section) + if ($inSection === null && $this->blueprints !== null) { + return $this->blueprints; // @codeCoverageIgnore + } + + // always include the current template as option + $templates = [ + $this->template() ?? 'default', + ...$this->parent()->blueprint()->acceptedFileTemplates($inSection) + ]; + + // make sure every template is only included once + $templates = array_unique(array_filter($templates)); + + // load the blueprint details for each collected template name + $blueprints = []; + + foreach ($templates as $template) { + // default template doesn't need to exist as file + // to be included in the list + if ($template === 'default') { + $blueprints[$template] = [ + 'name' => 'default', + 'title' => '– (default)', + ]; + continue; + } + + if ($blueprint = FileBlueprint::factory('files/' . $template, null, $this)) { + try { + // ensure that file matches `accept` option, + // if not remove template from available list + $this->match($blueprint->accept()); + + $blueprints[$template] = [ + 'name' => $name = Str::after($blueprint->name(), '/'), + 'title' => $blueprint->title() . ' (' . $name . ')', + ]; + } catch (Exception) { + // skip when `accept` doesn't match + } + } + } + + $blueprints = array_values($blueprints); + + // sort blueprints alphabetically while + // making sure the default blueprint is on top of list + usort($blueprints, fn ($a, $b) => match (true) { + $a['name'] === 'default' => -1, + $b['name'] === 'default' => 1, + default => strnatcmp($a['title'], $b['title']) + }); + + // no caching for when collecting for specific section + if ($inSection !== null) { + return $blueprints; // @codeCoverageIgnore + } + + return $this->blueprints = $blueprints; + } + + /** + * Store the template in addition to the + * other content. + * @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 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; + } + + /** + * Constructs a File object + */ + public static function factory(array $props): static + { + return new static($props); + } + + /** + * Returns the filename with extension + */ + public function filename(): string + { + return $this->filename; + } + + /** + * Returns the parent Files collection + */ + public function files(): Files + { + return $this->siblingsCollection(); + } + + /** + * Converts the file to html + */ + public function html(array $attr = []): string + { + return $this->asset()->html([ + 'alt' => $this->alt(), + ...$attr + ]); + } + + /** + * Returns the id + */ + public function id(): string + { + if ( + $this->parent() instanceof Page || + $this->parent() instanceof User + ) { + return $this->id ??= $this->parent()->id() . '/' . $this->filename(); + } + + return $this->id ??= $this->filename(); + } + + /** + * Compares the current object with the given file object + */ + public function is(File $file): bool + { + return $this->id() === $file->id(); + } + + /** + * 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 v6 + if ($this->isReadable() === false) { + return false; + } + + return FilePermissions::canFromCache($this, 'access'); + } + + /** + * Check if the file can be listable by the current user + * This permission depends on the `read` option until v6 + */ + public function isListable(): bool + { + // TODO: remove this check when `read` option deprecated in v6 + if ($this->isReadable() === false) { + return false; + } + + // not accessible also means not listable + if ($this->isAccessible() === false) { + return false; + } + + return FilePermissions::canFromCache($this, 'list'); + } + + /** + * Check if the file can be read by the current user + * + * @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()->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 + */ + public function mediaHash(): string + { + return $this->mediaToken() . '-' . $this->modifiedFile(); + } + + /** + * Returns the absolute path to the file in the public media folder + * + * @param string|null $filename Optional override for the filename + */ + public function mediaRoot(string|null $filename = null): string + { + $filename ??= $this->filename(); + + return $this->mediaDir() . '/' . $filename; + } + + /** + * Creates a non-guessable token string for this file + */ + public function mediaToken(): string + { + $token = $this->kirby()->contentToken($this, $this->id()); + return substr($token, 0, 10); + } + + /** + * Returns the absolute Url to the file in the public media folder + * + * @param string|null $filename Optional override for the filename + */ + public function mediaUrl(string|null $filename = null): string + { + $url = $this->parent()->mediaUrl() . '/' . $this->mediaHash(); + $filename ??= $this->filename(); + + return $url . '/' . $filename; + } + + /** + * Get the file's last modification time. + * + * @param string|null $handler date, intl or strftime + */ + public function modified( + string|IntlDateFormatter|null $format = null, + string|null $handler = null, + string|null $languageCode = null + ): string|int|false { + $file = $this->modifiedFile(); + $content = $this->modifiedContent($languageCode); + $modified = max($file, $content); + + return Str::date($modified, $format, $handler); + } + + /** + * Timestamp of the last modification + * of the content file + */ + protected function modifiedContent(string|null $languageCode = null): int + { + return $this->version('latest')->modified($languageCode ?? 'current') ?? 0; + } + + /** + * Timestamp of the last modification + * of the source file + */ + protected function modifiedFile(): int + { + return F::modified($this->root()); + } + + /** + * Returns the parent Page object + */ + public function page(): Page|null + { + if ($this->parent() instanceof Page) { + return $this->parent(); + } + + return null; + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the parent object + */ + public function parent(): Page|Site|User + { + return $this->parent ??= $this->kirby()->site(); + } + + /** + * Returns the parent id if a parent exists + */ + public function parentId(): string + { + return $this->parent()->id(); + } + + /** + * Returns a collection of all parent pages + */ + public function parents(): Pages + { + if ($this->parent() instanceof Page) { + return $this->parent()->parents()->prepend( + $this->parent()->id(), + $this->parent() + ); + } + + return new Pages(); + } + + /** + * Return the permanent URL to the file using its UUID + * @since 3.8.0 + */ + public function permalink(): string|null + { + return $this->uuid()?->toPermalink(); + } + + /** + * Returns the permissions object for this file + */ + public function permissions(): FilePermissions + { + return new FilePermissions($this); + } + + /** + * Returns the absolute root to the file + */ + public function root(): string|null + { + return $this->root ??= $this->parent()->root() . '/' . $this->filename(); + } + + /** + * Returns the FileRules class to + * validate any important action. + */ + protected function rules(): FileRules + { + return new FileRules(); + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array|null $blueprint = null): static + { + if ($blueprint !== null) { + $blueprint['model'] = $this; + $this->blueprint = new FileBlueprint($blueprint); + } + + return $this; + } + + /** + * Returns the parent Files collection + */ + protected function siblingsCollection(): Files + { + return $this->parent()->files(); + } + + /** + * Returns the parent Site object + */ + public function site(): Site + { + if ($this->parent() instanceof Site) { + return $this->parent(); + } + + return $this->kirby()->site(); + } + + /** + * Returns the final template + */ + public function template(): string|null + { + return $this->template ??= $this->content('default')->get('template')->value(); + } + + /** + * Returns siblings with the same template + */ + public function templateSiblings(bool $self = true): Files + { + return $this->siblings($self)->filter('template', $this->template()); + } + + /** + * Extended info for the array export + * by injecting the information from + * the asset. + */ + public function toArray(): array + { + return [ + ...parent::toArray(), + ...$this->asset()->toArray(), + 'id' => $this->id(), + 'template' => $this->template(), + ]; + } + + /** + * Returns the Url + */ + public function url(): string + { + return $this->url ??= ($this->kirby()->component('file::url'))($this->kirby(), $this); + } + + /** + * 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()); + + switch ($parent::CLASS_ALIAS) { + case 'page': + $preview = $parent->blueprint()->preview(); + + // the page has a custom preview setting, + // thus the file is only accessible through + // the direct media URL + if ($preview !== true) { + return $this->url(); + } + + // it's more stable to access files for drafts + // through their direct URL to avoid conflicts + // with draft token verification + if ($parent->isDraft() === true) { + return $this->url(); + } + + // checks `file::url` component is extended + if ($this->kirby()->isNativeComponent('file::url') === false) { + return $this->url(); + } + + 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 new file mode 100644 index 0000000..839da7b --- /dev/null +++ b/public/kirby/src/Cms/FileActions.php @@ -0,0 +1,467 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait FileActions +{ + protected function changeExtension( + File $file, + string|null $extension = null + ): File { + return $file->changeName($file->name(), false, $extension); + } + + /** + * Renames the file (optionally also the extension). + * The store is used to actually execute this. + * + * @throws \Kirby\Exception\LogicException + */ + public function changeName( + string $name, + bool $sanitize = true, + string|null $extension = null + ): static { + if ($sanitize === true) { + // sanitize the basename part only + // as the extension isn't included in $name + $name = F::safeBasename($name, false); + } + + // if no extension is passed, make sure to maintain current one + $extension ??= $this->extension(); + + // don't rename if not necessary + if ( + $name === $this->name() && + $extension === $this->extension() + ) { + return $this; + } + + return $this->commit('changeName', ['file' => $this, 'name' => $name, 'extension' => $extension], function ($oldFile, $name, $extension) { + $newFile = $oldFile->clone([ + 'filename' => $name . '.' . $extension, + ]); + + // remove all public versions, lock and clear UUID cache + $oldFile->unpublish(); + + if ($oldFile->exists() === false) { + return $newFile; + } + + if ($newFile->exists() === true) { + 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 + $oldFile->storage()->moveAll(to: $newFile->storage()); + + // update collections + $newFile->parent()->files()->remove($oldFile->id()); + $newFile->parent()->files()->set($newFile->id(), $newFile); + + return $newFile; + }); + } + + /** + * Changes the file's sorting number in the meta file + */ + public function changeSort(int $sort): static + { + // skip if the sort number stays the same + if ($this->sort()->value() === $sort) { + return $this; + } + + $arguments = [ + 'file' => $this, + 'position' => $sort + ]; + + return $this->commit( + 'changeSort', + $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]); + } + ); + } + + /** + * @return $this|static + */ + public function changeTemplate(string|null $template): static + { + if ($template === $this->template()) { + return $this; + } + + $arguments = [ + 'file' => $this, + 'template' => $template ?? 'default' + ]; + + return $this->commit('changeTemplate', $arguments, function ($oldFile, $template) { + // convert to new template/blueprint incl. content + $file = $oldFile->convertTo($template); + + // resize the file if configured by new blueprint + $create = $file->blueprint()->create(); + $file = $file->manipulate($create); + + return $file; + }); + } + + /** + * Commits a file action, by following these steps + * + * 1. applies the `before` hook + * 2. checks the action rules + * 3. commits the store action + * 4. applies the `after` hook + * 5. returns the result + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + $commit = new ModelCommit( + model: $this, + action: $action + ); + + return $commit->call($arguments, $callback); + } + + /** + * Copy the file to the given page + */ + public function copy(Page $page): static + { + F::copy($this->root(), $page->root() . '/' . $this->filename()); + + $copy = new static([ + 'parent' => $page, + 'filename' => $this->filename(), + ]); + + $this->storage()->copyAll(to: $copy->storage()); + + // overwrite with new UUID (remove old, add new) + if (Uuids::enabled() === true) { + $copy = $copy->save(['uuid' => Uuid::generate()]); + } + + return $copy; + } + + /** + * Creates a new file on disk and returns the + * File object. The store is used to handle file + * writing, so it can be replaced by any other + * way of generating files. + * + * @param bool $move If set to `true`, the source will be deleted + * @throws \Kirby\Exception\InvalidArgumentException + * @throws \Kirby\Exception\LogicException + */ + public static function create(array $props, bool $move = false): static + { + $props = static::normalizeProps($props); + + // create the basic file and a test upload object + $file = File::factory([ + ...$props, + 'content' => null, + 'translations' => null, + ]); + + $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) { + $props['content']['uuid'] ??= Uuid::generate(); + } + + // 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->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 + // are applied + $create = $file->blueprint()->create(); + + // run the hook + $arguments = compact('file', 'upload'); + return $file->commit('create', $arguments, function ($file, $upload) use ($create, $move, $storage) { + // remove all public versions, lock and clear UUID cache + $file->unpublish(); + + // only move the original source if intended + $method = $move === true ? 'move' : 'copy'; + + // overwrite the original + if (F::$method($upload->root(), $file->root(), true) !== true) { + // @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 + $file->changeStorage($storage); + + // return a fresh clone + return $file->clone(); + }); + } + + /** + * Deletes the file. The store is used to + * manipulate the filesystem or whatever you prefer. + */ + public function delete(): bool + { + return $this->commit('delete', ['file' => $this], function ($file) { + $old = $file->clone(); + + // keep the content in iummtable memory storage + // to still have access to it in after hooks + $file->changeStorage(ImmutableMemoryStorage::class); + + // clear UUID cache + $file->uuid()?->clear(); + + // 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; + }); + } + + /** + * Resizes/crops the original file with Kirby's thumb handler + */ + public function manipulate(array|null $options = []): static + { + // nothing to process + if (empty($options) === true || $this->isResizable() === false) { + return $this; + } + + // generate image file and overwrite it in place + $this->kirby()->thumb($this->root(), $this->root(), $options); + + $file = $this->clone(); + + // change the file extension if format option configured + if ($format = $options['format'] ?? null) { + $file = $file->changeExtension($file, $format); + } + + 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. + * + * @return $this + */ + public function publish(): static + { + Media::publish($this, $this->mediaRoot()); + return $this; + } + + /** + * Replaces the file. The source must + * be an absolute path to a file or a Url. + * The store handles the replacement so it + * finally decides what it will support as + * source. + * + * @param bool $move If set to `true`, the source will be deleted + * @throws \Kirby\Exception\LogicException + */ + public function replace(string $source, bool $move = false): static + { + $file = $this->clone(); + + $arguments = [ + 'file' => $file, + 'upload' => $file->asset($source) + ]; + + return $this->commit('replace', $arguments, function ($file, $upload) use ($move) { + // delete all public versions + $file->unpublish(true); + + // only move the original source if intended + $method = $move === true ? 'move' : 'copy'; + + // overwrite the original + if (F::$method($upload->root(), $file->root(), true) !== true) { + throw new LogicException( + message: 'The file could not be created' + ); + } + + // apply the resizing/crop options from the blueprint + $create = $file->blueprint()->create(); + $file = $file->manipulate($create); + + // return a fresh clone + return $file->clone(); + }); + } + + /** + * Remove all public versions of this file + * + * @return $this + */ + public function unpublish(bool $onlyMedia = false): static + { + // unpublish media files + Media::unpublish($this->parent()->mediaRoot(), $this); + + if ($onlyMedia !== true) { + // clear UUID cache + $this->uuid()?->clear(); + } + + return $this; + } + + /** + * Updates the file's data and ensures that + * media files get wiped if `focus` changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values + */ + public function update( + array|null $input = null, + string|null $languageCode = null, + bool $validate = false + ): static { + // delete all public media versions when focus field gets changed + if (($input['focus'] ?? null) !== $this->focus()->value()) { + $this->unpublish(true); + } + + return parent::update($input, $languageCode, $validate); + } +} diff --git a/public/kirby/src/Cms/FileBlueprint.php b/public/kirby/src/Cms/FileBlueprint.php new file mode 100644 index 0000000..b85ef44 --- /dev/null +++ b/public/kirby/src/Cms/FileBlueprint.php @@ -0,0 +1,254 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class FileBlueprint extends Blueprint +{ + /** + * `true` if the default accepted + * types are being used + */ + protected bool $defaultTypes = false; + + public function __construct(array $props) + { + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'access' => null, + 'changeName' => null, + 'changeTemplate' => null, + 'create' => null, + 'delete' => null, + 'list' => null, + 'read' => null, + 'replace' => null, + 'sort' => null, + 'update' => null, + ] + ); + + // normalize the accept settings + $this->props['accept'] = $this->normalizeAccept($this->props['accept'] ?? []); + } + + public function accept(): array + { + return $this->props['accept']; + } + + /** + * Returns the list of all accepted MIME types for + * file upload or `*` if all MIME types are allowed + * + * @deprecated 4.2.0 Use `acceptAttribute` instead + * @todo 6.0.0 Remove method + */ + public function acceptMime(): string + { + // don't disclose the specific default types + if ($this->defaultTypes === true) { + return '*'; + } + + $accept = $this->accept(); + $restrictions = []; + + if (is_array($accept['mime']) === true) { + $restrictions[] = $accept['mime']; + } else { + // only fall back to the extension or type if + // no explicit MIME types were defined + // (allows to set custom MIME types for the frontend + // check but still restrict the extension and/or type) + + if (is_array($accept['extension']) === true) { + // determine the main MIME type for each extension + $restrictions[] = array_map( + Mime::fromExtension(...), + $accept['extension'] + ); + } + + if (is_array($accept['type']) === true) { + // determine the MIME types of each file type + $mimes = []; + foreach ($accept['type'] as $type) { + if ($extensions = F::typeToExtensions($type)) { + $mimes[] = array_map( + Mime::fromExtension(...), + $extensions + ); + } + } + + $restrictions[] = array_merge(...$mimes); + } + } + + if ($restrictions !== []) { + // 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))); + } + + // no restrictions, accept everything + return '*'; + } + + /** + * Returns the list of all accepted file extensions + * for file upload or `*` if all extensions are allowed + * + * If a MIME type is specified in the blueprint, the `extension` and `type` options are ignored for the browser. + * Extensions and types, however, are still used to validate an uploaded file on the server. + * This behavior might change in the future to better represent which file extensions are actually allowed. + * + * If no MIME type is specified, the intersection between manually defined extensions and the Kirby "file types" is returned. + * If the intersection is empty, an empty string is returned. + * This behavior might change in the future to instead return the union of `mime`, `extension` and `type`. + * + * @since 4.2.0 + */ + public function acceptAttribute(): string + { + // don't disclose the specific default types + if ($this->defaultTypes === true) { + return '*'; + } + + $accept = $this->accept(); + + // get extensions from "mime" option + if (is_array($accept['mime']) === true) { + // determine the extensions for each MIME type + $extensions = array_map( + fn ($pattern) => Mime::toExtensions($pattern, true), + $accept['mime'] + ); + + $fromMime = array_unique(array_merge(...array_values($extensions))); + + // return early to ignore the other options + return implode(',', array_map(fn ($ext) => ".$ext", $fromMime)); + } + + $restrictions = []; + + // get extensions from "type" option + if (is_array($accept['type']) === true) { + $extensions = array_map( + fn ($type) => F::typeToExtensions($type) ?? [], + $accept['type'] + ); + + $fromType = array_merge(...array_values($extensions)); + $restrictions[] = $fromType; + } + + // get extensions from "extension" option + if (is_array($accept['extension']) === true) { + $restrictions[] = $accept['extension']; + } + + // intersect all restrictions + $list = match (count($restrictions)) { + 0 => [], + 1 => $restrictions[0], + default => array_intersect(...$restrictions) + }; + + $list = array_unique($list); + + // format the list to include a leading dot on each extension + return implode(',', array_map(fn ($ext) => ".$ext", $list)); + } + + protected function normalizeAccept(mixed $accept = null): array + { + $accept = match (true) { + is_string($accept) => ['mime' => $accept], + // explicitly no restrictions at all + $accept === true => ['mime' => null], + // no custom restrictions + empty($accept) === true => [], + // custom restrictions + default => $accept + }; + + $accept = array_change_key_case($accept); + + $defaults = [ + 'extension' => null, + 'mime' => null, + 'maxheight' => null, + 'maxsize' => null, + 'maxwidth' => null, + 'minheight' => null, + 'minsize' => null, + 'minwidth' => null, + 'orientation' => null, + 'type' => null + ]; + + // default type restriction if none are configured; + // this ensures that no unexpected files are uploaded + if ( + array_key_exists('mime', $accept) === false && + array_key_exists('extension', $accept) === false && + array_key_exists('type', $accept) === false + ) { + $defaults['type'] = ['image', 'document', 'archive', 'audio', 'video']; + $this->defaultTypes = true; + } + + $accept = [...$defaults, ...$accept]; + + // normalize the MIME, extension and type from strings into arrays + if (is_string($accept['mime']) === true) { + $accept['mime'] = array_map( + fn ($mime) => $mime['value'], + Str::accepted($accept['mime']) + ); + } + + if (is_string($accept['extension']) === true) { + $accept['extension'] = array_map( + 'trim', + explode(',', $accept['extension']) + ); + } + + if (is_string($accept['type']) === true) { + $accept['type'] = array_map( + 'trim', + explode(',', $accept['type']) + ); + } + + return $accept; + } +} diff --git a/public/kirby/src/Cms/FileModifications.php b/public/kirby/src/Cms/FileModifications.php new file mode 100644 index 0000000..b07de97 --- /dev/null +++ b/public/kirby/src/Cms/FileModifications.php @@ -0,0 +1,217 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait FileModifications +{ + /** + * Blurs the image by the given amount of pixels + */ + public function blur(int|bool $pixels = true): FileVersion|File|Asset + { + return $this->thumb(['blur' => $pixels]); + } + + /** + * Converts the image to black and white + */ + public function bw(): FileVersion|File|Asset + { + return $this->thumb(['grayscale' => true]); + } + + /** + * Crops the image by the given width and height + */ + public function crop( + int $width, + int|null $height = null, + $options = null + ): FileVersion|File|Asset { + $quality = null; + $crop = true; + + if (is_int($options) === true) { + $quality = $options; + } elseif (is_string($options)) { + $crop = $options; + } elseif ($options instanceof Field) { + $crop = $options->value(); + } elseif (is_array($options)) { + $quality = $options['quality'] ?? $quality; + $crop = $options['crop'] ?? $crop; + } + + return $this->thumb([ + 'width' => $width, + 'height' => $height, + 'quality' => $quality, + 'crop' => $crop + ]); + } + + /** + * Alias for File::bw() + */ + public function grayscale(): FileVersion|File|Asset + { + return $this->thumb(['grayscale' => true]); + } + + /** + * Alias for File::bw() + */ + public function greyscale(): FileVersion|File|Asset + { + return $this->thumb(['grayscale' => true]); + } + + /** + * Sets the JPEG compression quality + */ + public function quality(int $quality): FileVersion|File|Asset + { + return $this->thumb(['quality' => $quality]); + } + + /** + * Resizes the file with the given width and height + * while keeping the aspect ratio. + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function resize( + int|null $width = null, + int|null $height = null, + int|null $quality = null + ): FileVersion|File|Asset { + return $this->thumb([ + 'width' => $width, + 'height' => $height, + 'quality' => $quality + ]); + } + + /** + * Sharpens the image + */ + public function sharpen(int $amount = 50): FileVersion|File|Asset + { + return $this->thumb(['sharpen' => $amount]); + } + + /** + * Create a srcset definition for the given sizes + * Sizes can be defined as a simple array. They can + * also be set up in the config with the thumbs.srcsets option. + * @since 3.1.0 + */ + public function srcset(array|string|null $sizes = null): string|null + { + if (empty($sizes) === true) { + $sizes = $this->kirby()->option('thumbs.srcsets.default', []); + } + + if (is_string($sizes) === true) { + $sizes = $this->kirby()->option('thumbs.srcsets.' . $sizes, []); + } + + if (is_array($sizes) === false || $sizes === []) { + return null; + } + + $set = []; + + foreach ($sizes as $key => $value) { + if (is_array($value)) { + $options = $value; + $condition = $key; + } elseif (is_string($value) === true) { + $options = [ + 'width' => $key + ]; + $condition = $value; + } else { + $options = [ + 'width' => $value + ]; + $condition = $value . 'w'; + } + + $set[] = $this->thumb($options)->url() . ' ' . $condition; + } + + return implode(', ', $set); + } + + /** + * Creates a modified version of images + * The media manager takes care of generating + * those modified versions and putting them + * in the right place. This is normally the + * `/media` folder of your installation, but + * could potentially also be a CDN or any other + * place. + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function thumb( + array|string|null $options = null + ): FileVersion|File|Asset { + // thumb presets + if (empty($options) === true) { + $options = $this->kirby()->option('thumbs.presets.default'); + } elseif (is_string($options) === true) { + $options = $this->kirby()->option('thumbs.presets.' . $options); + } + + if (empty($options) === true || is_array($options) === false) { + return $this; + } + + // fallback to content file options + if (($options['crop'] ?? false) === true) { + $options['crop'] = match (true) { + $this instanceof ModelWithContent + => $this->focus()->value() ?? 'center', + default + => 'center' + }; + } + + // fallback to global config options + if (isset($options['format']) === false) { + if ($format = $this->kirby()->option('thumbs.format')) { + $options['format'] = $format; + } + } + + $component = $this->kirby()->component('file::version'); + $result = $component($this->kirby(), $this, $options); + + if ( + $result instanceof FileVersion === false && + $result instanceof File === false && + $result instanceof Asset === false + ) { + 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 new file mode 100644 index 0000000..6c7eee8 --- /dev/null +++ b/public/kirby/src/Cms/FilePermissions.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class FilePermissions extends ModelPermissions +{ + 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 + { + if (count($this->model->blueprints()) <= 1) { + return false; + } + + return true; + } +} diff --git a/public/kirby/src/Cms/FilePicker.php b/public/kirby/src/Cms/FilePicker.php new file mode 100644 index 0000000..0ed91bf --- /dev/null +++ b/public/kirby/src/Cms/FilePicker.php @@ -0,0 +1,76 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class FilePicker extends Picker +{ + /** + * Extends the basic defaults + */ + public function defaults(): array + { + return [ + ...parent::defaults(), + 'text' => '{{ file.filename }}' + ]; + } + + /** + * Search all files for the picker + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function items(): Files|null + { + $model = $this->options['model']; + + // find the right default query + $query = match (true) { + empty($this->options['query']) === false + => $this->options['query'], + $model instanceof File + => 'file.siblings', + default + => $model::CLASS_ALIAS . '.files' + }; + + // fetch all files for the picker + $files = $model->query($query); + + // help mitigate some typical query usage issues + // by converting site and page objects to proper + // pages by returning their children + $files = match (true) { + $files instanceof Site, + $files instanceof Page, + $files instanceof User => $files->files(), + $files instanceof Files => $files, + + default => throw new InvalidArgumentException( + message: 'Your query must return a set of files' + ) + }; + + // filter protected and hidden pages + $files = $files->filter('isListable', true); + + // search + $files = $this->search($files); + + // paginate + return $this->paginate($files); + } +} diff --git a/public/kirby/src/Cms/FileRules.php b/public/kirby/src/Cms/FileRules.php new file mode 100644 index 0000000..72149f5 --- /dev/null +++ b/public/kirby/src/Cms/FileRules.php @@ -0,0 +1,335 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class FileRules +{ + /** + * Validates if the filename can be changed + * + * @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): void + { + 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' + ); + } + + $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()] + ); + } + } + + /** + * Validates if the file can be sorted + */ + public static function changeSort(File $file, int $sort): void + { + if ($file->permissions()->can('sort') !== true) { + throw new PermissionException( + key: 'file.sort.permission', + data: ['filename' => $file->filename()] + ); + } + } + + /** + * Validates if the template of the file can be changed + * + * @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): void + { + if ($file->permissions()->can('changeTemplate') !== true) { + throw new PermissionException( + key: 'file.changeTemplate.permission', + data: ['id' => $file->id()] + ); + } + + $blueprints = $file->blueprints(); + + // ensure that the $template is a valid blueprint + // option for this file + if ( + count($blueprints) <= 1 || + in_array($template, array_column($blueprints, 'name'), true) === false + ) { + throw new LogicException( + key: 'file.changeTemplate.invalid', + data: [ + 'id' => $file->id(), + 'template' => $template, + 'blueprints' => implode(', ', array_column($blueprints, 'name')) + ] + ); + } + } + + /** + * Validates if the file can be created + * + * @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): void + { + // We want to ensure that we are not creating duplicate files. + // If a file with the same name already exists + if ($file->exists() === true) { + // $file will be based on the props of the new file, + // to compare templates, we need to get the props of + // the already existing file from meta content file + $existing = $file->parent()->file($file->filename()); + + // if the new upload is the exact same file + // and uses the same template, we can continue + if ( + $file->sha1() === $upload->sha1() && + $file->template() === $existing->template() + ) { + return; + } + + // otherwise throw an error for duplicate file + throw new DuplicateException( + key: 'file.duplicate', + data: [ + 'filename' => $file->filename() + ] + ); + } + + 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); + } + + /** + * Validates if the file can be deleted + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the file + */ + public static function delete(File $file): void + { + if ($file->permissions()->can('delete') !== true) { + throw new PermissionException( + message: 'The file cannot be deleted' + ); + } + } + + /** + * Validates if the file can be replaced + * + * @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): void + { + if ($file->permissions()->can('replace') !== true) { + throw new PermissionException( + message: 'The file cannot be replaced' + ); + } + + static::validMime($file, $upload->mime()); + + if ( + (string)$upload->mime() !== (string)$file->mime() && + (string)$upload->extension() !== (string)$file->extension() + ) { + throw new InvalidArgumentException( + key: 'file.mime.differs', + data: ['mime' => $file->mime()] + ); + } + + $upload->match($file->blueprint()->accept()); + $upload->validateContents(true); + } + + /** + * Validates if the file can be updated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the file + */ + public static function update(File $file, array $content = []): void + { + if ($file->permissions()->can('update') !== true) { + throw new PermissionException( + message: 'The file cannot be updated' + ); + } + } + + /** + * Validates the file extension + * + * @throws \Kirby\Exception\InvalidArgumentException If the extension is missing or forbidden + */ + 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()] + ); + } + + if ( + Str::contains($extension, 'php') !== false || + Str::contains($extension, 'phar') !== false || + Str::contains($extension, 'pht') !== false + ) { + 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'] + ); + } + + if (V::in($extension, ['exe', App::instance()->contentExtension()]) !== false) { + throw new InvalidArgumentException( + key: 'file.extension.forbidden', + data: ['extension' => $extension] + ); + } + } + + /** + * Validates the extension, MIME type and filename + * + * @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 + ): void { + // request to skip the MIME check for performance reasons + if ($mime !== false) { + static::validMime($file, $mime ?? $file->mime()); + } + + static::validExtension($file, $file->extension()); + static::validFilename($file, $file->filename()); + } + + /** + * Validates the filename + * + * @throws \Kirby\Exception\InvalidArgumentException If the filename is missing or forbidden + */ + 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' + ); + } + + // Block htaccess files + if (Str::startsWith($filename, '.ht')) { + 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'] + ); + } + } + + /** + * Validates the MIME type + * + * @throws \Kirby\Exception\InvalidArgumentException If the MIME type is missing or forbidden + */ + 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()] + ); + } + + if (Str::contains($mime, '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] + ); + } + } +} diff --git a/public/kirby/src/Cms/FileVersion.php b/public/kirby/src/Cms/FileVersion.php new file mode 100644 index 0000000..77db1a9 --- /dev/null +++ b/public/kirby/src/Cms/FileVersion.php @@ -0,0 +1,121 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class FileVersion +{ + use IsFile; + + protected array $modifications; + protected File|Asset $original; + + public function __construct(array $props) + { + $this->root = $props['root'] ?? null; + $this->url = $props['url'] ?? null; + $this->original = $props['original']; + $this->modifications = $props['modifications'] ?? []; + } + + /** + * Proxy for public properties, asset methods + * and content field getters + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // asset method proxy + if (method_exists($this->asset(), $method)) { + if ($this->exists() === false) { + $this->save(); + } + + return $this->asset()->$method(...$arguments); + } + + // content fields + if ($this->original() instanceof File) { + return $this->original()->content()->get($method); + } + } + + /** + * Returns the unique ID + */ + public function id(): string + { + return dirname($this->original()->id()) . '/' . $this->filename(); + } + + /** + * Returns the parent Kirby App instance + */ + public function kirby(): App + { + return $this->original()->kirby(); + } + + /** + * Returns an array with all applied modifications + */ + public function modifications(): array + { + return $this->modifications; + } + + /** + * Returns the instance of the original File object + */ + public function original(): mixed + { + return $this->original; + } + + /** + * Applies the stored modifications and + * saves the file on disk + * + * @return $this + */ + public function save(): static + { + $this->kirby()->thumb( + $this->original()->root(), + $this->root(), + $this->modifications() + ); + return $this; + } + + + /** + * Converts the object to an array + */ + public function toArray(): array + { + $array = [ + ...$this->asset()->toArray(), + 'modifications' => $this->modifications() + ]; + + ksort($array); + + return $array; + } +} diff --git a/public/kirby/src/Cms/Files.php b/public/kirby/src/Cms/Files.php new file mode 100644 index 0000000..2561dd3 --- /dev/null +++ b/public/kirby/src/Cms/Files.php @@ -0,0 +1,226 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @template TFile of \Kirby\Cms\File + * @extends \Kirby\Cms\Collection + */ +class Files extends Collection +{ + use HasUuids; + + /** + * All registered files methods + */ + 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|TFile|string $object + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException When no `File` or `Files` object or an ID of an existing file is passed + */ + public function add($object): static + { + // add a files collection + if ($object instanceof self) { + $this->data = [...$this->data, ...$object->data]; + + // add a file by id + } elseif ( + is_string($object) === true && + $file = App::instance()->file($object) + ) { + $this->__set($file->id(), $file); + + // 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 + } elseif (in_array($object, [null, false, true], true) !== true) { + 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; + } + + /** + * Sort all given files by the + * order in the array + * + * @param array $files List of file ids + * @param int $offset Sorting offset + * @return $this + */ + public function changeSort(array $files, int $offset = 0): static + { + foreach ($files as $filename) { + if ($file = $this->get($filename)) { + $offset++; + $file->changeSort($offset); + } + } + + 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 { + $collection = new static([], $parent); + + foreach ($files as $props) { + $props['collection'] = $collection; + $props['parent'] = $parent; + + $file = File::factory($props); + + $collection->data[$file->id()] = $file; + } + + return $collection; + } + + /** + * Finds a file by its filename + * @internal Use `$files->find()` instead + * @return TFile|null + */ + public function findByKey(string $key): File|null + { + if ($file = $this->findByUuid($key, 'file')) { + return $file; + } + + return $this->get(ltrim($this->parent?->id() . '/' . $key, '/')); + } + + /** + * Returns the file size for all + * files in the collection in a + * human-readable format + * @since 3.6.0 + * + * @param string|null|false $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public function niceSize(string|false|null $locale = null): string + { + return F::niceSize($this->size(), $locale); + } + + /** + * Returns the raw size for all + * files in the collection + * @since 3.6.0 + */ + public function size(): int + { + return F::size($this->values(fn ($file) => $file->root())); + } + + /** + * Returns the collection sorted by + * the sort number and the filename + * @return \Kirby\Cms\Files + */ + public function sorted(): static + { + return $this->sort('sort', 'asc', 'filename', 'asc'); + } + + /** + * Filter all files by the given template + * + * @return $this|static + */ + public function template(string|array|null $template): static + { + if (empty($template) === true) { + return $this; + } + + if ($template === 'default') { + $template = ['default', '']; + } + + return $this->filter( + 'template', + is_array($template) ? 'in' : '==', + $template + ); + } +} diff --git a/public/kirby/src/Cms/Find.php b/public/kirby/src/Cms/Find.php new file mode 100644 index 0000000..046a749 --- /dev/null +++ b/public/kirby/src/Cms/Find.php @@ -0,0 +1,172 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Find +{ + /** + * Returns the file object for the given + * parent path and filename + * + * @param string $path Path to file's parent model + * @throws \Kirby\Exception\NotFoundException if the file cannot be found + */ + public static function file( + string $path, + string $filename + ): File|null { + $filename = urldecode($filename); + $parent = empty($path) ? null : static::parent($path); + $file = App::instance()->file($filename, $parent); + + if ($file?->isAccessible() === true) { + return $file; + } + + throw new NotFoundException( + key: 'file.notFound', + data: ['filename' => $filename] + ); + } + + /** + * Returns the language object for the given code + * + * @param string $code Language code + * @throws \Kirby\Exception\NotFoundException if the language cannot be found + */ + public static function language(string $code): Language|null + { + if ($language = App::instance()->language($code)) { + return $language; + } + + throw new NotFoundException( + key: 'language.notFound', + data: ['code' => $code] + ); + } + + /** + * Returns the page object for the given id + * + * @param string $id Page's id + * @throws \Kirby\Exception\NotFoundException if the page cannot be found + */ + public static function page(string $id): Page|null + { + // decode API ID encoding + $id = str_replace(['+', ' '], '/', $id); + $kirby = App::instance(); + $page = $kirby->page($id, null, true); + + if ($page?->isAccessible() === true) { + return $page; + } + + throw new NotFoundException( + key: 'page.notFound', + data: ['slug' => $id] + ); + } + + /** + * Returns the model's object for the given path + * + * @param string $path Path to parent model + * @throws \Kirby\Exception\InvalidArgumentException if the model type is invalid + * @throws \Kirby\Exception\NotFoundException if the model cannot be found + */ + public static function parent(string $path): ModelWithContent + { + $path = trim($path, '/'); + $modelType = match ($path) { + 'site', 'account' => $path, + default => trim(dirname($path), '/') + }; + $modelTypes = [ + 'site' => 'site', + 'users' => 'user', + 'pages' => 'page', + 'account' => 'account' + ]; + + $modelName = $modelTypes[$modelType] ?? null; + + if (Str::endsWith($modelType, '/files') === true) { + $modelName = 'file'; + } + + $kirby = App::instance(); + + $model = match ($modelName) { + 'site' => $kirby->site(), + 'account' => static::user(), + 'page' => static::page(basename($path)), + // regular expression to split the path at the last + // occurrence of /files/ which separates parent path + // and filename + 'file' => static::file(...preg_split('$.*\K(/files/)$', $path)), + 'user' => $kirby->user(basename($path)), + default => throw new InvalidArgumentException( + message: 'Invalid model type: ' . $modelType + ) + }; + + return $model ?? throw new NotFoundException( + key: $modelName . '.undefined' + ); + } + + /** + * Returns the user object for the given id or + * 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 static function user(string|null $id = null): User|null + { + // account is a reserved word to find the current + // user. It's used in various API and area routes. + if ($id === 'account') { + $id = null; + } + + $kirby = App::instance(); + + // get the authenticated user + if ($id === null) { + $user = $kirby->user( + null, + $kirby->option('api.allowImpersonation', false) + ); + + 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] + ); + } +} diff --git a/public/kirby/src/Cms/HasChildren.php b/public/kirby/src/Cms/HasChildren.php new file mode 100644 index 0000000..d12ee38 --- /dev/null +++ b/public/kirby/src/Cms/HasChildren.php @@ -0,0 +1,201 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait HasChildren +{ + /** + * The list of available published children + */ + public Pages|null $children = null; + + /** + * The list of available draft children + */ + public Pages|null $drafts = null; + + /** + * The combined list of available published + * and draft children + */ + public Pages|null $childrenAndDrafts = null; + + /** + * Returns all published children + */ + public function children(): Pages + { + return $this->children ??= Pages::factory($this->inventory()['children'], $this); + } + + /** + * Returns all published and draft children at the same time + */ + public function childrenAndDrafts(): Pages + { + return $this->childrenAndDrafts ??= $this->children()->merge($this->drafts()); + } + + /** + * Searches for a draft child by ID + */ + public function draft(string $path): Page|null + { + $path = str_replace('_drafts/', '', $path); + + if (Str::contains($path, '/') === false) { + return $this->drafts()->find($path); + } + + $parts = explode('/', $path); + $parent = $this; + + foreach ($parts as $slug) { + if ($page = $parent->find($slug)) { + $parent = $page; + continue; + } + + if ($draft = $parent->drafts()->find($slug)) { + $parent = $draft; + continue; + } + + return null; + } + + return $parent; + } + + /** + * Returns all draft children + */ + public function drafts(): Pages + { + if ($this->drafts instanceof Pages) { + return $this->drafts; + } + + $kirby = $this->kirby(); + + // create the inventory for all drafts + $inventory = Dir::inventory( + $this->root() . '/_drafts', + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + + return $this->drafts = Pages::factory($inventory['children'], $this, true); + } + + /** + * Finds one or multiple published children by ID + */ + public function find(string|array ...$arguments): Page|Pages|null + { + return $this->children()->find(...$arguments); + } + + /** + * Finds a single published or draft child + */ + public function findPageOrDraft(string $path): Page|null + { + return $this->children()->find($path) ?? $this->drafts()->find($path); + } + + /** + * Returns a collection of all published children of published children + */ + public function grandChildren(): Pages + { + return $this->children()->children(); + } + + /** + * Checks if the model has any published children + */ + public function hasChildren(): bool + { + return $this->children()->count() > 0; + } + + /** + * Checks if the model has any draft children + */ + public function hasDrafts(): bool + { + return $this->drafts()->count() > 0; + } + + /** + * Checks if the page has any listed children + */ + public function hasListedChildren(): bool + { + return $this->children()->listed()->count() > 0; + } + + /** + * Checks if the page has any unlisted children + */ + public function hasUnlistedChildren(): bool + { + return $this->children()->unlisted()->count() > 0; + } + + /** + * Creates a flat child index + * + * @param bool $drafts If set to `true`, draft children are included + */ + public function index(bool $drafts = false): Pages + { + if ($drafts === true) { + return $this->childrenAndDrafts()->index($drafts); + } + + return $this->children()->index(); + } + + /** + * Sets the published children collection + * + * @return $this + */ + protected function setChildren(array|null $children = null): static + { + if ($children !== null) { + $this->children = Pages::factory($children, $this); + } + + return $this; + } + + /** + * Sets the draft children collection + * + * @return $this + */ + protected function setDrafts(array|null $drafts = null): static + { + if ($drafts !== null) { + $this->drafts = Pages::factory($drafts, $this, true); + } + + return $this; + } +} diff --git a/public/kirby/src/Cms/HasFiles.php b/public/kirby/src/Cms/HasFiles.php new file mode 100644 index 0000000..9553db6 --- /dev/null +++ b/public/kirby/src/Cms/HasFiles.php @@ -0,0 +1,190 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait HasFiles +{ + /** + * The Files collection + */ + protected Files|array|null $files = null; + + /** + * Filters the Files collection by type audio + */ + public function audio(): Files + { + return $this->files()->filter('type', '==', 'audio'); + } + + /** + * Filters the Files collection by type code + */ + public function code(): Files + { + return $this->files()->filter('type', '==', 'code'); + } + + /** + * Creates a new file + * + * @param bool $move If set to `true`, the source will be deleted + */ + public function createFile(array $props, bool $move = false): File + { + $props = [ + ...$props, + 'parent' => $this, + 'url' => null + ]; + + return File::create($props, $move); + } + + /** + * Filters the Files collection by type documents + */ + public function documents(): Files + { + return $this->files()->filter('type', '==', 'document'); + } + + /** + * Returns a specific file by filename or the first one + */ + public function file( + string|null $filename = null, + string $in = 'files' + ): File|null { + if ($filename === null) { + return $this->$in()->first(); + } + + // find by global UUID + if (Uuid::is($filename, 'file') === true) { + return Uuid::for($filename, $this->$in())->model(); + } + + if (str_contains($filename, '/') === true) { + $path = dirname($filename); + $filename = basename($filename); + + if ($page = $this->find($path)) { + return $page->$in()->find($filename); + } + + return null; + } + + return $this->$in()->find($filename); + } + + /** + * Returns the Files collection + */ + public function files(): Files + { + if ($this->files instanceof Files) { + return $this->files; + } + + return $this->files = Files::factory($this->inventory()['files'], $this); + } + + /** + * Checks if the Files collection has any audio files + */ + public function hasAudio(): bool + { + return $this->audio()->count() > 0; + } + + /** + * Checks if the Files collection has any code files + */ + public function hasCode(): bool + { + return $this->code()->count() > 0; + } + + /** + * Checks if the Files collection has any document files + */ + public function hasDocuments(): bool + { + return $this->documents()->count() > 0; + } + + /** + * Checks if the Files collection has any files + */ + public function hasFiles(): bool + { + return $this->files()->count() > 0; + } + + /** + * Checks if the Files collection has any images + */ + public function hasImages(): bool + { + return $this->images()->count() > 0; + } + + /** + * Checks if the Files collection has any videos + */ + public function hasVideos(): bool + { + return $this->videos()->count() > 0; + } + + /** + * Returns a specific image by filename or the first one + */ + public function image(string|null $filename = null): File|null + { + return $this->file($filename, 'images'); + } + + /** + * Filters the Files collection by type image + */ + public function images(): Files + { + return $this->files()->filter('type', '==', 'image'); + } + + /** + * Sets the Files collection + * + * @return $this + */ + protected function setFiles(array|null $files = null): static + { + if ($files !== null) { + $this->files = Files::factory($files, $this); + } + + return $this; + } + + /** + * Filters the Files collection by type videos + */ + public function videos(): Files + { + return $this->files()->filter('type', '==', 'video'); + } +} diff --git a/public/kirby/src/Cms/HasMethods.php b/public/kirby/src/Cms/HasMethods.php new file mode 100644 index 0000000..282969f --- /dev/null +++ b/public/kirby/src/Cms/HasMethods.php @@ -0,0 +1,70 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait HasMethods +{ + /** + * All registered methods + */ + public static array $methods = []; + + /** + * Calls a registered method class with the + * passed arguments + * + * @throws \Kirby\Exception\BadMethodCallException + */ + protected function callMethod(string $method, array $args = []): mixed + { + $closure = $this->getMethod($method); + + if ($closure === null) { + throw new BadMethodCallException( + message: 'The method ' . $method . ' does not exist' + ); + } + + return $closure->call($this, ...$args); + } + + /** + * Checks if the object has a registered custom method + */ + public function hasMethod(string $method): bool + { + return $this->getMethod($method) !== null; + } + + /** + * Returns a registered method by name, either from + * the current class or from a parent class ordered by + * inheritance order (top to bottom) + */ + protected function getMethod(string $method): Closure|null + { + if (isset(static::$methods[$method]) === true) { + return static::$methods[$method]; + } + + foreach (class_parents($this) as $parent) { + if (isset($parent::$methods[$method]) === true) { + return $parent::$methods[$method]; + } + } + + return null; + } +} 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 new file mode 100644 index 0000000..7086b08 --- /dev/null +++ b/public/kirby/src/Cms/HasSiblings.php @@ -0,0 +1,159 @@ + + * @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 TCollection|null $collection + */ + 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 TCollection|null $collection + * @return static|null + */ + public function next($collection = null) + { + $collection ??= $this->siblingsCollection(); + return $collection->nth($this->indexOf($collection) + 1); + } + + /** + * Returns the end of the collection starting after the current item + * + * @param TCollection|null $collection + * @return TCollection + */ + public function nextAll(Collection|null $collection = null): Collection + { + $collection ??= $this->siblingsCollection(); + return $collection->slice($this->indexOf($collection) + 1); + } + + /** + * Returns the previous 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 TCollection|null $collection + * @return static|null + */ + public function prev(Collection|null $collection = null) + { + $collection ??= $this->siblingsCollection(); + return $collection->nth($this->indexOf($collection) - 1); + } + + /** + * Returns the beginning of the collection before the current item + * + * @param TCollection|null $collection + * @return TCollection + */ + public function prevAll(Collection|null $collection = null): Collection + { + $collection ??= $this->siblingsCollection(); + return $collection->slice(0, $this->indexOf($collection)); + } + + /** + * Returns all sibling elements + * + * @return TCollection + */ + public function siblings(bool $self = true): Collection + { + $siblings = $this->siblingsCollection(); + + if ($self === false) { + return $siblings->not($this); + } + + return $siblings; + } + + /** + * Returns the collection of siblings + * @return TCollection + */ + abstract protected function siblingsCollection(): Collection; +} diff --git a/public/kirby/src/Cms/Helpers.php b/public/kirby/src/Cms/Helpers.php new file mode 100644 index 0000000..cdc7699 --- /dev/null +++ b/public/kirby/src/Cms/Helpers.php @@ -0,0 +1,214 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Helpers +{ + /** + * Allows to disable specific deprecation warnings + * by setting them to `false`. + * You can do this by putting the following code in + * `site/config/config.php`: + * + * ```php + * Helpers::$deprecations[''] = false; + * ``` + */ + public static array $deprecations = [ + // The internal `$model->contentFile*()` methods have been deprecated + 'model-content-file' => true, + + // Passing an `info` array inside the `extends` array + // has been deprecated. Pass the individual entries (e.g. root, version) + // directly as named arguments. + // TODO: switch to true in v6 + 'plugin-extends-root' => false, + + // 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 + ]; + + /** + * Triggers a deprecation warning if debug mode is active + * and warning has not been surpressed via `Helpers::$deprecations` + * + * @param string|null $key If given, the key will be checked against the static array + * @return bool Whether the warning was triggered + */ + public static function deprecated( + string $message, + string|null $key = null + ): bool { + // only trigger warning in debug mode or when running PHPUnit tests + // @codeCoverageIgnoreStart + if ( + App::instance()->option('debug') !== true && + (defined('KIRBY_TESTING') !== true || KIRBY_TESTING !== true) + ) { + return false; + } + // @codeCoverageIgnoreEnd + + // don't trigger the warning if disabled by default or by the dev + if ($key !== null && (static::$deprecations[$key] ?? true) === false) { + return false; + } + + return trigger_error($message, E_USER_DEPRECATED) === true; + } + + /** + * Simple object and variable dumper + * to help with debugging. + */ + public static function dump(mixed $variable, bool $echo = true): string + { + $kirby = App::instance(); + $output = print_r($variable, true); + + if ($kirby->environment()->cli() === true) { + $output .= PHP_EOL; + } else { + $output = Str::wrap($output, '
    ', '
    '); + } + + if ($echo === true) { + echo $output; + } + + return $output; + } + + /** + * Performs an action with custom handling + * for all PHP errors and warnings + * @since 3.7.4 + * + * @param \Closure $action Any action that may cause an error or warning + * @param \Closure $condition Closure that returns bool to determine if to + * suppress an error, receives arguments for + * `set_error_handler()` + * @param mixed $fallback Value to return when error is suppressed + * @return mixed Return value of the `$action` closure, + * possibly overridden by `$fallback` + */ + public static function handleErrors( + Closure $action, + Closure $condition, + $fallback = null + ) { + $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 + */ + $handler = set_error_handler(function () use (&$override, &$handler, $condition, $fallback) { + // check if suppress condition is met + $suppress = $condition(...func_get_args()); + + if ($suppress !== true) { + // handle other warnings with Whoops if loaded + if (is_callable($handler) === true) { + return $handler(...func_get_args()); + } + + // otherwise use the standard error handler + return false; // @codeCoverageIgnore + } + + // use fallback to override return for suppressed errors + $override = $fallback; + + if (is_callable($override) === true) { + $override = $override(); + } + + // no additional error handling + return true; + }); + + try { + $result = $action(); + } finally { + // always restore the error handler, even if the + // 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; + } + + /** + * Checks if a helper was overridden by the user + * by setting the `KIRBY_HELPER_*` constant + * + * @param string $name Name of the helper + */ + public static function hasOverride(string $name): bool + { + $name = 'KIRBY_HELPER_' . strtoupper($name); + return defined($name) === true && constant($name) === false; + } + + /** + * Determines the size/length of numbers, + * strings, arrays and countable objects + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function size(mixed $value): int + { + if (is_numeric($value)) { + return (int)$value; + } + + if (is_string($value)) { + return Str::length(trim($value)); + } + + if (is_countable($value)) { + return count($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 new file mode 100644 index 0000000..540fb25 --- /dev/null +++ b/public/kirby/src/Cms/Html.php @@ -0,0 +1,164 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Html extends \Kirby\Toolkit\Html +{ + /** + * Creates one or multiple CSS link tags + * @since 3.7.0 + * + * @param string|array $url Relative or absolute URLs, an array of URLs or `@auto` for automatic template css loading + * @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|Assets $url, + string|array|null $options = null + ): string|null { + if ($url instanceof Plugin) { + $url = $url->assets(); + } + + if ($url instanceof Assets) { + $url = $url->css()->values(fn ($asset) => $asset->url()); + } + + if (is_array($url) === true) { + $links = A::map($url, fn ($url) => static::css($url, $options)); + return implode(PHP_EOL, $links); + } + + if (is_string($options) === true) { + $options = ['media' => $options]; + } + + $kirby = App::instance(); + + if ($url === '@auto') { + if (!$url = Url::toTemplateAsset('css/templates', 'css')) { + return null; + } + } + + // only valid value for 'rel' is 'alternate stylesheet', + // if 'title' is given as well + if ( + ($options['rel'] ?? '') !== 'alternate stylesheet' || + ($options['title'] ?? '') === '' + ) { + $options['rel'] = 'stylesheet'; + } + + $url = ($kirby->component('css'))($kirby, $url, $options); + $url = Url::to($url); + $attr = [...$options ?? [], 'href' => $url]; + + return ''; + } + + /** + * Generates an `a` tag with an absolute Url + * + * @param string|null $href Relative or absolute Url + * @param string|array|null $text If `null`, the link will be used as link text. If an array is passed, each element will be added unencoded + * @param array $attr Additional attributes for the a tag. + */ + public static function link( + string|null $href = null, + string|array|null $text = null, + array $attr = [] + ): string { + return parent::link(Url::to($href), $text, $attr); + } + + /** + * Creates a script tag to load a javascript file + * @since 3.7.0 + */ + public static function js( + string|array|Plugin|Assets $url, + string|array|bool|null $options = null + ): string|null { + if ($url instanceof Plugin) { + $url = $url->assets(); + } + + if ($url instanceof Assets) { + $url = $url->js()->values(fn ($asset) => $asset->url()); + } + + if (is_array($url) === true) { + $scripts = A::map($url, fn ($url) => static::js($url, $options)); + return implode(PHP_EOL, $scripts); + } + + if (is_bool($options) === true) { + $options = ['async' => $options]; + } + + $kirby = App::instance(); + + if ($url === '@auto') { + if (!$url = Url::toTemplateAsset('js/templates', 'js')) { + return null; + } + } + + $url = ($kirby->component('js'))($kirby, $url, $options); + $url = Url::to($url); + $attr = [...$options ?? [], 'src' => $url]; + + return ''; + } + + /** + * Includes an SVG file by absolute or + * relative file path. + * @since 3.7.0 + */ + public static function svg(string|File $file): string|false + { + // support for Kirby's file objects + if ( + $file instanceof File && + $file->extension() === 'svg' + ) { + return $file->read(); + } + + if (is_string($file) === false) { + return false; + } + + $extension = F::extension($file); + + // check for valid svg files + if ($extension !== 'svg') { + return false; + } + + // try to convert relative paths to absolute + if (file_exists($file) === false) { + $root = App::instance()->root(); + $file = realpath($root . '/' . $file); + } + + return F::read($file); + } +} diff --git a/public/kirby/src/Cms/Ingredients.php b/public/kirby/src/Cms/Ingredients.php new file mode 100644 index 0000000..87e346d --- /dev/null +++ b/public/kirby/src/Cms/Ingredients.php @@ -0,0 +1,77 @@ +urls()` and `$kirby->roots()` objects. + * Those are configured in `kirby/config/urls.php` + * and `kirby/config/roots.php` + * + * @package Kirby Cms + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Ingredients +{ + /** + * Creates a new ingredient collection + */ + public function __construct( + protected array $ingredients = [] + ) { + } + + /** + * Magic getter for single ingredients + */ + public function __call(string $method, array|null $args = null): mixed + { + return $this->ingredients[$method] ?? null; + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->ingredients; + } + + /** + * Get a single ingredient by key + */ + public function __get(string $key) + { + return $this->ingredients[$key] ?? null; + } + + /** + * Resolves all ingredient callbacks + * and creates a plain array + * @internal + */ + public static function bake(array $ingredients): static + { + foreach ($ingredients as $name => $ingredient) { + if ($ingredient instanceof Closure) { + $ingredients[$name] = $ingredient($ingredients); + } + } + + return new static($ingredients); + } + + /** + * Returns all ingredients as plain array + */ + public function toArray(): array + { + return $this->ingredients; + } +} diff --git a/public/kirby/src/Cms/Item.php b/public/kirby/src/Cms/Item.php new file mode 100644 index 0000000..a606f16 --- /dev/null +++ b/public/kirby/src/Cms/Item.php @@ -0,0 +1,121 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @template TCollection of \Kirby\Cms\Items + * @use \Kirby\Cms\HasSiblings + */ +class Item +{ + use HasSiblings; + + public const ITEMS_CLASS = Items::class; + + protected Field|null $field; + + protected string $id; + protected array $params; + protected ModelWithContent $parent; + protected Items $siblings; + + /** + * Creates a new item + */ + public function __construct(array $params = []) + { + $class = static::ITEMS_CLASS; + $this->id = $params['id'] ?? Str::uuid(); + $this->params = $params; + $this->field = $params['field'] ?? null; + $this->parent = $params['parent'] ?? App::instance()->site(); + $this->siblings = $params['siblings'] ?? new $class(); + } + + /** + * Static Item factory + */ + public static function factory(array $params): static + { + return new static($params); + } + + /** + * Returns the parent field if known + */ + public function field(): Field|null + { + return $this->field; + } + + /** + * Returns the unique item id (UUID v4) + */ + public function id(): string + { + return $this->id; + } + + /** + * Compares the item to another one + */ + public function is(Item $item): bool + { + return $this->id() === $item->id(); + } + + /** + * Returns the Kirby instance + */ + public function kirby(): App + { + return $this->parent()->kirby(); + } + + /** + * Returns the parent model + */ + public function parent(): ModelWithContent + { + return $this->parent; + } + + /** + * Returns the sibling collection + * This is required by the HasSiblings trait + * + * @psalm-return self::ITEMS_CLASS + */ + protected function siblingsCollection(): Items + { + return $this->siblings; + } + + /** + * Converts the item to an array + */ + public function toArray(): array + { + return [ + 'id' => $this->id(), + ]; + } +} diff --git a/public/kirby/src/Cms/Items.php b/public/kirby/src/Cms/Items.php new file mode 100644 index 0000000..7cc56ab --- /dev/null +++ b/public/kirby/src/Cms/Items.php @@ -0,0 +1,105 @@ + + * @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 +{ + public const ITEM_CLASS = Item::class; + + protected Field|null $field; + + /** + * All registered items methods + */ + public static array $methods = []; + + protected array $options; + + /** + * @var \Kirby\Cms\ModelWithContent + */ + protected object|null $parent = null; + + public function __construct($objects = [], array $options = []) + { + $this->options = $options; + $this->parent = $options['parent'] ?? App::instance()->site(); + $this->field = $options['field'] ?? null; + + parent::__construct($objects, $this->parent); + } + + /** + * Creates a new item collection from a + * an array of item props + */ + public static function factory( + array|null $items = null, + array $params = [] + ): static { + if (empty($items) === true || is_array($items) === false) { + return new static(); + } + + if (is_array($params) === false) { + throw new InvalidArgumentException(message: 'Invalid item options'); + } + + // create a new collection of blocks + $collection = new static([], $params); + + foreach ($items as $item) { + if (is_array($item) === false) { + throw new InvalidArgumentException( + message: 'Invalid data for ' . static::ITEM_CLASS + ); + } + + // inject properties from the parent + $item['field'] = $collection->field(); + $item['options'] = $params['options'] ?? []; + $item['parent'] = $collection->parent(); + $item['siblings'] = $collection; + $item['params'] = $item; + + $class = static::ITEM_CLASS; + $item = $class::factory($item); + $collection->append($item->id(), $item); + } + + return $collection; + } + + /** + * Returns the parent field if known + */ + public function field(): Field|null + { + return $this->field; + } + + /** + * Convert the items to an array + */ + public function toArray(Closure|null $map = null): array + { + return array_values(parent::toArray($map)); + } +} diff --git a/public/kirby/src/Cms/Language.php b/public/kirby/src/Cms/Language.php new file mode 100644 index 0000000..fde1f5d --- /dev/null +++ b/public/kirby/src/Cms/Language.php @@ -0,0 +1,658 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Languages> + */ +class Language implements Stringable +{ + use HasSiblings; + + /** + * Short human-readable version used in template queries + */ + public const CLASS_ALIAS = 'language'; + + /** + * The parent Kirby instance + */ + public static App|null $kirby; + + protected string $code; + protected bool $default; + protected string $direction; + protected array $locale; + protected string $name; + protected bool $single; + protected array $slugs; + protected array $smartypants; + protected array $translations; + protected string|null $url; + + /** + * Creates a new language object + */ + public function __construct(array $props) + { + if (isset($props['code']) === false) { + throw new InvalidArgumentException( + message: 'The property "code" is required' + ); + } + + static::$kirby = $props['kirby'] ?? null; + $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'] ?? []; + $this->url = $props['url'] ?? null; + + if ($locale = $props['locale'] ?? null) { + $this->locale = Locale::normalize($locale); + } else { + $this->locale = [LC_ALL => $this->code]; + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the language code + * when the language is converted to a string + */ + public function __toString(): string + { + return $this->code(); + } + + /** + * Returns the base Url for the language + * without the path or other cruft + */ + public function baseUrl(): string + { + $kirbyUrl = $this->kirby()->url(); + $languageUrl = $this->url(); + + if (empty($this->url)) { + return $kirbyUrl; + } + + if (Str::startsWith($languageUrl, $kirbyUrl) === true) { + return $kirbyUrl; + } + + return Url::base($languageUrl) ?? $kirbyUrl; + } + + /** + * Creates an instance with the same + * initial properties. + */ + public function clone(array $props = []): static + { + return new static(array_replace_recursive([ + 'code' => $this->code, + 'default' => $this->default, + 'direction' => $this->direction, + 'locale' => $this->locale, + 'name' => $this->name, + 'slugs' => $this->slugs, + 'smartypants' => $this->smartypants, + 'translations' => $this->translations, + 'url' => $this->url, + ], $props)); + } + + /** + * Returns the language code/id. + * The language code is used in + * text file names as appendix. + */ + public function code(): string + { + return $this->code; + } + + /** + * Creates a new language object + */ + public static function create(array $props): static + { + $kirby = App::instance(); + $languages = $kirby->languages(); + $props['code'] = Str::slug($props['code'] ?? null); + + // make the first language the default language + if ($languages->count() === 0) { + $props['default'] = true; + } + + $language = new static($props); + + // validate the new language + LanguageRules::create($language); + + // apply before hook + $language = $kirby->apply( + 'language.create:before', + [ + 'input' => $props, + 'language' => $language + ], + '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()->moveLanguage( + Language::single(), + $language + ); + } + } + + // update the main languages collection in the app instance + $kirby->languages(false)->append($language->code(), $language); + + // apply after hook + $language = $kirby->apply( + 'language.create:after', + [ + 'input' => $props, + 'language' => $language + ], + 'language' + ); + + return $language; + } + + /** + * Delete the current language and + * all its translation files + * + * @throws \Kirby\Exception\Exception + */ + public function delete(): bool + { + $kirby = App::instance(); + $code = $this->code(); + + // 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 ($language->isLast() === true) { + $model->storage()->moveLanguage($this, Language::single()); + } else { + $model->storage()->deleteLanguage($this); + } + } + + // get the original language collection and remove the current language + $kirby->languages(false)->remove($code); + + // trigger after hook + $kirby->trigger('language.delete:after', [ + 'language' => $language + ]); + + return true; + } + + /** + * Reading direction of this language + */ + public function direction(): string + { + 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 + */ + public function exists(): bool + { + 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. + */ + public function isDefault(): bool + { + return $this->default; + } + + /** + * Checks if the language can be deleted + */ + 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; + } + + return true; + } + + /** + * Checks if this is the last language + */ + public function isLast(): bool + { + 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 + */ + public function id(): string + { + return $this->code; + } + + /** + * Returns the parent Kirby instance + */ + public function kirby(): App + { + return static::$kirby ??= App::instance(); + } + + /** + * Loads the language rules for provided locale code + */ + 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'; + + if (F::exists($file) === false) { + $file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json'; + } + + return Data::read($file, fail: false); + } + + /** + * Returns the PHP locale setting array + * + * @param int $category If passed, returns the locale for the specified category (e.g. LC_ALL) as string + */ + public function locale(int|null $category = null): array|string|null + { + if ($category !== null) { + return $this->locale[$category] ?? $this->locale[LC_ALL] ?? null; + } + + return $this->locale; + } + + /** + * Returns the human-readable name + * of the language + */ + public function name(): string + { + return $this->name; + } + + /** + * Returns the URL path for the language + */ + public function path(): string + { + if ($this->url === null) { + return $this->code; + } + + return Url::path($this->url); + } + + /** + * Returns the routing pattern for the language + */ + public function pattern(): string + { + $path = $this->path(); + + if (empty($path) === true) { + return '(:all)'; + } + + 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 + */ + public function root(): string + { + return App::instance()->root('languages') . '/' . $this->code() . '.php'; + } + + /** + * Returns the LanguageRouter instance + * which is used to handle language specific + * routes. + */ + public function router(): LanguageRouter + { + return new LanguageRouter($this); + } + + /** + * Get slug rules for language + */ + public function rules(): array + { + $code = $this->locale(LC_CTYPE); + + return [ + ...static::loadRules($code), + ...$this->slugs() + ]; + } + + /** + * Saves the language settings in the languages folder + * + * @return $this + */ + public function save(): static + { + $existingData = Data::read($this->root(), fail: false); + + $data = [ + ...$existingData, + 'code' => $this->code(), + 'default' => $this->isDefault(), + 'direction' => $this->direction(), + 'locale' => Locale::export($this->locale()), + 'name' => $this->name(), + 'translations' => $this->translations(), + 'url' => $this->url, + ]; + + ksort($data); + + Data::write($this->root(), $data); + + return $this; + } + + /** + * Private siblings collector + */ + 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 + */ + public function slugs(): array + { + return $this->slugs; + } + + /** + * Returns the custom SmartyPants options for this language + */ + public function smartypants(): array + { + return $this->smartypants; + } + + /** + * Returns the most important properties as array + */ + public function toArray(): array + { + return [ + 'code' => $this->code(), + 'default' => $this->isDefault(), + 'direction' => $this->direction(), + 'locale' => $this->locale(), + 'name' => $this->name(), + 'rules' => $this->rules(), + 'url' => $this->url() + ]; + } + + /** + * Returns the translation strings for this language + */ + public function translations(): array + { + return $this->translations; + } + + /** + * Returns the absolute Url for the language + */ + public function url(): string + { + $url = $this->url; + $url ??= '/' . $this->code; + return Url::makeAbsolute($url, $this->kirby()->url()); + } + + /** + * Update language properties and save them + */ + public function update(array|null $props = null): static + { + $kirby = App::instance(); + + // don't change the language code + unset($props['code']); + + // make sure the slug is nice and clean + $props['slug'] = Str::slug($props['slug'] ?? null); + + // 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) { + $language->translations = $props['translations']; + } + + // 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 && $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); + + foreach ($kirby->models() as $model) { + $model->storage()->touchLanguage($this); + } + } + + $language = $language->save(); + + // make sure the language is also updated in the languages collection + $kirby->languages(false)->set($language->code(), $language); + + // trigger after hook + $language = $kirby->apply( + 'language.update:after', + [ + 'newLanguage' => $language, + 'oldLanguage' => $this, + 'input' => $props + ] + ); + + return $language; + } + + /** + * Returns a language variable object + * for the key in the translations array + */ + 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( + 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 new file mode 100644 index 0000000..550766d --- /dev/null +++ b/public/kirby/src/Cms/LanguageRouter.php @@ -0,0 +1,149 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LanguageRouter +{ + protected Router $router; + + /** + * Creates a new language router instance + * for the given language + */ + public function __construct( + protected Language $language + ) { + } + + /** + * Fetches all scoped routes for the + * current language from the Kirby instance + * + * @throws \Kirby\Exception\NotFoundException + */ + public function routes(): array + { + $language = $this->language; + $kirby = $language->kirby(); + $routes = $kirby->routes(); + + // only keep the scoped language routes + $routes = array_values(array_filter($routes, function ($route) use ($language) { + // no language scope + if (empty($route['language']) === true) { + return false; + } + + // wildcard + if ($route['language'] === '*') { + return true; + } + + // get all applicable languages + $languages = Str::split(strtolower($route['language']), '|'); + + // validate the language + return in_array($language->code(), $languages, true) === true; + })); + + // add the page-scope if necessary + foreach ($routes as $index => $route) { + if ($pageId = ($route['page'] ?? null)) { + if ($page = $kirby->page($pageId)) { + // convert string patterns to arrays + $patterns = A::wrap($route['pattern']); + + // prefix all patterns with the page slug + $patterns = A::map( + $patterns, + fn ($pattern) => $page->uri($language) . '/' . $pattern + ); + + // re-inject the pattern and the full page object + $routes[$index]['pattern'] = $patterns; + $routes[$index]['page'] = $page; + } else { + throw new NotFoundException( + message: 'The page "' . $pageId . '" does not exist' + ); + } + } + } + + // Language-specific UUID URLs + $routes[] = [ + 'pattern' => '@/(page|file)/(:all)', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function (string $languageCode, string $type, string $id) use ($kirby, $language) { + // try to resolve to model, but only from UUID cache; + // this ensures that only existing UUIDs can be queried + // and attackers can't force Kirby to go through the whole + // site index with a non-existing UUID + if ($model = Uuid::for($type . '://' . $id)?->model(true)) { + return $kirby + ->response() + ->redirect($model->url($language->code())); + } + + // render the error page + return false; + } + ]; + + return $routes; + } + + /** + * Wrapper around the Router::call method + * that injects the Language instance and + * if needed also the Page as arguments. + */ + public function call(string|null $path = null): mixed + { + $language = $this->language; + $kirby = $language->kirby(); + $this->router ??= new Router($this->routes()); + + try { + return $this->router->call($path, $kirby->request()->method(), function ($route) use ($kirby, $language) { + $kirby->setCurrentTranslation($language); + $kirby->setCurrentLanguage($language); + + if ($page = $route->page()) { + return $route->action()->call( + $route, + $language, + $page, + ...$route->arguments() + ); + } + + return $route->action()->call( + $route, + $language, + ...$route->arguments() + ); + }); + } catch (Exception) { + return $kirby->resolve($path, $language->code()); + } + } +} diff --git a/public/kirby/src/Cms/LanguageRoutes.php b/public/kirby/src/Cms/LanguageRoutes.php new file mode 100644 index 0000000..2e74d3b --- /dev/null +++ b/public/kirby/src/Cms/LanguageRoutes.php @@ -0,0 +1,155 @@ +url(); + + foreach ($kirby->languages() as $language) { + // ignore languages with a different base url + if ($language->baseurl() !== $baseurl) { + continue; + } + + $routes[] = [ + 'pattern' => $language->pattern(), + 'method' => 'ALL', + 'env' => 'site', + 'action' => function ($path = null) use ($language) { + $result = $language->router()->call($path); + + // explicitly test for null as $result can + // contain falsy values that should still be returned + if ($result !== null) { + return $result; + } + + // jump through to the fallback if nothing + // can be found for this language + /** @var \Kirby\Http\Route $this */ + $this->next(); + } + ]; + } + + $routes[] = static::fallback($kirby); + + return $routes; + } + + + /** + * Create the fallback route + * for unprefixed default language URLs. + */ + public static function fallback(App $kirby): array + { + return [ + 'pattern' => '(:all)', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function (string $path) use ($kirby) { + // check for content representations or files + $extension = F::extension($path); + + // try to redirect prefixed pages + if ( + empty($extension) === true && + $page = $kirby->page($path) + ) { + $url = $kirby->request()->url([ + 'query' => null, + 'params' => null, + 'fragment' => null + ]); + + if ($url->toString() !== $page->url()) { + // redirect to translated page directly if translation + // is exists and languages detect is enabled + $lang = $kirby->detectedLanguage()->code(); + + if ( + $kirby->option('languages.detect') === true && + $page->translation($lang)->exists() === true + ) { + return $kirby + ->response() + ->redirect($page->url($lang)); + } + + return $kirby + ->response() + ->redirect($page->url()); + } + } + + return $kirby->language()->router()->call($path); + } + ]; + } + + /** + * Create the multi-language home page route + */ + public static function home(App $kirby): array + { + // Multi-language home + return [ + 'pattern' => '', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function () use ($kirby) { + // find all languages with the same base url as the current installation + $languages = $kirby->languages()->filter( + 'baseurl', + $kirby->url() + ); + + // if there's no language with a matching base url, + // redirect to the default language + if ($languages->count() === 0) { + return $kirby + ->response() + ->redirect($kirby->defaultLanguage()->url()); + } + + // if there's just one language, + // we take that to render the home page + $currentLanguage = match ($languages->count()) { + 1 => $languages->first(), + default => $kirby->defaultLanguage() + }; + + // language detection on the home page with / as URL + if ($kirby->url() !== $currentLanguage->url()) { + if ($kirby->option('languages.detect') === true) { + return $kirby + ->response() + ->redirect($kirby->detectedLanguage()->url()); + } + + return $kirby + ->response() + ->redirect($currentLanguage->url()); + } + + // render the home page of the current language + return $currentLanguage->router()->call(); + } + ]; + } +} diff --git a/public/kirby/src/Cms/LanguageRules.php b/public/kirby/src/Cms/LanguageRules.php new file mode 100644 index 0000000..5a1f45e --- /dev/null +++ b/public/kirby/src/Cms/LanguageRules.php @@ -0,0 +1,128 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +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): void + { + static::validLanguageCode($language); + static::validLanguageName($language); + + if ($language->exists() === true) { + throw new DuplicateException( + key: 'language.duplicate', + data: ['code' => $language->code()] + ); + } + + 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 $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' + ); + } + } + + /** + * Validates if the language code is formatted correctly + * + * @throws \Kirby\Exception\InvalidArgumentException If the language code is not valid + */ + public static function validLanguageCode(Language $language): void + { + if (Str::length($language->code()) < 2) { + throw new InvalidArgumentException( + key: 'language.code', + data: [ + 'code' => $language->code(), + 'name' => $language->name() + ] + ); + } + } + + /** + * Validates if the language name is formatted correctly + * + * @throws \Kirby\Exception\InvalidArgumentException If the language name is invalid + */ + public static function validLanguageName(Language $language): void + { + if (Str::length($language->name()) < 1) { + throw new InvalidArgumentException( + key: 'language.name', + data: [ + 'code' => $language->code(), + 'name' => $language->name() + ] + ); + } + } +} diff --git a/public/kirby/src/Cms/LanguageVariable.php b/public/kirby/src/Cms/LanguageVariable.php new file mode 100644 index 0000000..935a598 --- /dev/null +++ b/public/kirby/src/Cms/LanguageVariable.php @@ -0,0 +1,148 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LanguageVariable +{ + protected App $kirby; + + public function __construct( + protected Language $language, + protected string $key + ) { + $this->kirby = App::instance(); + } + + /** + * Creates a new language variable. This will + * be added to the default language first and + * can then be translated in other languages. + */ + public static function create( + string $key, + string|array|null $value = null + ): static { + if (is_numeric($key) === true) { + throw new InvalidArgumentException( + message: 'The variable key must not be numeric' + ); + } + + if (empty($key) === true) { + throw new InvalidArgumentException( + message: 'The variable needs a valid key' + ); + } + + $kirby = App::instance(); + $language = $kirby->defaultLanguage(); + $translations = $language->translations(); + + if ($kirby->translation()->get($key) !== null) { + if (isset($translations[$key]) === true) { + throw new DuplicateException( + message: 'The variable already exists' + ); + } + + throw new DuplicateException( + message: 'The variable is part of the core translation and cannot be overwritten' + ); + } + + $translations[$key] = $value ?? ''; + + $language = $language->update(['translations' => $translations]); + + return $language->variable($key); + } + + /** + * Deletes a language variable from the translations array. + * This will go through all language files and delete the + * key from all translation arrays to keep them clean. + */ + public function delete(): bool + { + // go through all languages and remove the variable + foreach ($this->kirby->languages() as $language) { + $variables = $language->translations(); + + unset($variables[$this->key]); + + $language->update(['translations' => $variables]); + } + + return true; + } + + /** + * Checks if a language variable exists in the default language + */ + public function exists(): bool + { + $language = $this->kirby->defaultLanguage(); + 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 + */ + public function key(): string + { + return $this->key; + } + + /** + * Returns the parent language + * @since 5.1.0 + */ + public function language(): Language + { + return $this->language; + } + + /** + * Sets a new value for the language variable + */ + public function update(string|array|null $value = null): static + { + $translations = $this->language->translations(); + $translations[$this->key] = $value ?? ''; + + $language = $this->language->update(['translations' => $translations]); + + return $language->variable($this->key); + } + + /** + * Returns the value if the variable has been translated. + */ + 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 new file mode 100644 index 0000000..3c7d771 --- /dev/null +++ b/public/kirby/src/Cms/Languages.php @@ -0,0 +1,115 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Language> + */ +class Languages extends Collection +{ + /** + * All registered languages methods + */ + public static array $methods = []; + + /** + * Creates a new collection with the given language objects + * + * @param null $parent + * @throws \Kirby\Exception\DuplicateException + */ + public function __construct( + array $objects = [], + $parent = null + ) { + $defaults = array_filter( + $objects, + fn ($language) => $language->isDefault() === true + ); + + if (count($defaults) > 1) { + throw new DuplicateException( + message: 'You cannot have multiple default languages. Please check your language config files.' + ); + } + + parent::__construct($objects, null); + } + + /** + * Returns all language codes as array + */ + public function codes(): array + { + return App::instance()->multilang() ? $this->keys() : ['default']; + } + + /** + * Creates a new language with the given props + */ + public function create(array $props): Language + { + return Language::create($props); + } + + /** + * Returns the default language + */ + public function default(): Language|null + { + return $this->findBy('isDefault', true) ?? $this->first(); + } + + /** + * 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 + { + $languages = []; + $files = glob(App::instance()->root('languages') . '/*.php'); + + foreach ($files as $file) { + $props = F::load($file, allowOutput: false); + + if (is_array($props) === true) { + // inject the language code from the filename + // if it does not exist + $props['code'] ??= F::name($file); + + $languages[] = new Language($props); + } + } + + return new static($languages); + } +} diff --git a/public/kirby/src/Cms/Layout.php b/public/kirby/src/Cms/Layout.php new file mode 100644 index 0000000..9feb6d8 --- /dev/null +++ b/public/kirby/src/Cms/Layout.php @@ -0,0 +1,107 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Layouts> + */ +class Layout extends Item +{ + use HasMethods; + + public const ITEMS_CLASS = Layouts::class; + + protected Content $attrs; + protected LayoutColumns $columns; + + /** + * Proxy for attrs + */ + public function __call(string $method, array $args = []): mixed + { + // layout methods + if ($this->hasMethod($method) === true) { + return $this->callMethod($method, $args); + } + + return $this->attrs()->get($method); + } + + /** + * Creates a new Layout object + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + $this->columns = LayoutColumns::factory($params['columns'] ?? [], [ + 'field' => $this->field, + 'parent' => $this->parent + ]); + + // create the attrs object + $this->attrs = new Content($params['attrs'] ?? [], $this->parent); + } + + /** + * Returns the attrs object + */ + public function attrs(): Content + { + return $this->attrs; + } + + /** + * Returns the columns in this layout + */ + public function columns(): LayoutColumns + { + return $this->columns; + } + + /** + * Checks if the layout is empty + * @since 3.5.2 + */ + public function isEmpty(): bool + { + return $this + ->columns() + ->filter('isEmpty', false) + ->count() === 0; + } + + /** + * Checks if the layout is not empty + * @since 3.5.2 + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * The result is being sent to the editor + * via the API in the panel + */ + public function toArray(): array + { + return [ + 'attrs' => $this->attrs()->toArray(), + 'columns' => $this->columns()->toArray(), + 'id' => $this->id(), + ]; + } +} diff --git a/public/kirby/src/Cms/LayoutColumn.php b/public/kirby/src/Cms/LayoutColumn.php new file mode 100644 index 0000000..1865a91 --- /dev/null +++ b/public/kirby/src/Cms/LayoutColumn.php @@ -0,0 +1,122 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\LayoutColumns> + */ +class LayoutColumn extends Item +{ + use HasMethods; + + public const ITEMS_CLASS = LayoutColumns::class; + + protected Blocks $blocks; + protected string $width; + + /** + * Creates a new LayoutColumn object + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + $this->blocks = Blocks::factory($params['blocks'] ?? [], [ + 'field' => $this->field, + 'parent' => $this->parent + ]); + + $this->width = $params['width'] ?? '1/1'; + } + + /** + * Magic getter function + */ + public function __call(string $method, mixed $args): mixed + { + // layout column methods + if ($this->hasMethod($method) === true) { + return $this->callMethod($method, $args); + } + } + + /** + * Returns the blocks collection + * + * @param bool $includeHidden Sets whether to include hidden blocks + */ + public function blocks(bool $includeHidden = false): Blocks + { + if ($includeHidden === false) { + return $this->blocks->filter('isHidden', false); + } + + return $this->blocks; + } + + /** + * Checks if the column is empty + * @since 3.5.2 + */ + public function isEmpty(): bool + { + return $this + ->blocks() + ->filter('isHidden', false) + ->count() === 0; + } + + /** + * Checks if the column is not empty + * @since 3.5.2 + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Returns the number of columns this column spans + */ + public function span(int $columns = 12): int + { + $fraction = Str::split($this->width, '/'); + $a = $fraction[0] ?? 1; + $b = $fraction[1] ?? 1; + + return $columns * $a / $b; + } + + /** + * The result is being sent to the editor + * via the API in the panel + */ + public function toArray(): array + { + return [ + 'blocks' => $this->blocks(true)->toArray(), + 'id' => $this->id(), + 'width' => $this->width(), + ]; + } + + /** + * Returns the width of the column + */ + public function width(): string + { + return $this->width; + } +} diff --git a/public/kirby/src/Cms/LayoutColumns.php b/public/kirby/src/Cms/LayoutColumns.php new file mode 100644 index 0000000..62890a1 --- /dev/null +++ b/public/kirby/src/Cms/LayoutColumns.php @@ -0,0 +1,25 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\LayoutColumn> + */ +class LayoutColumns extends Items +{ + public const ITEM_CLASS = LayoutColumn::class; + + /** + * All registered layout columns methods + */ + public static array $methods = []; +} diff --git a/public/kirby/src/Cms/Layouts.php b/public/kirby/src/Cms/Layouts.php new file mode 100644 index 0000000..ce499c7 --- /dev/null +++ b/public/kirby/src/Cms/Layouts.php @@ -0,0 +1,122 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\Layout> + */ +class Layouts extends Items +{ + public const ITEM_CLASS = Layout::class; + + /** + * All registered layouts methods + */ + public static array $methods = []; + + public static function factory( + array|null $items = null, + array $params = [] + ): static { + // convert single layout to layouts array + if ( + isset($items['columns']) === true || + isset($items['id']) === true + ) { + $items = [$items]; + } + + $first = $items[0] ?? []; + + // if there are no wrapping layouts for blocks yet … + if ( + isset($first['content']) === true || + isset($first['type']) === true + ) { + $items = [ + [ + 'id' => Str::uuid(), + 'columns' => [ + [ + 'width' => '1/1', + 'blocks' => $items + ] + ] + ] + ]; + } + + return parent::factory($items, $params); + } + + /** + * Checks if a given block type exists in the layouts collection + * @since 3.6.0 + */ + public function hasBlockType(string $type): bool + { + return $this->toBlocks()->hasType($type); + } + + /** + * Parse layouts data + */ + public static function parse(array|string|null $input): array + { + if ( + empty($input) === false && + is_array($input) === false + ) { + try { + $input = Json::decode((string)$input); + } catch (Throwable) { + return []; + } + } + + if (empty($input) === true) { + return []; + } + + return $input; + } + + /** + * Converts layouts to blocks + * @since 3.6.0 + * + * @param bool $includeHidden Sets whether to include hidden blocks + */ + public function toBlocks(bool $includeHidden = false): Blocks + { + $blocks = []; + + if ($this->isNotEmpty() === true) { + foreach ($this->data() as $layout) { + foreach ($layout->columns() as $column) { + foreach ($column->blocks($includeHidden) as $block) { + $blocks[] = $block->toArray(); + } + } + } + } + + return Blocks::factory($blocks, [ + 'field' => $this->field, + 'parent' => $this->parent + ]); + } +} diff --git a/public/kirby/src/Cms/License.php b/public/kirby/src/Cms/License.php new file mode 100644 index 0000000..e005b45 --- /dev/null +++ b/public/kirby/src/Cms/License.php @@ -0,0 +1,570 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class License +{ + public const HISTORY = [ + '3' => '2019-02-05', + '4' => '2023-11-28', + '5' => '2025-06-24' + ]; + + protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX'; + + protected App $kirby; + + // cache + protected LicenseStatus $status; + protected LicenseType $type; + + public function __construct( + protected string|null $activation = null, + protected string|null $code = null, + protected string|null $domain = null, + protected string|null $email = null, + protected string|null $order = null, + protected string|null $date = null, + protected string|null $signature = null, + ) { + if ($code !== null) { + $this->code = trim($code); + } + + if ($email !== null) { + $this->email = $this->normalizeEmail($email); + } + + $this->kirby = App::instance(); + } + + /** + * Returns the activation date if available + */ + public function activation( + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): int|string|null { + return $this->activation !== null ? Str::date(strtotime($this->activation), $format, $handler) : null; + } + + /** + * Returns the license code if available + */ + public function code(bool $obfuscated = false): string|null + { + if ($this->code !== null && $obfuscated === true) { + return Str::substr($this->code, 0, 10) . str_repeat('X', 22); + } + + return $this->code; + } + + /** + * Content for the license file + */ + public function content(): array + { + return [ + 'activation' => $this->activation, + 'code' => $this->code, + 'date' => $this->date, + 'domain' => $this->domain, + 'email' => $this->email, + 'order' => $this->order, + 'signature' => $this->signature, + ]; + } + + /** + * Returns the purchase date if available + */ + public function date( + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): int|string|null { + return $this->date !== null ? Str::date(strtotime($this->date), $format, $handler) : null; + } + + /** + * Deletes the license file if it exists + * @since 5.1.0 + */ + public function delete(): bool + { + return F::remove($this->root()); + } + + /** + * Returns the activation domain if available + */ + public function domain(): string|null + { + return $this->domain; + } + + /** + * Returns the activation email if available + */ + public function email(): string|null + { + return $this->email; + } + + /** + * Validates the email address of the license + */ + public function hasValidEmailAddress(): bool + { + return V::email($this->email) === true; + } + + /** + * Hub address + */ + public static function hub(): string + { + return App::instance()->option('hub', 'https://hub.getkirby.com'); + } + + /** + * Checks for all required components of a valid license + */ + public function isComplete(): bool + { + if ( + $this->code !== null && + $this->date !== null && + $this->domain !== null && + $this->email !== null && + $this->order !== null && + $this->signature !== null && + $this->hasValidEmailAddress() === true && + $this->type() !== LicenseType::Invalid + ) { + return true; + } + + return false; + } + + /** + * The license is still valid for the currently + * installed version, but it passed the 3 year period. + */ + public function isInactive(): bool + { + return $this->renewal() < time(); + } + + /** + * Checks for licenses beyond their 3 year period + */ + public function isLegacy(): bool + { + if ($this->type() === LicenseType::Legacy) { + return true; + } + + // without an activation date, the license + // renewal cannot be evaluated and the license + // has to be marked as expired + if ($this->activation === null) { + return true; + } + + // get release date of current major version + $major = Str::before($this->kirby->version(), '.'); + $release = strtotime(static::HISTORY[$major] ?? ''); + + // if there's no matching version in the history + // rather throw an exception to avoid further issues + // @codeCoverageIgnoreStart + if ($release === false) { + throw new InvalidArgumentException( + message: 'The version for your license could not be found' + ); + } + // @codeCoverageIgnoreEnd + + // If the renewal date is older than the version launch + // date, the license is expired + return $this->renewal() < $release; + } + + /** + * Runs multiple checks to find out if the license is + * installed and verifiable + */ + public function isMissing(): bool + { + return + $this->isComplete() === false || + $this->isOnCorrectDomain() === false || + $this->isSigned() === false; + } + + /** + * Checks if the license is on the correct domain + */ + public function isOnCorrectDomain(): bool + { + if ($this->domain === null) { + return false; + } + + // compare domains + if ($this->normalizeDomain($this->kirby->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) { + return false; + } + + return true; + } + + /** + * Compares the signature with all ingredients + */ + public function isSigned(): bool + { + if ($this->signature === null) { + return false; + } + + // get the public key + $pubKey = F::read($this->kirby->root('kirby') . '/kirby.pub'); + + // verify the license signature + $data = json_encode($this->signatureData()); + $signature = hex2bin($this->signature); + + return openssl_verify($data, $signature, $pubKey, 'RSA-SHA256') === 1; + } + + /** + * Returns a reliable label for the license type + */ + public function label(): string + { + if ($this->status() === LicenseStatus::Missing) { + return LicenseType::Invalid->label(); + } + + return $this->type()->label(); + } + + /** + * Prepares the email address to be make sure it + * does not have trailing spaces and is lowercase. + */ + protected function normalizeEmail(string $email): string + { + return Str::lower(trim($email)); + } + + /** + * Prepares the domain to be comparable + */ + protected function normalizeDomain(string $domain): string + { + // remove common "testing" subdomains as well as www. + // to ensure that installations of the same site have + // the same license URL; only for installations at /, + // subdirectory installations are difficult to normalize + if (Str::contains($domain, '/') === false) { + if (Str::startsWith($domain, 'www.')) { + return substr($domain, 4); + } + + if (Str::startsWith($domain, 'dev.')) { + return substr($domain, 4); + } + + if (Str::startsWith($domain, 'test.')) { + return substr($domain, 5); + } + + if (Str::startsWith($domain, 'staging.')) { + return substr($domain, 8); + } + } + + return $domain; + } + + /** + * Returns the order id if available + */ + public function order(): string|null + { + return $this->order; + } + + /** + * Support the old license file dataset + * from older licenses + */ + public static function polyfill(array $license): array + { + return [ + 'activation' => $license['activation'] ?? null, + 'code' => $license['code'] ?? $license['license'] ?? null, + 'date' => $license['date'] ?? null, + 'domain' => $license['domain'] ?? null, + 'email' => $license['email'] ?? null, + 'order' => $license['order'] ?? null, + 'signature' => $license['signature'] ?? null, + ]; + } + + /** + * Reads the license file in the config folder + * and creates a new license instance for it. + */ + public static function read(): static + { + try { + $license = Json::read(static::root()); + } catch (Throwable) { + return new static(); + } + + return new static(...static::polyfill($license)); + } + + /** + * Sends a request to the hub to register the license + */ + public function register(): static + { + if ($this->type() === LicenseType::Invalid) { + throw new InvalidArgumentException( + key: 'license.format' + ); + } + + if ($this->hasValidEmailAddress() === false) { + throw new InvalidArgumentException( + key: 'license.email' + ); + } + + if ($this->domain === null) { + throw new InvalidArgumentException( + key: 'license.domain' + ); + } + + // @codeCoverageIgnoreStart + $response = $this->request('register', [ + 'license' => $this->code, + 'email' => $this->email, + 'domain' => $this->domain + ]); + + return $this->update($response); + // @codeCoverageIgnoreEnd + } + + /** + * Returns the renewal date + */ + public function renewal( + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): int|string|null { + if ($this->activation === null) { + return null; + } + + $time = strtotime('+3 years', $this->activation()); + return Str::date($time, $format, $handler); + } + + /** + * Sends a hub request + */ + public function request(string $path, array $data): array + { + // @codeCoverageIgnoreStart + $response = Remote::get(static::hub() . '/' . $path, [ + 'data' => $data + ]); + + // handle request errors + if ($response->code() !== 200) { + $message = $response->json()['message'] ?? 'The request failed'; + + throw new LogicException( + key: $response->code(), + message: $message, + ); + } + + return $response->json(); + // @codeCoverageIgnoreEnd + } + + /** + * Returns the root path to the license file + * @since 5.1.0 + */ + public static function root(): string + { + return App::instance()->root('license'); + } + + /** + * Saves the license in the config folder + */ + public function save(): bool + { + if ($this->status()->activatable() !== true) { + throw new InvalidArgumentException( + key: 'license.verification' + ); + } + + // save the license information + return Json::write( + file: $this->root(), + data: $this->content() + ); + } + + /** + * Returns the signature if available + */ + public function signature(): string|null + { + return $this->signature; + } + + /** + * Creates the signature data array to compare + * with the signature in ::isSigned + */ + public function signatureData(): array + { + if ($this->type() === LicenseType::Legacy) { + return [ + 'license' => $this->code, + 'order' => $this->order, + 'email' => hash('sha256', $this->email . static::SALT), + 'domain' => $this->domain, + 'date' => $this->date, + ]; + } + + return [ + 'activation' => $this->activation, + 'code' => $this->code, + 'date' => $this->date, + 'domain' => $this->domain, + 'email' => hash('sha256', $this->email . static::SALT), + 'order' => $this->order, + ]; + } + + /** + * Returns the license status as string + * This is used to build the proper UI elements + * for the license activation + */ + public function status(): LicenseStatus + { + return $this->status ??= match (true) { + $this->isMissing() => LicenseStatus::Missing, + $this->isLegacy() => LicenseStatus::Legacy, + $this->isInactive() => LicenseStatus::Inactive, + default => LicenseStatus::Active + }; + } + + /** + * Detects the license type if the license key is available + */ + public function type(): LicenseType + { + return $this->type ??= LicenseType::detect($this->code); + } + + /** + * Updates the license file + */ + public function update(array $data): static + { + // decode the response + $data = static::polyfill($data); + + $this->activation = $data['activation']; + $this->code = $data['code']; + $this->date = $data['date']; + $this->order = $data['order']; + $this->signature = $data['signature']; + + // clear the caches + unset($this->status, $this->type); + + // save the new state of the license + $this->save(); + + return $this; + } + + /** + * Sends an upgrade request to the hub in order + * to either redirect to the upgrade form or + * sync the new license state + * + * @codeCoverageIgnore + */ + public function upgrade(): array + { + $response = $this->request('upgrade', [ + 'domain' => $this->domain, + 'email' => $this->email, + 'license' => $this->code, + ]); + + // the license still needs an upgrade + if (empty($response['url']) === false) { + // validate the redirect URL + if (Str::startsWith($response['url'], static::hub()) === false) { + throw new Exception( + message: 'We couldn’t redirect you to the Hub' + ); + } + + return [ + 'status' => 'upgrade', + 'url' => $response['url'] + ]; + } + + // the license has already been upgraded + // and can now be replaced + $this->update($response); + + return [ + 'status' => 'complete', + ]; + } +} diff --git a/public/kirby/src/Cms/LicenseStatus.php b/public/kirby/src/Cms/LicenseStatus.php new file mode 100644 index 0000000..bfb67ec --- /dev/null +++ b/public/kirby/src/Cms/LicenseStatus.php @@ -0,0 +1,151 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @codeCoverageIgnore + */ +enum LicenseStatus: string +{ + /** + * The license is valid and active + */ + case Active = 'active'; + + /** + * Only used for the demo instance + */ + case Demo = 'demo'; + + /** + * The included updates period of + * the license is over. + */ + case Inactive = 'inactive'; + + /** + * The installation has an old + * license (v1, v2, v3) + */ + case Legacy = 'legacy'; + + /** + * The installation has no license or + * the license cannot be validated + */ + 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; + * renewable licenses are accepted as well + * to allow renewal from the Panel + */ + public function activatable(): bool + { + return match ($this) { + static::Active, + static::Inactive, + static::Legacy => true, + default => false + }; + } + + /** + * Returns the dialog according to the status + */ + public function dialog(): string|null + { + return match ($this) { + static::Demo => null, + static::Missing => 'registration', + default => 'license' + }; + } + + /** + * Returns the icon according to the status. + * The icon is used for the system view and + * in the license dialog. + */ + public function icon(): string + { + return match ($this) { + static::Active => 'check', + static::Demo => 'preview', + static::Inactive => 'clock', + static::Legacy => 'alert', + static::Missing => 'key', + static::Unknown => 'question', + }; + } + + /** + * The info text is shown in the license dialog + * in the status row. + */ + public function info(string|null $end = null): string + { + return I18n::template('license.status.' . $this->value . '.info', ['date' => $end]); + } + + /** + * Label for the system view + */ + public function label(): string + { + return I18n::translate('license.status.' . $this->value . '.label'); + } + + /** + * Checks if the license can be renewed + * The license dialog will show the renew + * button in this case and redirect to the hub + */ + public function renewable(): bool + { + return match ($this) { + static::Active, + static::Demo => false, + default => true + }; + } + + /** + * Returns the theme according to the status + * The theme is used for the label in the system + * view and the status icon in the license dialog. + */ + public function theme(): string + { + return match ($this) { + static::Active => 'positive', + static::Demo => 'notice', + static::Inactive => 'notice', + static::Legacy => 'negative', + static::Missing => 'love', + static::Unknown => 'passive', + }; + } + + /** + * Returns the status as string value + */ + public function value(): string + { + return $this->value; + } +} diff --git a/public/kirby/src/Cms/LicenseType.php b/public/kirby/src/Cms/LicenseType.php new file mode 100644 index 0000000..6a9fea7 --- /dev/null +++ b/public/kirby/src/Cms/LicenseType.php @@ -0,0 +1,111 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @codeCoverageIgnore + */ +enum LicenseType: string +{ + /** + * New basic licenses + */ + case Basic = 'basic'; + + /** + * New enterprise licenses + */ + case Enterprise = 'enterprise'; + + /** + * Invalid license codes + */ + case Invalid = 'invalid'; + + /** + * Old Kirby 3 licenses + */ + case Legacy = 'legacy'; + + /** + * Detects the correct LicenseType based on the code + */ + public static function detect(string|null $code): static + { + return match (true) { + static::Basic->isValidCode($code) => static::Basic, + static::Enterprise->isValidCode($code) => static::Enterprise, + static::Legacy->isValidCode($code) => static::Legacy, + default => static::Invalid + }; + } + + /** + * Checks for a valid license code + * by prefix and length. This is just a + * rough validation. + */ + public function isValidCode(string|null $code): bool + { + return + $code !== null && + Str::length($code) === $this->length() && + Str::startsWith($code, $this->prefix()) === true; + } + + /** + * The expected lengths of the license code + */ + public function length(): int + { + return match ($this) { + static::Basic => 38, + static::Enterprise => 38, + static::Legacy => 39, + static::Invalid => 0, + }; + } + + /** + * A human-readable license type label + */ + public function label(): string + { + return match ($this) { + static::Basic => 'Kirby Basic', + static::Enterprise => 'Kirby Enterprise', + static::Legacy => 'Kirby 3', + static::Invalid => I18n::translate('license.unregistered.label'), + }; + } + + /** + * The expected prefix for the license code + */ + public function prefix(): string|null + { + return match ($this) { + static::Basic => 'K-BAS-', + static::Enterprise => 'K-ENT-', + static::Legacy => 'K3-PRO-', + static::Invalid => null, + }; + } + + /** + * Returns the enum value + */ + public function value(): string + { + return $this->value; + } +} diff --git a/public/kirby/src/Cms/Loader.php b/public/kirby/src/Cms/Loader.php new file mode 100644 index 0000000..1fcc06b --- /dev/null +++ b/public/kirby/src/Cms/Loader.php @@ -0,0 +1,208 @@ +load()` and the + * `$kirby->core()->load()` methods. + * + * With `$kirby->load()` you get access to core parts + * that might be overwritten by plugins. + * + * With `$kirby->core()->load()` you get access to + * untouched core parts. This is useful if you want to + * reuse or fall back to core features in your plugins. + * + * @package Kirby Cms + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Loader +{ + public function __construct( + protected App $kirby, + protected bool $withPlugins = true + ) { + } + + /** + * Loads the area definition + */ + public function area(string $name): array|null + { + return $this->areas()[$name] ?? null; + } + + /** + * Loads all areas and makes sure that plugins + * are injected properly + */ + public function areas(): array + { + $areas = []; + $extensions = match ($this->withPlugins) { + true => $this->kirby->extensions('areas'), + false => [] + }; + + // load core areas and extend them with elements + // from plugins if they exist + foreach ($this->kirby->core()->areas() as $id => $area) { + $area = $this->resolveArea($area); + + if (isset($extensions[$id]) === true) { + foreach ($extensions[$id] as $areaExtension) { + $extension = $this->resolveArea($areaExtension); + $area = array_replace_recursive($area, $extension); + } + + unset($extensions[$id]); + } + + $areas[$id] = $area; + } + + // add additional areas from plugins + foreach ($extensions as $id => $areaExtensions) { + foreach ($areaExtensions as $areaExtension) { + $areas[$id] = $this->resolve($areaExtension); + } + } + + return $areas; + } + + /** + * Loads a core component closure + */ + public function component(string $name): Closure|null + { + return $this->extension('components', $name); + } + + /** + * Loads all core component closures + */ + public function components(): array + { + return $this->extensions('components'); + } + + /** + * Loads a particular extension + */ + public function extension(string $type, string $name): mixed + { + return $this->extensions($type)[$name] ?? null; + } + + /** + * Loads all defined extensions + */ + public function extensions(string $type): array + { + return match ($this->withPlugins) { + true => $this->kirby->extensions($type), + false => $this->kirby->core()->$type() + }; + } + + /** + * The resolver takes a string, array or closure. + * + * 1.) a string is supposed to be a path to an existing file. + * The file will either be included when it's a PHP file and + * the array contents will be read. Or it will be parsed with + * the Data class to read yml or json data into an array + * + * 2.) arrays are untouched and returned + * + * 3.) closures will be called and the Kirby instance will be + * passed as first argument + */ + public function resolve(mixed $item): mixed + { + if (is_string($item) === true) { + $item = match (F::extension($item)) { + 'php' => F::load($item, allowOutput: false), + default => Data::read($item) + }; + } + + if (is_callable($item) === true) { + $item = $item($this->kirby); + } + + return $item; + } + + /** + * Calls `static::resolve()` on all items + * in the given array + */ + public function resolveAll(array $items): array + { + $result = []; + + foreach ($items as $key => $value) { + $result[$key] = $this->resolve($value); + } + + return $result; + } + + /** + * Areas need a bit of special treatment + * when they are being loaded + */ + public function resolveArea(string|array|Closure $area): array + { + $area = $this->resolve($area); + + // convert closure dropdowns to an array definition + // otherwise they cannot be merged properly later + foreach ($area['dropdowns'] ?? [] as $key => $dropdown) { + if ($dropdown instanceof Closure) { + $area['dropdowns'][$key] = [ + 'options' => $dropdown + ]; + } + } + + return $area; + } + + /** + * Loads a particular section definition + */ + public function section(string $name): array|null + { + return $this->resolve($this->extension('sections', $name)); + } + + /** + * Loads all section defintions + */ + public function sections(): array + { + return $this->resolveAll($this->extensions('sections')); + } + + /** + * Returns the status flag, which shows + * if plugins are loaded as well. + */ + public function withPlugins(): bool + { + return $this->withPlugins; + } +} diff --git a/public/kirby/src/Cms/Media.php b/public/kirby/src/Cms/Media.php new file mode 100644 index 0000000..11a501e --- /dev/null +++ b/public/kirby/src/Cms/Media.php @@ -0,0 +1,194 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Media +{ + /** + * Tries to find a file by model and filename + * and to copy it to the media folder. + */ + public static function link( + Page|Site|User|null $model, + string $hash, + string $filename + ): Response|false { + if ($model === null) { + return false; + } + + // fix issues with spaces in filenames + $filename = urldecode($filename); + + // try to find a file by model and filename + // this should work for all original files + if ($file = $model->file($filename)) { + // check if the request contained an outdated media hash + if ($file->mediaHash() !== $hash) { + // if at least the token was correct, redirect + if (Str::startsWith($hash, $file->mediaToken() . '-') === true) { + return Response::redirect($file->mediaUrl(), 307); + } + + // don't leak the correct token, render the error page + return false; + } + + // send the file to the browser + return Response::file($file->publish()->mediaRoot()); + } + + // try to generate a thumb for the file + try { + return static::thumb($model, $hash, $filename); + } catch (NotFoundException) { + // render the error page if there is no job for this filename + return false; + } + } + + /** + * Copy the file to the final media folder location + */ + public static function publish(File $file, string $dest): bool + { + // never publish risky files (e.g. HTML, PHP or Apache config files) + FileRules::validFile($file, false); + + $src = $file->root(); + $version = dirname($dest); + $directory = dirname($version); + + // unpublish all files except stuff in the version folder + Media::unpublish($directory, $file, $version); + + // copy/overwrite the file to the dest folder + return F::copy($src, $dest, true); + } + + /** + * Tries to find a job file for the + * given filename and then calls the thumb + * component to create a thumbnail accordingly + */ + public static function thumb( + File|Page|Site|User|string $model, + string $hash, + string $filename + ): Response|false { + $kirby = App::instance(); + $index = $kirby->root('index'); + $media = $kirby->root('media'); + + $root = match (true) { + // assets + is_string($model) + => $media . '/assets/' . $model . '/' . $hash, + // parent files for file model that already included hash + $model instanceof File + => $model->mediaDir(), + // model files + default + => $model->mediaRoot() . '/' . $hash + }; + + 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( + message: 'The thumbnail configuration could not be found' + ); + } + + if (empty($options['filename']) === true) { + throw new InvalidArgumentException( + message: 'Incomplete thumbnail configuration' + ); + } + + try { + // find the correct source file depending on the model + // this adds support for custom assets + $source = match (true) { + is_string($model) === true + => F::realpath( + $index . '/' . $model . '/' . $options['filename'], + $index + ), + $model instanceof File + => $model->root(), + default + => $model->file($options['filename'])->root() + }; + + // generate the thumbnail and save it in the media folder + $kirby->thumb($source, $thumb, $options); + + // remove the job file once the thumbnail has been created + F::remove($job); + + // read the file and send it to the browser + return Response::file($thumb); + } catch (Throwable $e) { + // remove potentially broken thumbnails + F::remove($thumb); + throw $e; + } + } + + /** + * Deletes all versions of the given file + * within the parent directory + */ + public static function unpublish( + string $directory, + File $file, + string|null $ignore = null + ): bool { + if (is_dir($directory) === false) { + return true; + } + + // get both old and new versions (pre and post Kirby 3.4.0) + $versions = [ + ...glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR), + ...glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR) + ]; + + // delete all versions of the file + foreach ($versions as $version) { + if ($version === $ignore) { + continue; + } + + Dir::remove($version); + } + + return true; + } +} 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 new file mode 100644 index 0000000..798623b --- /dev/null +++ b/public/kirby/src/Cms/ModelPermissions.php @@ -0,0 +1,186 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class ModelPermissions +{ + protected const CATEGORY = 'model'; + protected array $options; + + protected static array $cache = []; + + public function __construct(protected ModelWithContent|Language $model) + { + $this->options = match (true) { + $model instanceof ModelWithContent => $model->blueprint()->options(), + default => [] + }; + } + + public function __call(string $method, array $arguments = []): bool + { + return $this->can($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + 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 + * + * @param bool $default Will be returned if $action does not exist + */ + public function can( + string $action, + bool $default = false + ): bool { + $user = static::user(); + $userId = $user->id(); + $role = $user->role()->id(); + + // users with the `nobody` role can do nothing + // that needs a permission check + if ($role === 'nobody') { + return false; + } + + // check for a custom `can` method + // which would take priority over any other + // role-based permission rules + if ( + method_exists($this, 'can' . $action) === true && + $this->{'can' . $action}() === false + ) { + return false; + } + + // the almighty `kirby` user can do anything + if ($userId === 'kirby' && $role === 'admin') { + return true; + } + + // evaluate the blueprint options block + if (isset($this->options[$action]) === true) { + $options = $this->options[$action]; + + if ($options === false) { + return false; + } + + if ($options === true) { + return true; + } + + if ( + is_array($options) === true && + A::isAssociative($options) === true + ) { + if (isset($options[$role]) === true) { + return $options[$role]; + } + + if (isset($options['*']) === true) { + return $options['*']; + } + } + } + + $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); + } + + /** + * Returns whether the current user is not allowed to do + * a certain action on the model + * + * @param bool $default Will be returned if $action does not exist + */ + public function cannot( + string $action, + bool $default = true + ): bool { + 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 = []; + + foreach ($this->options as $key => $value) { + $array[$key] = $this->can($key); + } + + 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 new file mode 100644 index 0000000..7c87dc8 --- /dev/null +++ b/public/kirby/src/Cms/ModelWithContent.php @@ -0,0 +1,732 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class ModelWithContent implements Identifiable, Stringable +{ + /** + * 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; + + /** + * Cached array of valid blueprints + * that could be used for the model + */ + public array|null $blueprints = null; + + public static App $kirby; + protected Site|null $site; + protected Storage $storage; + + /** + * Store values used to initilaize object + */ + protected array $propertyData = []; + + public function __construct(array $props = []) + { + $this->site = $props['site'] ?? null; + + $this->setContent($props['content'] ?? null); + $this->setTranslations($props['translations'] ?? null); + + $this->propertyData = $props; + } + + /** + * Returns the blueprint of the model + */ + abstract public function blueprint(): Blueprint; + + /** + * Returns an array with all blueprints that are available + */ + public function blueprints(string|null $inSection = null): array + { + // helper function + $toBlueprints = static function (array $sections): array { + $blueprints = []; + + foreach ($sections as $section) { + if ($section === null) { + continue; + } + + foreach ((array)$section->blueprints() as $blueprint) { + $blueprints[$blueprint['name']] = $blueprint; + } + } + + return array_values($blueprints); + }; + + $blueprint = $this->blueprint(); + + // no caching for when collecting for specific section + if ($inSection !== null) { + return $toBlueprints([$blueprint->section($inSection)]); + } + + 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 + * + * @todo eventually refactor without need of propertyData + */ + public function clone(array $props = []): static + { + $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; + } + + /** + * Executes any given model action + */ + abstract protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed; + + /** + * Returns the content + * + * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist + */ + public function content(string|null $languageCode = null): Content + { + // get the targeted language + $language = Language::ensure($languageCode ?? 'current'); + $versionId = VersionId::$render ?? 'latest'; + $version = $this->version($versionId); + + if ($version->exists($language) === true) { + return $version->content($language); + } + + return $this->version()->content($language); + } + + /** + * Prepares the content that should be written + * to the text file + * @unstable + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + return $data; + } + + /** + * Converts model to new blueprint + * incl. its content for all translations + */ + protected function convertTo(string $blueprint): static + { + // 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]); + + // 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); + + // 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 + ); + + // Get all languages to loop through + $languages = Languages::ensure(); + + // 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; + } + + // Convert the content to the new blueprint + $content = $oldVersion->content($language)->convertTo($blueprint); + + // 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); + } + } + + return $new; + } + + /** + * 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(); + } + + /** + * Decrement a given field value + */ + public function decrement( + string $field, + int $by = 1, + int $min = 0 + ): static { + $value = (int)$this->content()->get($field)->value() - $by; + + if ($value < $min) { + $value = $min; + } + + return $this->update([$field => $value]); + } + + /** + * Returns all content validation errors + */ + public function errors(): array + { + $errors = []; + + foreach ($this->blueprint()->sections() as $section) { + $errors = [...$errors, ...$section->errors()]; + } + + return $errors; + } + + /** + * Creates a clone and fetches all + * lazy-loaded getters to get a full copy + */ + public function hardcopy(): static + { + $clone = $this->clone(); + + foreach (get_object_vars($clone) as $name => $default) { + if (method_exists($clone, $name) === true) { + $clone->$name(); + } + } + + return $clone; + } + + /** + * Each model must return a unique id + */ + public function id(): string|null + { + return null; + } + + /** + * Increment a given field value + */ + public function increment( + string $field, + int $by = 1, + int|null $max = null + ): static { + $value = (int)$this->content()->get($field)->value() + $by; + + if ($max && $value > $max) { + $value = $max; + } + + return $this->update([$field => $value]); + } + + /** + * Checks if the model is locked for the current user + * @deprecated 5.0.0 Use `->lock()->isLocked()` instead + */ + public function isLocked(): bool + { + return $this->lock()->isLocked() === true; + } + + /** + * Checks if the data has any errors + */ + public function isValid(): bool + { + return $this->version('latest')->isValid('current'); + } + + /** + * Returns the parent Kirby instance + */ + public function kirby(): App + { + return static::$kirby ??= App::instance(); + } + + /** + * Returns lock for the model + */ + public function lock(): Lock + { + return $this->version('changes')->lock('*'); + } + + /** + * Returns the panel info of the model + * @since 3.6.0 + */ + abstract public function panel(): Model; + + /** + * Must return the permissions object for the model + */ + abstract public function permissions(): ModelPermissions; + + /** + * Clean internal caches + * + * @return $this + */ + public function purge(): static + { + $this->blueprints = null; + return $this; + } + + /** + * Creates a string query, starting from the model + */ + public function query( + string|null $query = null, + string|null $expect = null + ): mixed { + if ($query === null) { + return null; + } + + try { + $result = Str::query($query, [ + 'kirby' => $this->kirby(), + 'site' => $this instanceof Site ? $this : $this->site(), + 'model' => $this, + static::CLASS_ALIAS => $this + ]); + } catch (Throwable) { + return null; + } + + if ($expect !== null && $result instanceof $expect === false) { + return null; + } + + return $result; + } + + /** + * Read the content from the content file + * @internal + * @deprecated 5.0.0 Use `->version()->read()` instead + */ + public function readContent(string|null $languageCode = null): array + { + Helpers::deprecated('$model->readContent() is deprecated. Use $model->version()->read() instead.'); // @codeCoverageIgnore + return $this->version()->read($languageCode ?? 'default') ?? []; + } + + /** + * Returns the absolute path to the model + */ + abstract public function root(): string|null; + + /** + * 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 { + // create a clone to avoid modifying the original + $clone = $this->clone(); + + // move the old model into memory + $this->changeStorage( + toStorage: new ImmutableMemoryStorage( + model: $this, + nextModel: $clone + ), + copy: true + ); + + // update the clone + $clone->version()->save( + $data ?? [], + $languageCode ?? 'current', + $overwrite + ); + + ModelState::update( + method: 'set', + current: $this, + next: $clone + ); + + return $clone; + } + + /** + * @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 { + Helpers::deprecated('$model->saveTranslation() is deprecated. Use $model->save() instead.'); + return $this->save($data, $languageCode ?? 'default', $overwrite); + } + + /** + * Sets the Content object + * + * @return $this + */ + protected function setContent(array|null $content = null): static + { + if ($content === null) { + return $this; + } + + $this->changeStorage(MemoryStorage::class, copy: true); + $this->version()->save($content, 'default'); + + return $this; + } + + /** + * Create the translations collection from an array + * + * @return $this + */ + protected function setTranslations(array|null $translations = null): static + { + if ($translations === null) { + return $this; + } + + $this->changeStorage(MemoryStorage::class, copy: true); + + Translations::create( + model: $this, + version: $this->version(), + translations: $translations + ); + + return $this; + } + + /** + * Returns the parent Site instance + */ + public function site(): Site + { + return $this->site ??= $this->kirby()->site(); + } + + /** + * Returns the content storage handler + */ + public function storage(): Storage + { + return $this->storage ??= $this->kirby()->storage($this); + } + + /** + * Convert the model to a simple array + */ + public function toArray(): array + { + return [ + 'content' => $this->content()->toArray(), + 'translations' => $this->translations()->toArray() + ]; + } + + /** + * String template builder with automatic HTML escaping + * @since 3.6.0 + * + * @param string|null $template Template string or `null` to use the model ID + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced + * (`null` to keep the original token) + */ + public function toSafeString( + string|null $template = null, + array $data = [], + string|null $fallback = '' + ): string { + return $this->toString($template, $data, $fallback, 'safeTemplate'); + } + + /** + * String template builder + * + * @param string|null $template Template string or `null` to use the model ID + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced + * (`null` to keep the original token) + * @param string $handler For internal use + */ + public function toString( + string|null $template = null, + array $data = [], + string|null $fallback = '', + string $handler = 'template' + ): string { + if ($template === null) { + return $this->id() ?? ''; + } + + if ($handler !== 'template' && $handler !== 'safeTemplate') { + throw new InvalidArgumentException(message: 'Invalid toString handler'); // @codeCoverageIgnore + } + + $result = Str::$handler($template, array_replace([ + 'kirby' => $this->kirby(), + 'site' => $this instanceof Site ? $this : $this->site(), + 'model' => $this, + static::CLASS_ALIAS => $this, + ], $data), ['fallback' => $fallback]); + + return $result; + } + + /** + * Makes it possible to convert the entire model + * to a string. Mostly useful for debugging + */ + public function __toString(): string + { + 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 + ): Translation { + $language = Language::ensure($languageCode ?? 'current'); + + return new Translation( + model: $this, + version: $this->version(), + language: $language + ); + } + + /** + * Returns the translations collection + */ + public function translations(): Translations + { + return Translations::load( + model: $this, + version: $this->version() + ); + } + + /** + * Updates the model data + * + * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values + */ + public function update( + array|null $input = null, + string|null $languageCode = null, + bool $validate = false + ): static { + $form = Form::for( + model: $this, + language: $languageCode, + ); + + $form->submit( + input: $input ?? [], + force: $validate === false + ); + + if ($validate === true) { + $form->validate(); + } + + return $this->commit( + 'update', + [ + static::CLASS_ALIAS => $this, + 'values' => $form->toFormValues(), + 'strings' => $form->toStoredValues(), + 'languageCode' => $languageCode + ], + fn ($model, $values, $strings, $languageCode) => + $model->save($strings, $languageCode, true) + ); + } + + /** + * Returns the model's UUID + * @since 3.8.0 + */ + public function uuid(): Uuid|null + { + 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 + { + 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/Nest.php b/public/kirby/src/Cms/Nest.php new file mode 100644 index 0000000..0f8521d --- /dev/null +++ b/public/kirby/src/Cms/Nest.php @@ -0,0 +1,49 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Nest +{ + public static function create( + $data, + object|null $parent = null + ): NestCollection|NestObject|Field { + if (is_scalar($data) === true) { + return new Field($parent, $data, $data); + } + + $result = []; + + foreach ($data as $key => $value) { + if (is_array($value) === true) { + $result[$key] = static::create($value, $parent); + } elseif (is_scalar($value) === true) { + $result[$key] = new Field($parent, $key, $value); + } + } + + $key = key($data); + + if ($key === null || is_int($key) === true) { + return new NestCollection($result); + } + + return new NestObject($result); + } +} diff --git a/public/kirby/src/Cms/NestCollection.php b/public/kirby/src/Cms/NestCollection.php new file mode 100644 index 0000000..1ef051b --- /dev/null +++ b/public/kirby/src/Cms/NestCollection.php @@ -0,0 +1,32 @@ + + * @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 +{ + /** + * Converts all objects in the collection + * to an array. This can also take a callback + * function to further modify the array result. + */ + public function toArray(Closure|null $map = null): array + { + return parent::toArray( + $map ?? fn ($object) => $object->toArray() + ); + } +} diff --git a/public/kirby/src/Cms/NestObject.php b/public/kirby/src/Cms/NestObject.php new file mode 100644 index 0000000..2466023 --- /dev/null +++ b/public/kirby/src/Cms/NestObject.php @@ -0,0 +1,45 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class NestObject extends Obj +{ + /** + * Converts the object to an array + */ + public function toArray(): array + { + $result = []; + + foreach ((array)$this as $key => $value) { + if ($value instanceof Field) { + $result[$key] = $value->value(); + continue; + } + + if ( + is_object($value) === true && + method_exists($value, 'toArray') + ) { + $result[$key] = $value->toArray(); + continue; + } + + $result[$key] = $value; + } + + return $result; + } +} diff --git a/public/kirby/src/Cms/Page.php b/public/kirby/src/Cms/Page.php new file mode 100644 index 0000000..a764ed1 --- /dev/null +++ b/public/kirby/src/Cms/Page.php @@ -0,0 +1,1306 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Pages> + * @method \Kirby\Uuid\PageUuid uuid() + */ +class Page extends ModelWithContent +{ + use HasChildren; + use HasFiles; + use HasMethods; + use HasModels; + use HasSiblings; + use PageActions; + use PageSiblings; + + public const CLASS_ALIAS = 'page'; + + /** + * All registered page methods + * @todo Remove when support for PHP 8.2 is dropped + */ + public static array $methods = []; + + /** + * The PageBlueprint object + */ + protected PageBlueprint|null $blueprint = null; + + /** + * Nesting level + */ + protected int $depth; + + /** + * Sorting number + slug + */ + protected string|null $dirname; + + /** + * Path of dirnames + */ + protected string|null $diruri = null; + + /** + * Draft status flag + */ + protected bool $isDraft; + + /** + * The Page id + */ + protected string|null $id = null; + + /** + * The template, that should be loaded + * if it exists + */ + protected Template|null $intendedTemplate = null; + + protected array|null $inventory = null; + + /** + * The sorting number + */ + protected int|null $num; + + /** + * The parent page + */ + protected Page|null $parent; + + /** + * Absolute path to the page directory + */ + protected string|null $root; + + /** + * The URL-appendix aka slug + */ + protected string $slug; + + /** + * The intended page template + */ + protected Template|null $template = null; + + /** + * The page url + */ + protected string|null $url; + + /** + * Creates a new page object + */ + public function __construct(array $props) + { + if (isset($props['slug']) === false) { + throw new InvalidArgumentException( + message: 'The page slug is required' + ); + } + + $this->slug = $props['slug']; + // Sets the dirname manually, which works + // more reliable in connection with the inventory + // than computing the dirname afterwards + $this->dirname = $props['dirname'] ?? null; + $this->isDraft = $props['isDraft'] ?? false; + $this->num = $props['num'] ?? null; + $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); + $this->setTemplate($props['template'] ?? null); + $this->setUrl($props['url'] ?? null); + } + + /** + * Magic caller + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // page methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // return page content otherwise + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + ...$this->toArray(), + 'content' => $this->content(), + 'children' => $this->children(), + 'siblings' => $this->siblings(), + 'translations' => $this->translations(), + 'files' => $this->files(), + ]; + } + + /** + * Returns the url to the api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + if ($relative === true) { + return 'pages/' . $this->panel()->id(); + } + + return $this->kirby()->url('api') . '/pages/' . $this->panel()->id(); + } + + /** + * Returns the blueprint object + */ + public function blueprint(): PageBlueprint + { + return $this->blueprint ??= PageBlueprint::factory( + 'pages/' . $this->intendedTemplate(), + 'pages/default', + $this + ); + } + + /** + * Returns an array with all blueprints that are available for the page + */ + public function blueprints(string|null $inSection = null): array + { + if ($inSection !== null) { + return $this->blueprint()->section($inSection)->blueprints(); + } + + if ($this->blueprints !== null) { + return $this->blueprints; + } + + $blueprints = []; + $templates = $this->blueprint()->changeTemplate() ?? null; + $templates ??= $this->blueprint()->options()['changeTemplate'] ?? []; + + $currentTemplate = $this->intendedTemplate()->name(); + + if (is_array($templates) === false) { + $templates = []; + } + + // add the current template to the array if it's not already there + if (in_array($currentTemplate, $templates, true) === false) { + array_unshift($templates, $currentTemplate); + } + + // make sure every template is only included once + $templates = array_unique($templates); + + foreach ($templates as $template) { + try { + $props = Blueprint::load('pages/' . $template); + + $blueprints[] = [ + 'name' => basename($props['name']), + 'title' => $props['title'], + ]; + } catch (Exception) { + // skip invalid blueprints + } + } + + return $this->blueprints = array_values($blueprints); + } + + /** + * Builds the cache id for the page + */ + protected function cacheId(string $contentType, VersionId $versionId): string + { + $cacheId = [$this->id()]; + + if ($this->kirby()->multilang() === true) { + $cacheId[] = $this->kirby()->language()->code(); + } + + $cacheId[] = $versionId->value(); + $cacheId[] = $contentType; + + return implode('.', $cacheId); + } + + /** + * Prepares the content for the write method + * @internal + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + return A::prepend($data, [ + 'title' => $data['title'] ?? null, + 'slug' => $data['slug'] ?? null + ]); + } + + /** + * Call the page controller + * + * @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page` + */ + public function controller( + array $data = [], + string $contentType = 'html' + ): array { + // create the template 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( + $this->template()->name(), + $data, + $contentType + ); + + // merge controller data with original data safely + // to provide original data to template even if + // it wasn't returned by the controller explicitly + if ($controllerData !== []) { + $classes = [ + 'kirby' => App::class, + 'site' => Site::class, + 'pages' => Pages::class, + 'page' => Page::class + ]; + + foreach ($controllerData as $key => $value) { + $data[$key] = match (true) { + // original data wasn't overwritten + array_key_exists($key, $classes) === false => $value, + // 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( + message: 'The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"' + ) + }; + } + } + + // unwrap remaining lazy values in data + // (happens if the controller didn't override an original lazy Kirby object) + $data = LazyValue::unwrap($data); + + return $data; + } + + /** + * Returns a number indicating how deep the page + * is nested within the content folder + */ + public function depth(): int + { + return $this->depth ??= (substr_count($this->id(), '/') + 1); + } + + /** + * Returns the directory name (UID with optional sorting number) + */ + public function dirname(): string + { + if ($this->dirname !== null) { + return $this->dirname; + } + + if ($this->num() !== null) { + return $this->dirname = $this->num() . Dir::$numSeparator . $this->uid(); + } + + return $this->dirname = $this->uid(); + } + + /** + * Returns the directory path relative to the `content` root + * (including optional sorting numbers and draft directories) + */ + public function diruri(): string + { + if (is_string($this->diruri) === true) { + return $this->diruri; + } + + $dirname = match ($this->isDraft()) { + true => '_drafts/' . $this->dirname(), + false => $this->dirname() + }; + + if ($parent = $this->parent()) { + return $this->diruri = $parent->diruri() . '/' . $dirname; + } + + return $this->diruri = $dirname; + } + + /** + * Checks if the page exists on disk + */ + public function exists(): bool + { + return is_dir($this->root()) === true; + } + + /** + * Constructs a Page object and also + * takes page models into account. + */ + public static function factory($props): static + { + return static::model($props['model'] ?? $props['template'] ?? 'default', $props); + } + + /** + * Redirects to this page, + * wrapper for the `go()` helper + * + * @since 3.4.0 + * + * @param array $options Options for `Kirby\Http\Uri` to create URL parts + * @param int $code HTTP status code + */ + public function go(array $options = [], int $code = 302): void + { + Response::go($this->url($options), $code); + } + + /** + * Checks if the intended template + * for the page exists. + */ + public function hasTemplate(): bool + { + return $this->intendedTemplate() === $this->template(); + } + + /** + * Returns the Page Id + */ + public function id(): string + { + if ($this->id !== null) { + return $this->id; + } + + // set the id, depending on the parent + if ($parent = $this->parent()) { + return $this->id = $parent->id() . '/' . $this->uid(); + } + + return $this->id = $this->uid(); + } + + /** + * Returns the template that should be + * loaded if it exists. + */ + public function intendedTemplate(): Template + { + if ($this->intendedTemplate !== null) { + return $this->intendedTemplate; + } + + return $this + ->setTemplate($this->inventory()['template']) + ->intendedTemplate(); + } + + /** + * Returns the inventory of files children and content files + */ + public function inventory(): array + { + if ($this->inventory !== null) { + return $this->inventory; + } + + $kirby = $this->kirby(); + + return $this->inventory = Dir::inventory( + $this->root(), + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + } + + /** + * Compares the current object with the given page object + * + * @param \Kirby\Cms\Page|string $page + */ + public function is($page): bool + { + if ($page instanceof self === false) { + if (is_string($page) === false) { + return false; + } + + $page = $this->kirby()->page($page); + } + + if ($page instanceof self === false) { + return false; + } + + return $this->id() === $page->id(); + } + + /** + * 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 v6 + if ($this->isReadable() === false) { + return false; + } + + return PagePermissions::canFromCache($this, 'access'); + } + + /** + * Checks if the page is the current page + */ + public function isActive(): bool + { + return $this->site()->page()?->is($this) === true; + } + + /** + * Checks if the page is a direct or indirect ancestor + * of the given $page object + */ + public function isAncestorOf(Page $child): bool + { + return $child->parents()->has($this->id()) === true; + } + + /** + * Checks if the page can be cached in the + * pages cache. This will also check if one + * of the ignore rules from the config kick in. + */ + public function isCacheable(VersionId|null $versionId = null): bool + { + $kirby = $this->kirby(); + $cache = $kirby->cache('pages'); + $options = $cache->options(); + $ignore = $options['ignore'] ?? null; + + // the pages cache is switched off + if (($options['active'] ?? false) === false) { + 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'], true) === false) { + return false; + } + + // disable the pages cache when there's request data + if (empty($request->data()) === false) { + return false; + } + + // disable the pages cache when there are any params + if ($request->params()->isNotEmpty()) { + return false; + } + + // check for a custom ignore rule + if ($ignore instanceof Closure) { + if ($ignore($this) === true) { + return false; + } + } + + // ignore pages by id + if (is_array($ignore) === true) { + if (in_array($this->id(), $ignore, true) === true) { + return false; + } + } + + return true; + } + + /** + * Checks if the page is a child of the given page + * + * @param \Kirby\Cms\Page|string $parent + */ + public function isChildOf($parent): bool + { + return $this->parent()?->is($parent) ?? false; + } + + /** + * Checks if the page is a descendant of the given page + * + * @param \Kirby\Cms\Page|string $parent + */ + public function isDescendantOf($parent): bool + { + if (is_string($parent) === true) { + $parent = $this->site()->find($parent); + } + + if (!$parent) { + return false; + } + + return $this->parents()->has($parent->id()) === true; + } + + /** + * Checks if the page is a descendant of the currently active page + */ + public function isDescendantOfActive(): bool + { + if ($active = $this->site()->page()) { + return $this->isDescendantOf($active); + } + + return false; + } + + /** + * Checks if the current page is a draft + */ + public function isDraft(): bool + { + return $this->isDraft; + } + + /** + * Checks if the page is the error page + */ + public function isErrorPage(): bool + { + return $this->id() === $this->site()->errorPageId(); + } + + /** + * Checks if the page is the home page + */ + public function isHomePage(): bool + { + return $this->id() === $this->site()->homePageId(); + } + + /** + * It's often required to check for the + * home and error page to stop certain + * actions. That's why there's a shortcut. + */ + public function isHomeOrErrorPage(): bool + { + return $this->isHomePage() === true || $this->isErrorPage() === true; + } + + /** + * Check if the page can be listable by the current user + * This permission depends on the `read` option until v6 + */ + public function isListable(): bool + { + // TODO: remove this check when `read` option deprecated in v6 + if ($this->isReadable() === false) { + return false; + } + + // not accessible also means not listable + if ($this->isAccessible() === false) { + return false; + } + + return PagePermissions::canFromCache($this, 'list'); + } + + /** + * Checks if the page has a sorting number + */ + public function isListed(): bool + { + return $this->isPublished() && $this->num() !== null; + } + + public function isMovableTo(Page|Site $parent): bool + { + try { + PageRules::move($this, $parent); + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Checks if the page is open. + * Open pages are either the current one + * or descendants of the current one. + */ + public function isOpen(): bool + { + if ($this->isActive() === true) { + return true; + } + + if ($this->site()->page()?->parents()->has($this->id()) === true) { + return true; + } + + return false; + } + + /** + * Checks if the page is not a draft. + */ + public function isPublished(): bool + { + return $this->isDraft() === false; + } + + /** + * Check if the page can be read by the current user + * @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()->role()?->id() ?? '__none__'; + $template = $this->intendedTemplate()->name(); + $readable[$role] ??= []; + + return $readable[$role][$template] ??= $this->permissions()->can('read'); + } + + /** + * Checks if the page is sortable + */ + public function isSortable(): bool + { + return $this->permissions()->can('sort'); + } + + /** + * Checks if the page has no sorting number + */ + public function isUnlisted(): bool + { + return $this->isPublished() && $this->num() === null; + } + + /** + * Returns the absolute path to the media folder for the page + */ + 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 + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/pages/' . $this->id(); + } + + /** + * Returns the last modification date of the page + */ + public function modified( + string|null $format = null, + string|null $handler = null, + string|null $languageCode = null + ): int|string|false|null { + $modified = $this->version()->modified( + $languageCode ?? 'current' + ); + + if ($modified === null) { + return null; + } + + return Str::date($modified, $format, $handler); + } + + /** + * Returns the sorting number + */ + public function num(): int|null + { + return $this->num; + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the parent Page object + */ + public function parent(): Page|null + { + return $this->parent; + } + + /** + * Returns the parent id, if a parent exists + */ + public function parentId(): string|null + { + return $this->parent()?->id(); + } + + /** + * Returns the parent model, + * which can either be another Page + * or the Site + */ + public function parentModel(): Page|Site + { + return $this->parent() ?? $this->site(); + } + + /** + * Returns a list of all parents and their parents recursively + */ + public function parents(): Pages + { + $parents = new Pages(); + $page = $this->parent(); + + while ($page instanceof Page) { + $parents->append($page->id(), $page); + $page = $page->parent(); + } + + return $parents; + } + + /** + * Return the permanent URL to the page using its UUID + * @since 3.8.0 + */ + public function permalink(): string|null + { + return $this->uuid()?->toPermalink(); + } + + /** + * Returns the permissions object for this page + */ + public function permissions(): PagePermissions + { + return new PagePermissions($this); + } + + /** + * Returns the preview URL with authentication for drafts and versions + * @unstable + */ + public function previewUrl(VersionId|string $versionId = 'latest'): string|null + { + if ($this->permissions()->can('preview') !== true) { + return null; + } + + return $this->version($versionId)->url(); + } + + /** + * Renders the page with the given data. + * + * An optional content type can be passed to + * render a content representation instead of + * 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', + 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 ($data === [] && $this->isCacheable($versionId) === true) { + $cache = $kirby->cache('pages'); + $cacheId = $this->cacheId($contentType, $versionId); + $result = $cache->get($cacheId); + $html = $result['html'] ?? null; + $response = $result['response'] ?? []; + $usesAuth = $result['usesAuth'] ?? false; + $usesCookies = $result['usesCookies'] ?? []; + + // if the request contains dynamic data that the cached response + // relied on, don't use the cache to allow dynamic code to run + if (Responder::isPrivate($usesAuth, $usesCookies) === true) { + $html = null; + } + + // reconstruct the response configuration + if (empty($html) === false && empty($response) === false) { + $kirby->response()->fromArray($response); + } + } + + // fetch the page regularly + if ($html === null) { + // 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' + ); + } + + $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'); + + // render the page + $html = $template->render($kirby->data); + + // 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(); + if ($cache !== null && $response->cache() === true) { + $cache->set($cacheId, [ + 'html' => $html, + 'response' => $response->toArray(), + 'usesAuth' => $response->usesAuth(), + 'usesCookies' => $response->usesCookies(), + ], $response->expires() ?? 0); + } + } + + return $html; + } + + /** + * 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 + { + $kirby = $this->kirby(); + $template = $this->template(); + $representation = $kirby->template($template->name(), $type); + + if ($representation->exists() === true) { + return $representation; + } + + throw new NotFoundException( + message: 'The content representation cannot be found' + ); + } + + /** + * Returns the absolute root to the page directory + * No matter if it exists or not. + */ + public function root(): string + { + return $this->root ??= $this->kirby()->root('content') . '/' . $this->diruri(); + } + + /** + * Returns the PageRules class instance + * which is being used in various methods + * to check for valid actions and input. + */ + protected function rules(): PageRules + { + return new PageRules(); + } + + /** + * Search all pages within the current page + */ + public function search(string|null $query = null, string|array $params = []): Pages + { + return $this->index()->search($query, $params); + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array|null $blueprint = null): static + { + if ($blueprint !== null) { + $blueprint['model'] = $this; + $this->blueprint = new PageBlueprint($blueprint); + } + + return $this; + } + + /** + * Sets the intended template + * + * @return $this + */ + protected function setTemplate(string|null $template = null): static + { + if ($template !== null) { + $this->intendedTemplate = $this->kirby()->template(strtolower($template)); + } + + return $this; + } + + /** + * Sets the Url + * + * @return $this + */ + protected function setUrl(string|null $url = null): static + { + if (is_string($url) === true) { + $url = rtrim($url, '/'); + } + + $this->url = $url; + return $this; + } + + /** + * Returns the slug of the page + */ + public function slug(string|null $languageCode = null): string + { + if ($this->kirby()->multilang() === true) { + $languageCode ??= $this->kirby()->languageCode(); + $defaultLanguageCode = $this->kirby()->defaultLanguage()->code(); + + if ( + $languageCode !== $defaultLanguageCode && + $translation = $this->translations()->find($languageCode) + ) { + return $translation->slug() ?? $this->slug; + } + } + + return $this->slug; + } + + /** + * Returns the page status, which + * can be `draft`, `listed` or `unlisted` + */ + public function status(): string + { + if ($this->isDraft() === true) { + return 'draft'; + } + + if ($this->isUnlisted() === true) { + return 'unlisted'; + } + + return 'listed'; + } + + /** + * Returns the final template + */ + public function template(): Template + { + if ($this->template !== null) { + return $this->template; + } + + $intended = $this->intendedTemplate(); + + if ($intended->exists() === true) { + return $this->template = $intended; + } + + return $this->template = $this->kirby()->template('default'); + } + + /** + * Returns the title field or the slug as fallback + */ + public function title(): Field + { + return $this->content()->get('title')->or($this->slug()); + } + + /** + * Converts the most important + * properties to array + */ + public function toArray(): array + { + return [ + ...parent::toArray(), + 'children' => $this->children()->keys(), + 'files' => $this->files()->keys(), + 'id' => $this->id(), + 'mediaUrl' => $this->mediaUrl(), + 'mediaRoot' => $this->mediaRoot(), + 'num' => $this->num(), + 'parent' => $this->parent()?->id(), + 'slug' => $this->slug(), + 'template' => $this->template(), + 'uid' => $this->uid(), + 'uri' => $this->uri(), + 'url' => $this->url() + ]; + } + + /** + * Returns the UID of the page. + * The UID is basically the same as the + * slug, but stays the same on + * multi-language sites. Whereas the slug + * can be translated. + * + * @see self::slug() + */ + public function uid(): string + { + return $this->slug; + } + + /** + * The uri is the same as the id, except + * that it will be translated in multi-language setups + */ + public function uri(string|null $languageCode = null): string + { + // set the id, depending on the parent + if ($parent = $this->parent()) { + return $parent->uri($languageCode) . '/' . $this->slug($languageCode); + } + + return $this->slug($languageCode); + } + + /** + * Returns the Url + * + * @param array|string|null $options + */ + public function url($options = null): string + { + if ($this->kirby()->multilang() === true) { + if (is_string($options) === true) { + return $this->urlForLanguage($options); + } + + return $this->urlForLanguage(null, $options); + } + + if ($options !== null) { + return Url::to($this->url(), $options); + } + + if (is_string($this->url) === true) { + return $this->url; + } + + if ($this->isHomePage() === true) { + return $this->url = $this->site()->url(); + } + + if ($parent = $this->parent()) { + if ($parent->isHomePage() === true) { + return $this->url = $this->kirby()->url('base') . '/' . $parent->uid() . '/' . $this->uid(); + } + + return $this->url = $this->parent()->url() . '/' . $this->uid(); + } + + return $this->url = $this->kirby()->url('base') . '/' . $this->uid(); + } + + /** + * Builds the Url for a specific language + * + * @internal + * @param string|null $language + */ + public function urlForLanguage( + $language = null, + array|null $options = null + ): string { + if ($options !== null) { + return Url::to($this->urlForLanguage($language), $options); + } + + if ($this->isHomePage() === true) { + return $this->url = $this->site()->urlForLanguage($language); + } + + if ($parent = $this->parent()) { + if ($parent->isHomePage() === true) { + return $this->url = $this->site()->urlForLanguage($language) . '/' . $parent->slug($language) . '/' . $this->slug($language); + } + + return $this->url = $this->parent()->urlForLanguage($language) . '/' . $this->slug($language); + } + + return $this->url = $this->site()->urlForLanguage($language) . '/' . $this->slug($language); + } +} diff --git a/public/kirby/src/Cms/PageActions.php b/public/kirby/src/Cms/PageActions.php new file mode 100644 index 0000000..d84ad30 --- /dev/null +++ b/public/kirby/src/Cms/PageActions.php @@ -0,0 +1,948 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait PageActions +{ + /** + * Changes the sorting number. + * The sorting number must already be correct + * when the method is called. + * This only affects this page, + * siblings will not be resorted. + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved + */ + public function changeNum(int|null $num = null): static + { + if ($this->isDraft() === true) { + throw new LogicException( + message: 'Drafts cannot change their sorting number' + ); + } + + // don't run the action if everything stayed the same + if ($this->num() === $num) { + return $this; + } + + return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) { + $newPage = $oldPage->clone([ + 'num' => $num, + 'dirname' => null, + 'root' => null, + 'template' => $oldPage->intendedTemplate()->name(), + ]); + + // actually move the page on disk + if ($oldPage->exists() === true) { + if (Dir::move($oldPage->root(), $newPage->root()) === true) { + // Updates the root path of the old page with the root path + // of the moved new page to use fly actions on old page in loop + $oldPage->root = $newPage->root(); + } else { + throw new LogicException( + message: 'The page directory cannot be moved' + ); + } + } + + return $newPage; + }); + } + + /** + * Changes the slug/uid of the page + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If the directory cannot be moved + */ + public function changeSlug( + string $slug, + string|null $languageCode = null + ): static { + // always sanitize the 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 ($language->isDefault() === false) { + return $this->changeSlugForLanguage($slug, $language->code()); + } + + // if the slug stays exactly the same, + // nothing needs to be done. + if ($slug === $this->slug()) { + return $this; + } + + $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, + 'template' => $oldPage->intendedTemplate()->name(), + ]); + + // clear UUID cache recursively (for children and files as well) + $oldPage->uuid()?->clear(true); + + if ($oldPage->exists() === true) { + // actually move stuff on disk + if (Dir::move($oldPage->root(), $newPage->root()) !== true) { + 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 + ModelState::update( + method: 'remove', + current: $oldPage, + ); + + Dir::remove($oldPage->mediaRoot()); + } + + return $newPage; + }); + } + + /** + * Change the slug for a specific language + * + * @throws \Kirby\Exception\NotFoundException If the language for the given language code cannot be found + * @throws \Kirby\Exception\InvalidArgumentException If the slug for the default language is being changed + */ + protected function changeSlugForLanguage( + string $slug, + string|null $languageCode = null + ): static { + $language = Language::ensure($languageCode ?? 'current'); + + if ($language->isDefault() === true) { + throw new InvalidArgumentException( + message: 'Use the changeSlug method to change the slug for the default language' + ); + } + + $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; + } + + // 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); + } + + return $page->save(['slug' => $slug], $languageCode); + }); + } + + /** + * Change the status of the current page + * to either draft, listed or unlisted. + * If changing to `listed`, you can pass a position for the + * page in the siblings collection. Siblings will be resorted. + * + * @param string $status "draft", "listed" or "unlisted" + * @param int|null $position Optional sorting number + * @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed + */ + 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( + message: 'Invalid status: ' . $status + ) + }; + } + + protected function changeStatusToDraft(): static + { + $arguments = ['page' => $this, 'status' => 'draft', 'position' => null]; + $page = $this->commit( + 'changeStatus', + $arguments, + fn ($page) => $page->unpublish() + ); + + return $page; + } + + /** + * @return $this|static + */ + protected function changeStatusToListed(int|null $position = null): static + { + // create a sorting number for the page + $num = $this->createNum($position); + + // don't sort if not necessary + if ($this->status() === 'listed' && $num === $this->num()) { + return $this; + } + + $page = $this->commit( + 'changeStatus', + [ + 'page' => $this, + 'status' => 'listed', + 'position' => $num + ], + fn ($page, $status, $position) => + $page->publish()->changeNum($position) + ); + + if ($this->blueprint()->num() === 'default') { + $page->resortSiblingsAfterListing($num); + } + + return $page; + } + + /** + * @return $this|static + */ + protected function changeStatusToUnlisted(): static + { + if ($this->status() === 'unlisted') { + return $this; + } + + $page = $this->commit( + 'changeStatus', + [ + 'page' => $this, + 'status' => 'unlisted', + 'position' => null + ], + fn ($page) => $page->publish()->changeNum(null) + ); + + $this->resortSiblingsAfterUnlisting(); + + return $page; + } + + /** + * Change the position of the page in its siblings + * collection. Siblings will be resorted. If the page + * status isn't yet `listed`, it will be changed to it. + * + * @return $this|static + */ + public function changeSort(int|null $position = null): static + { + return $this->changeStatus('listed', $position); + } + + /** + * Changes the page template + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved + */ + public function changeTemplate(string $template): static + { + if ($template === $this->intendedTemplate()->name()) { + return $this; + } + + return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) { + // convert for new template/blueprint + return $oldPage->convertTo($template); + }); + } + + /** + * Change the page title + */ + public function changeTitle( + string $title, + string|null $languageCode = null + ): static { + $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); + } + + return $page->save(['title' => $title], $language->code()); + }); + } + + /** + * Commits a page action, by following these steps + * + * 1. applies the `before` hook + * 2. checks the action rules + * 3. commits the store action + * 4. applies the `after` hook + * 5. returns the result + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + $commit = new ModelCommit( + model: $this, + action: $action + ); + + return $commit->call($arguments, $callback); + } + + /** + * Copies the page to a new parent + * + * @throws \Kirby\Exception\DuplicateException If the page already exists + */ + 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; + + // clean up the slug + $slug = Url::slug($slug); + + if ($parentModel->findPageOrDraft($slug)) { + throw new DuplicateException( + key: 'page.duplicate', + data: ['slug' => $slug] + ); + } + + $tmp = new static([ + 'isDraft' => $isDraft, + 'num' => $num, + 'parent' => $parent, + 'slug' => $slug, + ]); + + $ignore = []; + + // don't copy files + if ($files === false) { + foreach ($this->files() as $file) { + $ignore[] = $file->root(); + + // append all content files + array_push($ignore, ...$file->storage()->contentFiles(VersionId::latest())); + array_push($ignore, ...$file->storage()->contentFiles(VersionId::changes())); + } + } + + Dir::copy($this->root(), $tmp->root(), $children, $ignore); + + $copy = $parentModel->clone()->findPageOrDraft($slug); + + // normalize copy object + $copy = PageCopy::process( + copy: $copy, + original: $this, + withFiles: $files, + withChildren: $children + ); + + // add copy to siblings + ModelState::update( + method: 'append', + current: $copy, + parent: $parentModel + ); + + return $copy; + } + + /** + * Creates and stores a new page + */ + public static function create(array $props): Page + { + $props = self::normalizeProps($props); + + // 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(); + } + + // keep the initial storage class + $storage = $page->storage()::class; + + // make sure that the temporary page is stored in memory + $page->changeStorage(MemoryStorage::class); + + // inject the content + $page->setContent($props['content']); + + // inject the translations + $page->setTranslations($props['translations'] ?? null); + + // run the hooks and creation action + $page = $page->commit( + 'create', + [ + 'page' => $page, + 'input' => $props + ], + function ($page) use ($storage) { + // move to final storage + return $page->changeStorage($storage); + } + ); + + // publish the new page if a number is given + if (isset($props['num']) === true) { + $page = $page->changeStatus('listed', $props['num']); + } + + return $page; + } + + /** + * Creates a child of the current page + */ + public function createChild(array $props): Page + { + $props = [ + ...$props, + 'url' => null, + 'num' => null, + 'parent' => $this, + 'site' => $this->site(), + ]; + + $modelClass = static::$models[$props['template'] ?? null] ?? static::class; + return $modelClass::create($props); + } + + /** + * Create the sorting number for the page + * depending on the blueprint settings + */ + public function createNum(int|null $num = null): int + { + $mode = $this->blueprint()->num(); + + switch ($mode) { + case 'zero': + return 0; + case 'date': + case 'datetime': + // the $format needs to produce only digits, + // so it can be converted to integer below + $format = $mode === 'date' ? 'Ymd' : 'YmdHi'; + $field = $this->content('default')->get('date'); + $date = $field->isEmpty() ? 'now' : $field; + return (int)date($format, strtotime($date)); + case 'default': + + $max = $this + ->parentModel() + ->children() + ->listed() + ->merge($this) + ->count(); + + // default positioning at the end + $num ??= $max; + + // avoid zeros or negative numbers + if ($num < 1) { + return 1; + } + + // avoid higher numbers than possible + if ($num > $max) { + return $max; + } + + return $num; + default: + // get instance with default language + $app = $this->kirby()->clone([], false); + $app->setCurrentLanguage(); + + $template = Str::template($mode, [ + 'kirby' => $app, + 'page' => $this, + 'site' => $app->site(), + ], ['fallback' => '']); + + return (int)$template; + } + } + + /** + * Deletes the page + */ + 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(); + + // Explanation: The two while loops below are only + // necessary because our property caches result in + // outdated collections when deleting nested pages. + // When we use a foreach loop to go through those collections, + // we encounter outdated objects. Using a while loop + // fixes this issue. + // + // TODO: We can remove this part as soon + // as we move away from our immutable object architecture. + + // delete all files individually + while ($file = $page->files()->first()) { + $file->delete(); + } + + // delete all children individually + while ($child = $page->childrenAndDrafts()->first()) { + $child->delete(true); + } + + // delete all versions, + // the plain text storage handler will then clean + // up the directory if it's empty + $old->versions()->delete(); + + if ( + $page->isListed() === true && + $page->blueprint()->num() === 'default' + ) { + $page->resortSiblingsAfterUnlisting(); + } + + return true; + }); + } + + /** + * Duplicates the page with the given + * slug and optionally copies all files + */ + public function duplicate(string|null $slug = null, array $options = []): static + { + // create the slug for the duplicate + $slug = Url::slug($slug ?? $this->slug() . '-' . Url::slug(I18n::translate('page.duplicate.appendix'))); + + $arguments = [ + 'originalPage' => $this, + 'input' => $slug, + 'options' => $options + ]; + + return $this->commit('duplicate', $arguments, function ($page, $slug, $options) { + $page = $this->copy([ + 'parent' => $this->parent(), + 'slug' => $slug, + 'isDraft' => true, + 'files' => $options['files'] ?? false, + 'children' => $options['children'] ?? false, + ]); + + if (isset($options['title']) === true) { + $page = $page->changeTitle($options['title']); + } + + return $page; + }); + } + + /** + * Moves the page to a new parent if the + * new parent accepts the page type + */ + public function move(Site|Page $parent): Page + { + // nothing to move + if ($this->parentModel()->is($parent) === true) { + return $this; + } + + $arguments = [ + 'page' => $this, + 'parent' => $parent + ]; + + return $this->commit('move', $arguments, function ($page, $parent) { + // remove the uuid cache for this page + $page->uuid()?->clear(true); + + // move drafts into the drafts folder of the parent + $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' + ); + } + + // flush all collection caches to be sure that + // the new child is included afterwards + $parent->purge(); + + // double-check if the new child can actually be found + if (!$newPage = $parent->childrenAndDrafts()->find($page->slug())) { + 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 + */ + public function publish(): static + { + if ($this->isDraft() === false) { + return $this; + } + + $page = $this->clone([ + '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( + message: 'The draft folder cannot be moved' + ); + } + + // Get the draft folder and check if there are any other drafts + // left. Otherwise delete it. + $draftDir = dirname($this->root()); + + if (Dir::isEmpty($draftDir) === true) { + Dir::remove($draftDir); + } + } + + // remove the page from the parent drafts and add it to children + $parentModel = $page->parentModel(); + $parentModel->drafts()->remove($page); + $parentModel->children()->append($page->id(), $page); + + // update the childrenAndDrafts() cache if it is initialized + if ($parentModel->childrenAndDrafts !== null) { + $parentModel->childrenAndDrafts()->set($page->id(), $page); + } + + return $page; + } + + /** + * Clean internal caches + * + * @return $this + */ + public function purge(): static + { + parent::purge(); + + $this->blueprint = null; + $this->children = null; + $this->childrenAndDrafts = null; + $this->drafts = null; + $this->files = null; + $this->inventory = null; + + return $this; + } + + /** + * @throws \Kirby\Exception\LogicException If the page is not included in the siblings collection + */ + protected function resortSiblingsAfterListing(int|null $position = null): bool + { + $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 = $listed->keys(); + $index = array_search($this->id(), $keys); + + // If the page is not included in the siblings something went wrong + if ($index === false) { + 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 + // zero-based array keys + $sorted = A::move($keys, $index, $position - 1); + + foreach ($sorted as $key => $id) { + if ($id === $this->id()) { + continue; + } + + // Apply the new sorting number + // and update the new object in the siblings collection + $newSibling = $listed->get($id)?->changeNum($key + 1); + $siblings->update($newSibling); + } + + // Update the parent's children collection with the new sorting + $parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc'); + $parent->childrenAndDrafts = null; + + return true; + } + + /** + * @internal + */ + public function resortSiblingsAfterUnlisting(): bool + { + $index = 0; + $parent = $this->parentModel(); + $siblings = $parent->children(); + + // Get all listed siblings excluding the current page + $listed = $siblings + ->listed() + ->not($this) + ->filter(fn ($page) => $page->blueprint()->num() === 'default'); + + if ($listed->count() > 0) { + foreach ($listed as $sibling) { + $index++; + + // Apply the new sorting number + // and update the new object in the siblings collection + $newSibling = $sibling->changeNum($index); + $siblings->update($newSibling); + } + + // Update the parent's children collection with the new sorting + $parent->children = $siblings->sort('isListed', 'desc', 'num', 'asc'); + $parent->childrenAndDrafts = null; + } + + return true; + } + + /** + * Convert a page from listed or unlisted to draft + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If the folder cannot be moved + */ + public function unpublish(): static + { + if ($this->isDraft() === true) { + return $this; + } + + $page = $this->clone([ + 'isDraft' => true, + 'num' => null, + 'dirname' => null, + 'root' => null, + 'template' => $this->intendedTemplate()->name(), + ]); + + // remove the media directory + Dir::remove($this->mediaRoot()); + + // actually do it on disk + if ($this->exists() === true) { + if (Dir::move($this->root(), $page->root()) !== true) { + throw new LogicException( + message: 'The page folder cannot be moved to drafts' + ); + } + } + + // remove the page from the parent children and add it to drafts + $parentModel = $page->parentModel(); + $parentModel->children()->remove($page); + $parentModel->drafts()->append($page->id(), $page); + + // update the childrenAndDrafts() cache if it is initialized + if ($parentModel->childrenAndDrafts !== null) { + $parentModel->childrenAndDrafts()->set($page->id(), $page); + } + + $page->resortSiblingsAfterUnlisting(); + + return $page; + } + + /** + * Updates the page data + */ + public function update( + array|null $input = null, + string|null $languageCode = null, + bool $validate = false + ): static { + if ($this->isDraft() === true) { + $validate = false; + } + + $page = parent::update($input, $languageCode, $validate); + + // if num is created from page content, update num on content update + if ( + $page->isListed() === true && + in_array($page->blueprint()->num(), ['zero', 'default'], true) === false + ) { + $page = $page->changeNum($page->createNum()); + } + + return $page; + } + + /** + * Updates parent collections with the new page object + * after a page action + * + * @deprecated 5.0.0 Use ModelState::update instead + */ + protected static function updateParentCollections( + Page $page, + string|false $method, + Page|Site|null $parentModel = null + ): void { + 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 new file mode 100644 index 0000000..d65e7c8 --- /dev/null +++ b/public/kirby/src/Cms/PageBlueprint.php @@ -0,0 +1,189 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageBlueprint extends Blueprint +{ + /** + * Creates a new page blueprint object + * with the given props + */ + public function __construct(array $props) + { + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'access' => null, + 'changeSlug' => null, + 'changeStatus' => null, + 'changeTemplate' => null, + 'changeTitle' => null, + 'create' => null, + 'delete' => null, + 'duplicate' => null, + 'list' => null, + 'move' => null, + 'preview' => null, + 'read' => null, + 'sort' => null, + 'update' => null, + ], + // aliases (from v2) + [ + 'status' => 'changeStatus', + 'template' => 'changeTemplate', + 'title' => 'changeTitle', + 'url' => 'changeSlug', + ] + ); + + // normalize the ordering number + $this->props['num'] = $this->normalizeNum($this->props['num'] ?? 'default'); + + // normalize the available status array + $this->props['status'] = $this->normalizeStatus($this->props['status'] ?? null); + } + + /** + * Returns the page numbering mode + */ + public function num(): string + { + return $this->props['num']; + } + + /** + * Normalizes the ordering number + */ + protected function normalizeNum($num): string + { + $aliases = [ + '0' => 'zero', + 'sort' => 'default', + ]; + + return $aliases[$num] ?? $num; + } + + /** + * Normalizes the available status options for the page + */ + protected function normalizeStatus($status): array + { + $defaults = [ + 'draft' => [ + 'label' => $this->i18n('page.status.draft'), + 'text' => $this->i18n('page.status.draft.description'), + ], + 'unlisted' => [ + 'label' => $this->i18n('page.status.unlisted'), + 'text' => $this->i18n('page.status.unlisted.description'), + ], + 'listed' => [ + 'label' => $this->i18n('page.status.listed'), + 'text' => $this->i18n('page.status.listed.description'), + ] + ]; + + // use the defaults, when the status is not defined + if (empty($status) === true) { + $status = $defaults; + } + + // extend the status definition + $status = $this->extend($status); + + // clean up and translate each status + foreach ($status as $key => $options) { + // skip invalid status definitions + if (in_array($key, ['draft', 'listed', 'unlisted'], true) === false || $options === false) { + unset($status[$key]); + continue; + } + + if ($options === true) { + $status[$key] = $defaults[$key]; + continue; + } + + // convert everything to a simple array + if (is_array($options) === false) { + $status[$key] = [ + 'label' => $options, + 'text' => null + ]; + } + + // always make sure to have a proper label + if (empty($status[$key]['label']) === true) { + $status[$key]['label'] = $defaults[$key]['label']; + } + + // also make sure to have the text field set + $status[$key]['text'] ??= null; + + // translate text and label if necessary + $status[$key]['label'] = $this->i18n($status[$key]['label'], $status[$key]['label']); + $status[$key]['text'] = $this->i18n($status[$key]['text'], $status[$key]['text']); + } + + // the draft status is required + if (isset($status['draft']) === false) { + $status = ['draft' => $defaults['draft']] + $status; + } + + // remove the draft status for the home and error pages + if ($this->model->isHomeOrErrorPage() === true) { + unset($status['draft']); + } + + return $status; + } + + /** + * Returns the options object + * that handles page options and permissions + */ + public function options(): array + { + return $this->props['options']; + } + + /** + * Returns the preview settings + * The preview setting controls the "Open" + * button in the panel and redirects it to a + * different URL if necessary. + */ + public function preview(): string|bool + { + $preview = $this->props['options']['preview'] ?? true; + + if (is_string($preview) === true) { + return $this->model->toString($preview); + } + + return $this->model->permissions()->can('preview', true); + } + + /** + * Returns the status array + */ + public function status(): array + { + return $this->props['status']; + } +} 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 new file mode 100644 index 0000000..0161079 --- /dev/null +++ b/public/kirby/src/Cms/PagePermissions.php @@ -0,0 +1,75 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PagePermissions extends ModelPermissions +{ + 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 + { + return $this->model->isHomeOrErrorPage() !== true; + } + + protected function canChangeStatus(): bool + { + return $this->model->isErrorPage() !== true; + } + + protected function canChangeTemplate(): bool + { + if ($this->model->isErrorPage() === true) { + return false; + } + + if (count($this->model->blueprints()) <= 1) { + return false; + } + + return true; + } + + protected function canDelete(): bool + { + return $this->model->isHomeOrErrorPage() !== true; + } + + protected function canMove(): bool + { + return $this->model->isHomeOrErrorPage() !== true; + } + + protected function canSort(): bool + { + if ($this->model->isErrorPage() === true) { + return false; + } + + if ($this->model->isListed() !== true) { + return false; + } + + if ($this->model->blueprint()->num() !== 'default') { + return false; + } + + return true; + } +} diff --git a/public/kirby/src/Cms/PagePicker.php b/public/kirby/src/Cms/PagePicker.php new file mode 100644 index 0000000..9c98598 --- /dev/null +++ b/public/kirby/src/Cms/PagePicker.php @@ -0,0 +1,232 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PagePicker extends Picker +{ + // TODO: null only due to our Properties setters, + // remove once our implementation is better + protected Pages|null $items = null; + protected Pages|null $itemsForQuery = null; + protected Page|Site|null $parent = null; + + /** + * Extends the basic defaults + */ + public function defaults(): array + { + return [ + ...parent::defaults(), + // Page ID of the selected parent. Used to navigate + 'parent' => null, + // enable/disable subpage navigation + 'subpages' => true, + ]; + } + + /** + * Returns the parent model object that + * is currently selected in the page picker. + * It normally starts at the site, but can + * also be any subpage. When a query is given + * and subpage navigation is deactivated, + * there will be no model available at all. + */ + public function model(): Page|Site|null + { + // no subpages navigation = no model + if ($this->options['subpages'] === false) { + return null; + } + + // the model for queries is a bit more tricky to find + if (empty($this->options['query']) === false) { + return $this->modelForQuery(); + } + + return $this->parent(); + } + + /** + * Returns a model object for the given + * query, depending on the parent and subpages + * options. + */ + public function modelForQuery(): Page|Site|null + { + if ($this->options['subpages'] === true && empty($this->options['parent']) === false) { + return $this->parent(); + } + + return $this->items()?->parent(); + } + + /** + * Returns basic information about the + * parent model that is currently selected + * in the page picker. + */ + public function modelToArray(Page|Site|null $model = null): array|null + { + if ($model === null) { + return null; + } + + // the selected model is the site. there's nothing above + if ($model instanceof Site) { + return [ + 'id' => null, + 'parent' => null, + 'title' => $model->title()->value() + ]; + } + + // the top-most page has been reached + // the missing id indicates that there's nothing above + if ($model->id() === $this->start()->id()) { + return [ + 'id' => null, + 'parent' => null, + 'title' => $model->title()->value() + ]; + } + + // the model is a regular page + return [ + 'id' => $model->id(), + 'parent' => $model->parentModel()->id(), + 'title' => $model->title()->value() + ]; + } + + /** + * Search all pages for the picker + */ + public function items(): Pages|null + { + // cache + if ($this->items !== null) { + return $this->items; + } + + // no query? simple parent-based search for pages + 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. + // Don't use the parent if it's the same as the start/top-most model. + } elseif ( + $this->options['subpages'] === true && + empty($this->options['parent']) === false && + $this->model()->id() !== $this->start()->id() + ) { + $items = $this->itemsForParent(); + + // search by query + } else { + $items = $this->itemsForQuery(); + } + + // filter protected and hidden pages + $items = $items->filter('isListable', true); + + // search + $items = $this->search($items); + + // paginate the result + return $this->items = $this->paginate($items); + } + + /** + * Search for pages by parent + */ + public function itemsForParent(): Pages + { + return $this->parent()->children(); + } + + /** + * Search for pages by query string + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function itemsForQuery(): Pages + { + // cache + if ($this->itemsForQuery !== null) { + return $this->itemsForQuery; + } + + $model = $this->options['model']; + $items = $model->query($this->options['query']); + + // help mitigate some typical query usage issues + // by converting site and page objects to proper + // pages by returning their children + $items = match (true) { + $items instanceof Site, + $items instanceof Page => $items->children(), + $items instanceof Pages => $items, + + default => throw new InvalidArgumentException( + message: 'Your query must return a set of pages' + ) + }; + + return $this->itemsForQuery = $items; + } + + /** + * Returns the parent model. + * The model will be used to fetch + * subpages unless there's a specific + * query to find pages instead. + */ + public function parent(): Page|Site + { + return $this->parent ??= $this->kirby->page($this->options['parent']) ?? $this->site; + } + + /** + * Calculates the top-most model (page or site) + * that can be accessed when navigating + * through pages. + */ + public function start(): Page|Site + { + if (empty($this->options['query']) === false) { + return $this->itemsForQuery()?->parent() ?? $this->site; + } + + return $this->site; + } + + /** + * Returns an associative array + * with all information for the picker. + * This will be passed directly to the API. + */ + public function toArray(): array + { + $array = parent::toArray(); + $array['model'] = $this->modelToArray($this->model()); + + return $array; + } +} diff --git a/public/kirby/src/Cms/PageRules.php b/public/kirby/src/Cms/PageRules.php new file mode 100644 index 0000000..b31fd75 --- /dev/null +++ b/public/kirby/src/Cms/PageRules.php @@ -0,0 +1,473 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageRules +{ + /** + * Validates if the sorting number of the page can be changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the given number is invalid + */ + public static function changeNum(Page $page, int|null $num = null): void + { + if ($num !== null && $num < 0) { + throw new InvalidArgumentException(key: 'page.num.invalid'); + } + } + + /** + * Validates if the slug for the page can be changed + * + * @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): void + { + if ($page->permissions()->can('changeSlug') !== true) { + throw new PermissionException( + key: 'page.changeSlug.permission', + data: ['slug' => $page->slug()] + ); + } + + self::validateSlugLength($slug); + self::validateSlugProtectedPaths($page, $slug); + + $siblings = $page->parentModel()->children(); + $drafts = $page->parentModel()->drafts(); + + if ($siblings->find($slug)?->is($page) === false) { + 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] + ); + } + } + + /** + * Validates if the status for the page can be changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the given status is invalid + */ + public static function changeStatus( + Page $page, + string $status, + int|null $position = null + ): void { + if (isset($page->blueprint()->status()[$status]) === false) { + throw new InvalidArgumentException(key: 'page.status.invalid'); + } + + match ($status) { + 'draft' => static::changeStatusToDraft($page), + 'listed' => static::changeStatusToListed($page, $position), + 'unlisted' => static::changeStatusToUnlisted($page), + default => throw new InvalidArgumentException( + key: 'page.status.invalid' + ) + }; + } + + /** + * Validates if a page can be converted to a draft + * + * @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): void + { + 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()] + ); + } + } + + /** + * Validates if the status of a page can be changed to listed + * + * @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): 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()] + ); + } + + return; + } + + static::publish($page); + + if ($position !== null && $position < 0) { + throw new InvalidArgumentException(key: 'page.num.invalid'); + } + } + + /** + * Validates if the status of a page can be changed to unlisted + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status + */ + public static function changeStatusToUnlisted(Page $page) + { + static::publish($page); + } + + /** + * Validates if the template of the page can be changed + * + * @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): void + { + 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'), true) === false + ) { + throw new LogicException( + key: 'page.changeTemplate.invalid', + data: ['slug' => $page->slug()] + ); + } + } + + /** + * Validates if the title of the page can be changed + * + * @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): void + { + if ($page->permissions()->can('changeTitle') !== true) { + throw new PermissionException( + key: 'page.changeTitle.permission', + data: ['slug' => $page->slug()] + ); + } + + static::validateTitleLength($title); + } + + /** + * Validates if the page can be created + * + * @throws \Kirby\Exception\DuplicateException If the same page or a draft already exists + * @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): void + { + 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()] + ); + } + + $siblings = $page->parentModel()->children(); + $drafts = $page->parentModel()->drafts(); + $slug = $page->slug(); + + if ($siblings->find($slug)) { + throw new DuplicateException( + key: 'page.duplicate', + data: ['slug' => $slug] + ); + } + + if ($drafts->find($slug)) { + throw new DuplicateException( + key: 'page.draft.duplicate', + data: ['slug' => $slug] + ); + } + } + + /** + * Validates if the page can be deleted + * + * @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): void + { + 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'); + } + } + + /** + * Validates if the page can be duplicated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to duplicate the page + */ + public static function duplicate( + Page $page, + string $slug, + array $options = [] + ): void { + if ($page->permissions()->can('duplicate') !== true) { + throw new PermissionException( + key: 'page.duplicate.permission', + data: ['slug' => $page->slug()] + ); + } + + self::validateSlugLength($slug); + } + + /** + * Check if the page can be moved + * to the given parent + */ + 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; + } + + 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'); + } + + // check for duplicates + if ($parent->childrenAndDrafts()->find($page->slug())) { + throw new DuplicateException( + key: 'page.move.duplicate', + data: ['slug' => $page->slug()] + ); + } + + $allowed = []; + + // collect all allowed subpage templates + // from all pages sections in the blueprint + // (only consider page sections that list pages + // of the targeted new parent page) + $sections = array_filter( + $parent->blueprint()->sections(), + fn ($section) => + $section->type() === 'pages' && + $section->parent()->is($parent) + ); + + // check if the parent has at least one pages section + if ($sections === []) { + throw new LogicException([ + 'key' => 'page.move.noSections', + 'data' => [ + 'parent' => $parent->id() ?? '/', + ] + ]); + } + + // go through all allowed templates and + // add the name to the allowlist + foreach ($sections as $section) { + foreach ($section->templates() as $template) { + $allowed[] = $template; + } + } + + // check if the template of this page is allowed as subpage type + // for the potential new parent + if ( + $allowed !== [] && + in_array($page->intendedTemplate()->name(), $allowed) === false + ) { + throw new PermissionException( + key: 'page.move.template', + data: [ + 'template' => $page->intendedTemplate()->name(), + 'parent' => $parent->id() ?? '/', + ] + ); + } + } + + /** + * Check if the page can be published + * (status change from draft to listed or unlisted) + */ + public static function publish(Page $page): void + { + 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() + ); + } + } + + /** + * Validates if the page can be updated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the page + */ + public static function update(Page $page, array $content = []): void + { + if ($page->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'page.update.permission', + data: ['slug' => $page->slug()] + ); + } + } + + /** + * Ensures that the slug is not empty and doesn't exceed the maximum length + * to make sure that the directory name will be accepted by the filesystem + * + * @throws \Kirby\Exception\InvalidArgumentException If the slug is empty or too long + */ + public static function validateSlugLength(string $slug): void + { + $slugLength = Str::length($slug); + + if ($slugLength === 0) { + 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] + ); + } + } + } + + + /** + * Ensure that a top-level page path does not start with one of + * the reserved URL paths, e.g. for API or the Panel + * + * @throws \Kirby\Exception\InvalidArgumentException If the page ID starts as one of the disallowed paths + */ + protected static function validateSlugProtectedPaths( + Page $page, + string $slug + ): void { + if ($page->parent() === null) { + $paths = A::map( + ['api', 'assets', 'media', 'panel'], + fn ($url) => $page->kirby()->url($url, true)->path()->toString() + ); + + $index = array_search($slug, $paths); + + if ($index !== false) { + throw new InvalidArgumentException( + key: 'page.changeSlug.reserved', + data: ['path' => $paths[$index]] + ); + } + } + } + + /** + * Ensures that the page title is not empty + * + * @throws \Kirby\Exception\InvalidArgumentException If the title is empty + */ + public static function validateTitleLength(string $title): void + { + if (Str::length($title) === 0) { + throw new InvalidArgumentException(key: 'page.changeTitle.empty'); + } + } +} diff --git a/public/kirby/src/Cms/PageSiblings.php b/public/kirby/src/Cms/PageSiblings.php new file mode 100644 index 0000000..9215f05 --- /dev/null +++ b/public/kirby/src/Cms/PageSiblings.php @@ -0,0 +1,106 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait PageSiblings +{ + /** + * Checks if there's a next listed + * page in the siblings collection + */ + public function hasNextListed(Pages|null $collection = null): bool + { + return $this->nextListed($collection) !== null; + } + + /** + * Checks if there's a next unlisted + * page in the siblings collection + */ + public function hasNextUnlisted(Pages|null $collection = null): bool + { + return $this->nextUnlisted($collection) !== null; + } + + /** + * Checks if there's a previous listed + * page in the siblings collection + */ + public function hasPrevListed(Pages|null $collection = null): bool + { + return $this->prevListed($collection) !== null; + } + + /** + * Checks if there's a previous unlisted + * page in the siblings collection + */ + public function hasPrevUnlisted(Pages|null $collection = null): bool + { + return $this->prevUnlisted($collection) !== null; + } + + /** + * Returns the next listed page if it exists + */ + public function nextListed(Pages|null $collection = null): Page|null + { + return $this->nextAll($collection)->listed()->first(); + } + + /** + * Returns the next unlisted page if it exists + */ + public function nextUnlisted(Pages|null $collection = null): Page|null + { + return $this->nextAll($collection)->unlisted()->first(); + } + + /** + * Returns the previous listed page + */ + public function prevListed(Pages|null $collection = null): Page|null + { + return $this->prevAll($collection)->listed()->last(); + } + + /** + * Returns the previous unlisted page + */ + public function prevUnlisted(Pages|null $collection = null): Page|null + { + return $this->prevAll($collection)->unlisted()->last(); + } + + /** + * Private siblings collector + */ + protected function siblingsCollection(): Pages + { + if ($this->isDraft() === true) { + return $this->parentModel()->drafts(); + } + + return $this->parentModel()->children(); + } + + /** + * Returns siblings with the same template + */ + public function templateSiblings(bool $self = true): Pages + { + 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 new file mode 100644 index 0000000..587aace --- /dev/null +++ b/public/kirby/src/Cms/Pages.php @@ -0,0 +1,547 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @template TPage of \Kirby\Cms\Page + * @extends \Kirby\Cms\Collection + */ +class Pages extends Collection +{ + use HasUuids; + + /** + * Cache for the index only listed and unlisted pages + */ + protected Pages|null $index = null; + + /** + * Cache for the index all statuses also including drafts + */ + 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 + * current collection + * + * @param \Kirby\Cms\Pages|TPage|string $object + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException When no `Page` or `Pages` object or an ID of an existing page is passed + */ + public function add($object): static + { + $site = App::instance()->site(); + + // add a pages collection + if ($object instanceof self) { + $this->data = [...$this->data, ...$object->data]; + + // add a page by id + } elseif ( + is_string($object) === true && + $page = $site->find($object) + ) { + $this->__set($page->id(), $page); + + // 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 + } elseif (in_array($object, [null, false, true], true) !== true) { + 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; + } + + /** + * Returns all audio files of all children + */ + public function audio(): Files + { + return $this->files()->filter('type', 'audio'); + } + + /** + * Returns all children for each page in the array + * @return \Kirby\Cms\Pages + */ + public function children(): static + { + $children = new static([]); + + foreach ($this->data as $page) { + foreach ($page->children() as $childKey => $child) { + $children->data[$childKey] = $child; + } + } + + return $children; + } + + /** + * Returns all code files of all children + */ + public function code(): Files + { + 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 = []; + $kirby = App::instance(); + + // delete all pages and collect errors + foreach ($ids as $id) { + try { + // Explanation: We get the page object from the global context + // as the objects in the pages collection itself could have rendered + // outdated from a sibling delete action in this loop (e.g. resorting + // after deleting a sibling page and leaving the object in this collection + // with an old root path). + // + // TODO: We can remove this part as soon + // as we move away from our immutable object architecture. + $page = $kirby->page($id); + + if ($page === null || $this->get($id) instanceof Page === false) { + throw new NotFoundException( + key: 'page.undefined', + ); + } + + $page->delete(); + $this->remove($id); + } catch (Throwable $e) { + $exceptions[$id] = $e; + } + } + + if ($exceptions !== []) { + throw new Exception( + key: 'page.delete.multiple', + details: $exceptions + ); + } + } + + /** + * Returns all documents of all children + */ + public function documents(): Files + { + return $this->files()->filter('type', 'document'); + } + + /** + * Fetch all drafts for all pages in the collection + * @return \Kirby\Cms\Pages + */ + public function drafts(): static + { + $drafts = new static([]); + + foreach ($this->data as $page) { + foreach ($page->drafts() as $draftKey => $draft) { + $drafts->data[$draftKey] = $draft; + } + } + + return $drafts; + } + + /** + * Creates a pages collection from an array of props + */ + public static function factory( + array $pages, + Page|Site|null $model = null, + bool|null $draft = null + ): static { + $model ??= App::instance()->site(); + $children = new static([], $model); + + if ($model instanceof Page) { + $parent = $model; + $site = $model->site(); + } else { + $parent = null; + $site = $model; + } + + foreach ($pages as $props) { + $props['parent'] = $parent; + $props['site'] = $site; + $props['isDraft'] = $draft ?? $props['isDraft'] ?? $props['draft'] ?? false; + + $page = Page::factory($props); + + $children->data[$page->id()] = $page; + } + + return $children; + } + + /** + * Returns all files of all children + */ + public function files(): Files + { + $files = new Files([], $this->parent); + + foreach ($this->data as $page) { + foreach ($page->files() as $fileKey => $file) { + $files->data[$fileKey] = $file; + } + } + + return $files; + } + + /** + * Finds a page by its ID or URI + * @internal Use `$pages->find()` instead + * @return TPage|null + */ + public function findByKey(string|null $key = null): Page|null + { + if ($key === null) { + return null; + } + + if ($page = $this->findByUuid($key, 'page')) { + return $page; + } + + // remove trailing or leading slashes + $key = trim($key, '/'); + + // strip extensions from the id + if (str_contains($key, '.') === true) { + $info = pathinfo($key); + + if ($info['dirname'] !== '.') { + $key = $info['dirname'] . '/' . $info['filename']; + } else { + $key = $info['filename']; + } + } + + // try the obvious way + if ($page = $this->get($key)) { + return $page; + } + + $kirby = App::instance(); + $multiLang = $kirby->multilang(); + + // try to find the page by its (translated) URI + // by stepping through the page tree + $start = $this->parent instanceof Page ? $this->parent->id() : ''; + if ($page = $this->findByKeyRecursive($key, $start, $multiLang)) { + return $page; + } + + // for secondary languages, try the full translated URI + // (for collections without parent that won't have a result above) + if ( + $multiLang === true && + $kirby->language()->isDefault() === false && + $page = $this->findBy('uri', $key) + ) { + return $page; + } + + return null; + } + + /** + * Finds a child or child of a child recursively + * @return TPage|null + */ + protected function findByKeyRecursive( + string $id, + string|null $startAt = null, + bool $multiLang = false + ): Page|null { + $path = explode('/', $id); + $item = null; + $query = $startAt; + + foreach ($path as $key) { + $collection = $item?->children() ?? $this; + $query = ltrim($query . '/' . $key, '/'); + $item = $collection->get($query) ?? null; + + if ( + $item === null && + $multiLang === true && + App::instance()->language()->isDefault() === false + ) { + if (count($path) > 1 || $collection->parent()) { + // either the desired path is definitely not a slug, + // or collection is the children of another collection + $item = $collection->findBy('slug', $key); + } else { + // desired path _could_ be a slug or a "top level" uri + $item = $collection->findBy('uri', $key); + } + } + + if ($item === null) { + return null; + } + } + + return $item; + } + + /** + * Finds the currently open page + * @return TPage|null + */ + public function findOpen(): Page|null + { + return $this->findBy('isOpen', true); + } + + /** + * Custom getter that is able to find + * extension pages + * @return TPage|null + */ + public function get(string $key, mixed $default = null): Page|null + { + if ($key === null) { + return null; + } + + if ($item = parent::get($key)) { + return $item; + } + + return App::instance()->extension('pages', $key); + } + + /** + * Returns all images of all children + */ + public function images(): Files + { + return $this->files()->filter('type', 'image'); + } + + /** + * Create a recursive flat index of all + * pages and subpages, etc. + */ + public function index(bool $drafts = false): static + { + // get object property by cache mode + $index = $drafts === true ? $this->indexWithDrafts : $this->index; + + if ($index instanceof Pages) { + return $index; + } + + $index = new static([]); + + foreach ($this->data as $pageKey => $page) { + $index->data[$pageKey] = $page; + $pageIndex = $page->index($drafts); + + if ($pageIndex) { + foreach ($pageIndex as $childKey => $child) { + $index->data[$childKey] = $child; + } + } + } + + if ($drafts === true) { + return $this->indexWithDrafts = $index; + } + + return $this->index = $index; + } + + /** + * Returns all listed pages in the collection + * @return \Kirby\Cms\Pages + */ + public function listed(): static + { + return $this->filter('isListed', '==', true); + } + + /** + * Returns all unlisted pages in the collection + * @return \Kirby\Cms\Pages + */ + public function unlisted(): static + { + return $this->filter('isUnlisted', '==', true); + } + + /** + * Include all given items in the collection + * + * @return $this|static + */ + public function merge(string|Pages|Page|array ...$args): static + { + // merge multiple arguments at once + if (count($args) > 1) { + $collection = clone $this; + foreach ($args as $arg) { + $collection = $collection->merge($arg); + } + return $collection; + } + + // merge all parent drafts + if ($args[0] === 'drafts') { + if ($parent = $this->parent()) { + return $this->merge($parent->drafts()); + } + + return $this; + } + + // merge an entire collection + if ($args[0] instanceof Pages) { + $collection = clone $this; + $collection->data = [...$collection->data, ...$args[0]->data]; + return $collection; + } + + // append a single page + if ($args[0] instanceof Page) { + $collection = clone $this; + return $collection->set($args[0]->id(), $args[0]); + } + + // merge an array + if (is_array($args[0]) === true) { + $collection = clone $this; + foreach ($args[0] as $arg) { + $collection = $collection->merge($arg); + } + return $collection; + } + + if (is_string($args[0]) === true) { + return $this->merge(App::instance()->site()->find($args[0])); + } + + return $this; + } + + /** + * Filter all pages by excluding the given template + * @since 3.3.0 + * + * @return $this|static + */ + public function notTemplate(string|array|null $templates): static + { + if (empty($templates) === true) { + return $this; + } + + if (is_array($templates) === false) { + $templates = [$templates]; + } + + return $this->filter( + fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true) === false + ); + } + + /** + * Returns an array with all page numbers + */ + public function nums(): array + { + return $this->pluck('num'); + } + + /** + * Returns all listed and unlisted pages in the collection + * @return \Kirby\Cms\Pages + */ + public function published(): static + { + return $this->filter('isDraft', '==', false); + } + + /** + * Filter all pages by the given template + * + * @return $this|static + */ + public function template(string|array|null $templates): static + { + if (empty($templates) === true) { + return $this; + } + + if (is_array($templates) === false) { + $templates = [$templates]; + } + + return $this->filter( + fn ($page) => in_array($page->intendedTemplate()->name(), $templates, true) + ); + } + + /** + * Returns all video files of all children + */ + public function videos(): Files + { + return $this->files()->filter('type', 'video'); + } +} diff --git a/public/kirby/src/Cms/Pagination.php b/public/kirby/src/Cms/Pagination.php new file mode 100644 index 0000000..c776ac7 --- /dev/null +++ b/public/kirby/src/Cms/Pagination.php @@ -0,0 +1,162 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Pagination extends BasePagination +{ + /** + * Pagination method (param, query, none) + */ + protected string $method; + + /** + * The base URL + */ + protected Uri $url; + + /** + * Variable name for query strings + */ + protected string $variable; + + /** + * Creates the pagination object. As a new + * property you can now pass the base Url. + * That Url must be the Url of the first + * page of the collection without additional + * pagination information/query parameters in it. + * + * ```php + * $pagination = new Pagination([ + * 'page' => 1, + * 'limit' => 10, + * 'total' => 120, + * 'method' => 'query', + * 'variable' => 'p', + * 'url' => new Uri('https://getkirby.com/blog') + * ]); + * ``` + */ + public function __construct(array $params = []) + { + $kirby = App::instance(); + $config = $kirby->option('pagination', []); + $request = $kirby->request(); + + $params['limit'] ??= $config['limit'] ?? 20; + $params['method'] ??= $config['method'] ?? 'param'; + $params['variable'] ??= $config['variable'] ?? 'page'; + + if (empty($params['url']) === true) { + $params['url'] = new Uri($kirby->url('current'), [ + 'params' => $request->params(), + 'query' => $request->query()->toArray(), + ]); + } + + $params['page'] ??= match ($params['method']) { + 'query' => $params['url']->query()->get($params['variable']), + 'param' => $params['url']->params()->get($params['variable']), + default => null + }; + + parent::__construct($params); + + $this->method = $params['method']; + $this->url = $params['url']; + $this->variable = $params['variable']; + } + + /** + * Returns the Url for the first page + */ + public function firstPageUrl(): string|null + { + return $this->pageUrl(1); + } + + /** + * Returns the Url for the last page + */ + public function lastPageUrl(): string|null + { + return $this->pageUrl($this->lastPage()); + } + + /** + * Returns the Url for the next page. + * Returns null if there's no next page. + */ + public function nextPageUrl(): string|null + { + if ($page = $this->nextPage()) { + return $this->pageUrl($page); + } + + return null; + } + + /** + * Returns the URL of the current page. + * If the `$page` variable is set, the URL + * for that page will be returned. + */ + public function pageUrl(int|null $page = null): string|null + { + if ($page === null) { + return $this->pageUrl($this->page()); + } + + $url = clone $this->url; + $variable = $this->variable; + + if ( + $this->hasPage($page) === false || + in_array($this->method, ['query', 'param'], true) === false + ) { + return null; + } + + if ($page === 1) { + $page = null; + } + + match ($this->method) { + 'query' => $url->query->$variable = $page, + 'param' => $url->params->$variable = $page + }; + + return $url->toString(); + } + + /** + * Returns the Url for the previous page. + * Returns null if there's no previous page. + */ + public function prevPageUrl(): string|null + { + if ($page = $this->prevPage()) { + return $this->pageUrl($page); + } + + return null; + } +} diff --git a/public/kirby/src/Cms/Permissions.php b/public/kirby/src/Cms/Permissions.php new file mode 100644 index 0000000..a89ff22 --- /dev/null +++ b/public/kirby/src/Cms/Permissions.php @@ -0,0 +1,221 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Permissions +{ + public static array $extendedActions = []; + + protected array $actions = [ + 'access' => [ + 'account' => true, + 'languages' => true, + 'panel' => true, + 'site' => true, + 'system' => true, + 'users' => true, + ], + 'files' => [ + 'access' => true, + 'changeName' => true, + 'changeTemplate' => true, + 'create' => true, + 'delete' => true, + 'list' => true, + 'read' => true, + 'replace' => true, + 'sort' => true, + 'update' => true + ], + 'languages' => [ + 'create' => true, + 'delete' => true, + 'update' => true + ], + 'pages' => [ + 'access' => true, + 'changeSlug' => true, + 'changeStatus' => true, + 'changeTemplate' => true, + 'changeTitle' => true, + 'create' => true, + 'delete' => true, + 'duplicate' => true, + 'list' => true, + 'move' => true, + 'preview' => true, + 'read' => true, + 'sort' => true, + 'update' => true + ], + 'site' => [ + 'changeTitle' => true, + 'update' => true + ], + 'users' => [ + 'changeEmail' => true, + 'changeLanguage' => true, + 'changeName' => true, + 'changePassword' => true, + 'changeRole' => true, + 'create' => true, + 'delete' => true, + 'update' => true + ], + 'user' => [ + 'changeEmail' => true, + 'changeLanguage' => true, + 'changeName' => true, + 'changePassword' => true, + 'changeRole' => true, + 'delete' => true, + 'update' => true + ] + ]; + + /** + * Permissions constructor + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(array|bool|null $settings = []) + { + // dynamically register the extended actions + foreach (static::$extendedActions as $key => $actions) { + if (isset($this->actions[$key]) === true) { + throw new InvalidArgumentException( + message: 'The action ' . $key . ' is already a core action' + ); + } + + $this->actions[$key] = $actions; + } + + if (is_array($settings) === true) { + return $this->setCategories($settings); + } + + if (is_bool($settings) === true) { + return $this->setAll($settings); + } + } + + public function for( + string|null $category = null, + string|null $action = null, + bool $default = false + ): bool { + if ($action === null) { + if ($this->hasCategory($category) === false) { + return $default; + } + + return $this->actions[$category]; + } + + if ($this->hasAction($category, $action) === false) { + return $default; + } + + return $this->actions[$category][$action]; + } + + protected function hasAction(string $category, string $action): bool + { + return + $this->hasCategory($category) === true && + array_key_exists($action, $this->actions[$category]) === true; + } + + protected function hasCategory(string $category): bool + { + return array_key_exists($category, $this->actions) === true; + } + + /** + * @return $this + */ + protected function setAction( + string $category, + string $action, + $setting + ): static { + // wildcard to overwrite the entire category + if ($action === '*') { + return $this->setCategory($category, $setting); + } + + $this->actions[$category][$action] = $setting; + + return $this; + } + + /** + * @return $this + */ + protected function setAll(bool $setting): static + { + foreach ($this->actions as $categoryName => $actions) { + $this->setCategory($categoryName, $setting); + } + + return $this; + } + + /** + * @return $this + */ + protected function setCategories(array $settings): static + { + foreach ($settings as $name => $actions) { + if (is_bool($actions) === true) { + $this->setCategory($name, $actions); + } + + if (is_array($actions) === true) { + foreach ($actions as $action => $setting) { + $this->setAction($name, $action, $setting); + } + } + } + + return $this; + } + + /** + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException + */ + protected function setCategory(string $category, bool $setting): static + { + if ($this->hasCategory($category) === false) { + throw new InvalidArgumentException( + message: 'Invalid permissions category' + ); + } + + foreach ($this->actions[$category] as $action => $actionSetting) { + $this->actions[$category][$action] = $setting; + } + + return $this; + } + + public function toArray(): array + { + return $this->actions; + } +} diff --git a/public/kirby/src/Cms/Picker.php b/public/kirby/src/Cms/Picker.php new file mode 100644 index 0000000..35e0ba9 --- /dev/null +++ b/public/kirby/src/Cms/Picker.php @@ -0,0 +1,148 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Picker +{ + protected App $kirby; + protected array $options; + protected Site $site; + + /** + * Creates a new Picker instance + */ + public function __construct(array $params = []) + { + $this->options = [...$this->defaults(), ...$params]; + $this->kirby = $this->options['model']->kirby(); + $this->site = $this->kirby->site(); + } + + /** + * Return the array of default values + */ + protected function defaults(): array + { + // default params + return [ + // image settings (ratio, cover, etc.) + 'image' => [], + // query template for the info field + 'info' => false, + // listing style: list, cards, cardlets + 'layout' => 'list', + // number of users displayed per pagination page + 'limit' => 20, + // optional mapping function for the result array + 'map' => null, + // the reference model + 'model' => App::instance()->site(), + // current page when paginating + 'page' => 1, + // a query string to fetch specific items + 'query' => null, + // search query + 'search' => null, + // query template for the text field + 'text' => null + ]; + } + + /** + * Fetches all items for the picker + */ + abstract public function items(): Collection|null; + + /** + * Converts all given items to an associative + * array that is already optimized for the + * panel picker component. + */ + public function itemsToArray(Collection|null $items = null): array + { + if ($items === null) { + return []; + } + + $result = []; + + foreach ($items as $index => $item) { + if (empty($this->options['map']) === false) { + $result[] = $this->options['map']($item); + } else { + $result[] = $item->panel()->pickerData([ + 'image' => $this->options['image'], + 'info' => $this->options['info'], + 'layout' => $this->options['layout'], + 'model' => $this->options['model'], + 'text' => $this->options['text'], + ]); + } + } + + return $result; + } + + /** + * Apply pagination to the collection + * of items according to the options. + */ + public function paginate(Collection $items): Collection + { + return $items->paginate([ + 'limit' => $this->options['limit'], + 'page' => $this->options['page'] + ]); + } + + /** + * Return the most relevant pagination + * info as array + */ + public function paginationToArray(Pagination $pagination): array + { + return [ + 'limit' => $pagination->limit(), + 'page' => $pagination->page(), + 'total' => $pagination->total() + ]; + } + + /** + * Search through the collection of items + * if not deactivate in the options + */ + public function search(Collection $items): Collection + { + if (empty($this->options['search']) === false) { + return $items->search($this->options['search']); + } + + return $items; + } + + /** + * Returns an associative array + * with all information for the picker. + * This will be passed directly to the API. + */ + public function toArray(): array + { + $items = $this->items(); + + return [ + 'data' => $this->itemsToArray($items), + 'pagination' => $this->paginationToArray($items->pagination()), + ]; + } +} diff --git a/public/kirby/src/Cms/R.php b/public/kirby/src/Cms/R.php new file mode 100644 index 0000000..312fc2b --- /dev/null +++ b/public/kirby/src/Cms/R.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class R extends Facade +{ + public static function instance(): Request + { + return App::instance()->request(); + } +} diff --git a/public/kirby/src/Cms/Responder.php b/public/kirby/src/Cms/Responder.php new file mode 100644 index 0000000..a6eade0 --- /dev/null +++ b/public/kirby/src/Cms/Responder.php @@ -0,0 +1,418 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Responder implements Stringable +{ + /** + * Timestamp when the response expires + * in Kirby's cache + */ + protected int|null $expires = null; + + /** + * HTTP status code + */ + protected int|null $code = null; + + /** + * Response body + */ + protected string|null $body = null; + + /** + * Flag that defines whether the current + * response can be cached by Kirby's cache + */ + protected bool $cache = true; + + /** + * HTTP headers + */ + protected array $headers = []; + + /** + * Content type + */ + protected string|null $type = null; + + /** + * Flag that defines whether the current + * response uses the HTTP `Authorization` + * request header + */ + protected bool $usesAuth = false; + + /** + * List of cookie names the response + * relies on + */ + protected array $usesCookies = []; + + /** + * Creates and sends the response + */ + public function __toString(): string + { + return (string)$this->send(); + } + + /** + * Setter and getter for the response body + * + * @return $this|string|null + */ + public function body(string|null $body = null): static|string|null + { + if ($body === null) { + return $this->body; + } + + $this->body = $body; + return $this; + } + + /** + * Setter and getter for the flag that defines + * whether the current response can be cached + * by Kirby's cache + * @since 3.5.5 + * + * @return bool|$this + */ + public function cache(bool|null $cache = null): bool|static + { + if ($cache === null) { + // never ever cache private responses + if (static::isPrivate($this->usesAuth(), $this->usesCookies()) === true) { + return false; + } + + return $this->cache; + } + + $this->cache = $cache; + return $this; + } + + /** + * Setter and getter for the flag that defines + * whether the current response uses the HTTP + * `Authorization` request header + * @since 3.7.0 + * + * @return bool|$this + */ + public function usesAuth(bool|null $usesAuth = null): bool|static + { + if ($usesAuth === null) { + return $this->usesAuth; + } + + $this->usesAuth = $usesAuth; + return $this; + } + + /** + * Setter for a cookie name that is + * used by the response + * @since 3.7.0 + */ + public function usesCookie(string $name): void + { + // only add unique names + if (in_array($name, $this->usesCookies, true) === false) { + $this->usesCookies[] = $name; + } + } + + /** + * Setter and getter for the list of cookie + * names the response relies on + * @since 3.7.0 + * + * @return array|$this + */ + public function usesCookies(array|null $usesCookies = null) + { + if ($usesCookies === null) { + return $this->usesCookies; + } + + $this->usesCookies = $usesCookies; + return $this; + } + + /** + * Setter and getter for the cache expiry + * timestamp for Kirby's cache + * @since 3.5.5 + * + * @param int|string|null $expires Timestamp, number of minutes or time string to parse + * @param bool $override If `true`, the already defined timestamp will be overridden + * @return int|null|$this + */ + public function expires($expires = null, bool $override = false) + { + // getter + if ($expires === null && $override === false) { + return $this->expires; + } + + // explicit un-setter + if ($expires === null) { + $this->expires = null; + return $this; + } + + // normalize the value to an integer timestamp + if (is_int($expires) === true && $expires < 1000000000) { + // number of minutes + $expires = time() + ($expires * 60); + } elseif (is_int($expires) !== true) { + // time string + $parsedExpires = strtotime($expires); + + if (is_int($parsedExpires) !== true) { + throw new InvalidArgumentException( + message: 'Invalid time string "' . $expires . '"' + ); + } + + $expires = $parsedExpires; + } + + // by default only ever *reduce* the cache expiry time + if ( + $override === true || + $this->expires === null || + $expires < $this->expires + ) { + $this->expires = $expires; + } + + return $this; + } + + /** + * Setter and getter for the status code + * + * @return int|$this + */ + public function code(int|null $code = null) + { + if ($code === null) { + return $this->code; + } + + $this->code = $code; + return $this; + } + + /** + * Construct response from an array + */ + public function fromArray(array $response): void + { + $this->body($response['body'] ?? null); + $this->cache($response['cache'] ?? null); + $this->code($response['code'] ?? null); + $this->expires($response['expires'] ?? null); + $this->headers($response['headers'] ?? null); + $this->type($response['type'] ?? null); + $this->usesAuth($response['usesAuth'] ?? null); + $this->usesCookies($response['usesCookies'] ?? null); + } + + /** + * Setter and getter for a single header + * + * @param string|false|null $value + * @param bool $lazy If `true`, an existing header value is not overridden + * @return string|$this + */ + public function header(string $key, $value = null, bool $lazy = false) + { + if ($value === null) { + return $this->headers()[$key] ?? null; + } + + if ($value === false) { + unset($this->headers[$key]); + return $this; + } + + if ($lazy === true && isset($this->headers[$key]) === true) { + return $this; + } + + $this->headers[$key] = $value; + return $this; + } + + /** + * Setter and getter for all headers + * + * @return array|$this + */ + public function headers(array|null $headers = null) + { + if ($headers === null) { + $injectedHeaders = []; + + if (static::isPrivate($this->usesAuth(), $this->usesCookies()) === true) { + // never ever cache private responses + $injectedHeaders['Cache-Control'] = 'no-store, private'; + } else { + // the response is public, but it may + // vary based on request headers + $vary = []; + + if ($this->usesAuth() === true) { + $vary[] = 'Authorization'; + } + + if ($this->usesCookies() !== []) { + $vary[] = 'Cookie'; + } + + if ($vary !== []) { + $injectedHeaders['Vary'] = implode(', ', $vary); + } + } + + // lazily inject (never override custom headers) + return [...$injectedHeaders, ...$this->headers]; + } + + $this->headers = $headers; + return $this; + } + + /** + * Shortcut to configure a json response + * + * @return string|$this + */ + public function json(array|null $json = null) + { + if ($json !== null) { + $this->body(json_encode($json)); + } + + return $this->type('application/json'); + } + + /** + * Shortcut to create a redirect response + * + * @return $this + */ + public function redirect( + string|null $location = null, + int|null $code = null + ) { + $location = Url::to($location ?? '/'); + $location = Url::unIdn($location); + + return $this + ->header('Location', (string)$location) + ->code($code ?? 302); + } + + /** + * Creates and returns the response object from the config + */ + public function send(HttpResponse|string|null $body = null): HttpResponse + { + if ($body instanceof HttpResponse) { + // inject headers from the responder into the response + // (only if they are not already set); + $body->setHeaderFallbacks($this->headers()); + return $body; + } + + if ($body !== null) { + $this->body($body); + } + + return new Response($this->toArray()); + } + + /** + * Converts the response configuration + * to an array + */ + public function toArray(): array + { + // the `cache`, `expires`, `usesAuth` and `usesCookies` + // values are explicitly *not* serialized as they are + // volatile and not to be exported + return [ + 'body' => $this->body(), + 'code' => $this->code(), + 'headers' => $this->headers(), + 'type' => $this->type(), + ]; + } + + /** + * Setter and getter for the content type + * + * @return string|$this + */ + public function type(string|null $type = null) + { + if ($type === null) { + return $this->type; + } + + if (Str::contains($type, '/') === false) { + $type = Mime::fromExtension($type); + } + + $this->type = $type; + return $this; + } + + /** + * Checks whether the response needs to be exempted from + * 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 + * @unstable + */ + public static function isPrivate(bool $usesAuth, array $usesCookies): bool + { + $kirby = App::instance(); + + if ($usesAuth === true && $kirby->request()->hasAuth() === true) { + return true; + } + + foreach ($usesCookies as $cookie) { + if (isset($_COOKIE[$cookie]) === true) { + return true; + } + } + + return false; + } +} diff --git a/public/kirby/src/Cms/Response.php b/public/kirby/src/Cms/Response.php new file mode 100644 index 0000000..5a21bc6 --- /dev/null +++ b/public/kirby/src/Cms/Response.php @@ -0,0 +1,28 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Response extends \Kirby\Http\Response +{ + /** + * Adjusted redirect creation which + * parses locations with the Url::to method + * first. + */ + public static function redirect( + string $location = '/', + int $code = 302 + ): static { + return parent::redirect(Url::to($location), $code); + } +} diff --git a/public/kirby/src/Cms/Role.php b/public/kirby/src/Cms/Role.php new file mode 100644 index 0000000..9b7d8d5 --- /dev/null +++ b/public/kirby/src/Cms/Role.php @@ -0,0 +1,147 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Role implements Stringable +{ + protected string|null $description; + protected string $name; + protected Permissions $permissions; + protected string|null $title; + + public function __construct(array $props) + { + $this->name = $props['name']; + $this->permissions = new Permissions($props['permissions'] ?? null); + $title = $props['title'] ?? null; + $this->title = I18n::translate($title) ?? $title; + $description = $props['description'] ?? null; + $this->description = I18n::translate($description) ?? $description; + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + public function __toString(): string + { + return $this->name(); + } + + public static function defaultAdmin(array $inject = []): static + { + 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 + { + return [ + 'admin' => [ + 'name' => 'admin', + 'description' => I18n::translate('role.admin.description'), + 'title' => I18n::translate('role.admin.title'), + 'permissions' => true, + ], + 'nobody' => [ + 'name' => 'nobody', + 'description' => I18n::translate('role.nobody.description'), + 'title' => I18n::translate('role.nobody.title'), + 'permissions' => false, + ] + ]; + } + + public function description(): string|null + { + return $this->description; + } + + public static function factory(array $props, array $inject = []): static + { + // ensure to properly extend the blueprint + $props = $props + $inject; + $props = Blueprint::extend($props); + + return new static($props); + } + + public function id(): string + { + return $this->name(); + } + + public function isAdmin(): bool + { + return $this->name() === 'admin'; + } + + public function isNobody(): bool + { + return $this->name() === 'nobody'; + } + + public static function load(string $file, array $inject = []): static + { + $data = [ + ...Data::read($file), + 'name' => F::name($file) + ]; + + return static::factory($data, $inject); + } + + public function name(): string + { + return $this->name; + } + + public function permissions(): Permissions + { + return $this->permissions; + } + + public function title(): string + { + return $this->title ??= ucfirst($this->name()); + } + + /** + * Converts the most important role + * properties to an array + */ + public function toArray(): array + { + return [ + 'description' => $this->description(), + 'id' => $this->id(), + 'name' => $this->name(), + 'permissions' => $this->permissions()->toArray(), + 'title' => $this->title(), + ]; + } +} diff --git a/public/kirby/src/Cms/Roles.php b/public/kirby/src/Cms/Roles.php new file mode 100644 index 0000000..230a9fe --- /dev/null +++ b/public/kirby/src/Cms/Roles.php @@ -0,0 +1,147 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Role> + */ +class Roles extends Collection +{ + /** + * All registered roles methods + */ + public static array $methods = []; + + /** + * Returns a filtered list of all + * roles that can be changed by the + * current user + * + * Use with `$kirby->roles()`. For retrieving + * which roles are available for a specific user, + * use `$user->roles()` without additional filters. + * + * @return $this|static + * @throws \Exception + */ + public function canBeChanged(): static + { + if (App::instance()->user()?->isAdmin() !== true) { + return $this->filter(function ($role) { + $newUser = new User([ + 'email' => 'test@getkirby.com', + 'role' => $role->id() + ]); + + return $newUser->permissions()->can('changeRole'); + }); + } + + return $this; + } + + /** + * Returns a filtered list of all + * roles that can be created by the + * current user. + * + * Use with `$kirby->roles()`. + * + * @return $this|static + * @throws \Exception + */ + public function canBeCreated(): static + { + if (App::instance()->user()?->isAdmin() !== true) { + return $this->filter(function ($role) { + $newUser = new User([ + 'email' => 'test@getkirby.com', + 'role' => $role->id() + ]); + + return $newUser->permissions()->can('create'); + }); + } + + return $this; + } + + public static function factory(array $roles, array $inject = []): static + { + $collection = new static(); + + // read all user blueprints + foreach ($roles as $props) { + $role = Role::factory($props, $inject); + $collection->set($role->id(), $role); + } + + // always include the admin role + if ($collection->find('admin') === null) { + $collection->set('admin', Role::defaultAdmin()); + } + + // return the collection sorted by name + return $collection->sort('name', 'asc'); + } + + public static function load(string|null $root = null, array $inject = []): static + { + $kirby = App::instance(); + $roles = new static(); + + // load roles from plugins + foreach ($kirby->extensions('blueprints') as $name => $blueprint) { + if (str_starts_with($name, 'users/') === false) { + continue; + } + + // callback option can be return array or blueprint file path + if (is_callable($blueprint) === true) { + $blueprint = $blueprint($kirby); + } + + $role = match (is_array($blueprint)) { + true => Role::factory($blueprint, $inject), + false => Role::load($blueprint, $inject) + }; + + $roles->set($role->id(), $role); + } + + // load roles from directory + if ($root !== null) { + foreach (glob($root . '/*.yml') as $file) { + $filename = basename($file); + + if ($filename === 'default.yml') { + continue; + } + + $role = Role::load($file, $inject); + $roles->set($role->id(), $role); + } + } + + // always include the admin role + if ($roles->find('admin') === null) { + $roles->set('admin', Role::defaultAdmin($inject)); + } + + // return the collection sorted by name + return $roles->sort('name', 'asc'); + } +} diff --git a/public/kirby/src/Cms/S.php b/public/kirby/src/Cms/S.php new file mode 100644 index 0000000..260ee30 --- /dev/null +++ b/public/kirby/src/Cms/S.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class S extends Facade +{ + public static function instance(): Session + { + return App::instance()->session(); + } +} diff --git a/public/kirby/src/Cms/Search.php b/public/kirby/src/Cms/Search.php new file mode 100644 index 0000000..b8e66f9 --- /dev/null +++ b/public/kirby/src/Cms/Search.php @@ -0,0 +1,51 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Search +{ + public static function files( + string|null $query = null, + array $params = [] + ): Files { + return App::instance()->site()->index()->files()->search($query, $params); + } + + /** + * Native search method to search for anything within the collection + */ + public static function collection( + Collection $collection, + string|null $query = null, + string|array $params = [] + ): Collection { + $kirby = App::instance(); + return ($kirby->component('search'))($kirby, $collection, $query, $params); + } + + public static function pages( + string|null $query = null, + array $params = [] + ): Pages { + return App::instance()->site()->index()->search($query, $params); + } + + public static function users( + string|null $query = null, + array $params = [] + ): Users { + return App::instance()->users()->search($query, $params); + } +} diff --git a/public/kirby/src/Cms/Section.php b/public/kirby/src/Cms/Section.php new file mode 100644 index 0000000..a1785d3 --- /dev/null +++ b/public/kirby/src/Cms/Section.php @@ -0,0 +1,107 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Section extends Component +{ + /** + * Registry for all component mixins + */ + public static array $mixins = []; + + /** + * Registry for all component types + */ + public static array $types = []; + + /** + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(string $type, array $attrs = []) + { + if (isset($attrs['model']) === false) { + throw new InvalidArgumentException( + message: 'Undefined section model' + ); + } + + if ($attrs['model'] instanceof ModelWithContent === false) { + throw new InvalidArgumentException( + message: 'Invalid section model' + ); + } + + // use the type as fallback for the name + $attrs['name'] ??= $type; + $attrs['type'] = $type; + + parent::__construct($type, $attrs); + } + + /** + * Returns field api call + */ + public function api(): mixed + { + if ( + isset($this->options['api']) === true && + $this->options['api'] instanceof Closure + ) { + return $this->options['api']->call($this); + } + + return null; + } + + public function errors(): array + { + if (array_key_exists('errors', $this->methods) === true) { + return $this->methods['errors']->call($this); + } + + return $this->errors ?? []; + } + + public function kirby(): App + { + return $this->model()->kirby(); + } + + public function model(): ModelWithContent + { + return $this->model; + } + + public function toArray(): array + { + $array = parent::toArray(); + + unset($array['model']); + + return $array; + } + + public function toResponse(): array + { + return [ + 'status' => 'ok', + 'code' => 200, + 'name' => $this->name, + 'type' => $this->type, + ...$this->toArray() + ]; + } +} diff --git a/public/kirby/src/Cms/Site.php b/public/kirby/src/Cms/Site.php new file mode 100644 index 0000000..6b5a2a5 --- /dev/null +++ b/public/kirby/src/Cms/Site.php @@ -0,0 +1,502 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @method \Kirby\Uuid\SiteUuid uuid() + */ +class Site extends ModelWithContent +{ + use HasChildren; + use HasFiles; + use HasMethods; + use SiteActions; + + public const CLASS_ALIAS = 'site'; + + /** + * The SiteBlueprint object + */ + protected SiteBlueprint|null $blueprint = null; + + /** + * The error page object + */ + protected Page|null $errorPage = null; + + /** + * The id of the error page, which is + * fetched in the errorPage method + */ + protected string $errorPageId; + + /** + * The home page object + */ + protected Page|null $homePage = null; + + /** + * The id of the home page, which is + * fetched in the errorPage method + */ + protected string $homePageId; + + /** + * Cache for the inventory array + */ + protected array|null $inventory = null; + + /** + * The current page object + */ + protected Page|null $page; + + /** + * The absolute path to the site directory + */ + protected string $root; + + /** + * The page url + */ + protected string|null $url; + + /** + * Creates a new Site object + */ + public function __construct(array $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); + } + + /** + * Modified getter to also return fields + * from the content + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // site methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // return site content otherwise + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + ...$this->toArray(), + 'content' => $this->content(), + 'children' => $this->children(), + 'files' => $this->files(), + ]; + } + + /** + * Makes it possible to convert the site model + * to a string. Mostly useful for debugging. + */ + public function __toString(): string + { + return $this->url(); + } + + /** + * Returns the url to the api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + if ($relative === true) { + return 'site'; + } + + return $this->kirby()->url('api') . '/site'; + } + + /** + * Returns the blueprint object + */ + public function blueprint(): SiteBlueprint + { + if ($this->blueprint instanceof SiteBlueprint) { + return $this->blueprint; + } + + return $this->blueprint = SiteBlueprint::factory('site', null, $this); + } + + /** + * Builds a breadcrumb collection + */ + public function breadcrumb(): Pages + { + // get all parents and flip the order + $crumb = $this->page()->parents()->flip(); + + // add the home page + $crumb->prepend($this->homePage()->id(), $this->homePage()); + + // add the active page + $crumb->append($this->page()->id(), $this->page()); + + return $crumb; + } + + /** + * Prepares the content for the write method + * @internal + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + return A::prepend($data, [ + 'title' => $data['title'] ?? null + ]); + } + + /** + * Returns the error page object + */ + public function errorPage(): Page|null + { + return $this->errorPage ??= $this->find($this->errorPageId()); + } + + /** + * Returns the global error page id + */ + public function errorPageId(): string + { + return $this->errorPageId ?? 'error'; + } + + /** + * Checks if the site exists on disk + */ + public function exists(): bool + { + return is_dir($this->root()) === true; + } + + /** + * Returns the home page object + */ + public function homePage(): Page|null + { + return $this->homePage ??= $this->find($this->homePageId()); + } + + /** + * Returns the global home page id + */ + public function homePageId(): string + { + return $this->homePageId ?? 'home'; + } + + /** + * Creates an inventory of all files + * and children in the site directory + */ + public function inventory(): array + { + if ($this->inventory !== null) { + return $this->inventory; + } + + $kirby = $this->kirby(); + + return $this->inventory = Dir::inventory( + $this->root(), + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + } + + /** + * Compares the current object with the given site object + */ + public function is($site): bool + { + if ($site instanceof self === false) { + return false; + } + + return $this === $site; + } + + /** + * Returns the absolute path to the media folder for the page + */ + 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 + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/site'; + } + + /** + * Gets the last modification date of all pages + * in the content folder. + */ + public function modified( + string|null $format = null, + string|null $handler = null + ): int|string { + return Dir::modified($this->root(), $format, $handler); + } + + /** + * Returns the current page if `$path` + * is not specified. Otherwise it will try + * to find a page by the given path. + * + * If no current page is set with the page + * prop, the home page will be returned if + * it can be found. (see `Site::homePage()`) + * + * @param string|null $path omit for current page, + * otherwise e.g. `notes/across-the-ocean` + */ + public function page(string|null $path = null): Page|null + { + if ($path !== null) { + return $this->find($path); + } + + if ($this->page instanceof Page) { + return $this->page; + } + + try { + return $this->page = $this->homePage(); + } catch (LogicException) { + return $this->page = null; + } + } + + /** + * Alias for `Site::children()` + */ + public function pages(): Pages + { + return $this->children(); + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the permissions object for this site + */ + public function permissions(): SitePermissions + { + return new SitePermissions($this); + } + + /** + * Returns the preview URL with authentication for drafts and versions + * @unstable + */ + public function previewUrl(VersionId|string $versionId = 'latest'): string|null + { + // the site previews the home page and thus needs to check permissions for it + if ($this->homePage()?->permissions()->can('preview') !== true) { + return null; + } + + return $this->version($versionId)->url(); + } + + /** + * Returns the absolute path to the content directory + */ + public function root(): string + { + return $this->root ??= $this->kirby()->root('content'); + } + + /** + * Returns the SiteRules class instance + * which is being used in various methods + * to check for valid actions and input. + */ + protected function rules(): SiteRules + { + return new SiteRules(); + } + + /** + * Search all pages in the site + */ + public function search( + string|null $query = null, + string|array $params = [] + ): Pages { + return $this->index()->search($query, $params); + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array|null $blueprint = null): static + { + if ($blueprint !== null) { + $this->blueprint = new SiteBlueprint([ + 'model' => $this, + ...$blueprint + ]); + } + + return $this; + } + + /** + * Converts the most important site + * properties to an array + */ + public function toArray(): array + { + return [ + ...parent::toArray(), + 'children' => $this->children()->keys(), + 'errorPage' => $this->errorPage()?->id() ?? false, + 'files' => $this->files()->keys(), + 'homePage' => $this->homePage()?->id() ?? false, + 'page' => $this->page()?->id() ?? false, + 'title' => $this->title()->value(), + 'url' => $this->url(), + ]; + } + + /** + * Returns the Url + */ + public function url(string|null $language = null): string + { + if ($language !== null || $this->kirby()->multilang() === true) { + return $this->urlForLanguage($language); + } + + return $this->url ?? $this->kirby()->url(); + } + + /** + * Returns the translated url + * @internal + */ + public function urlForLanguage( + string|null $languageCode = null, + array|null $options = null + ): string { + return + $this->kirby()->language($languageCode)?->url() ?? + $this->kirby()->url(); + } + + /** + * Sets the current page by id or page object and + * returns the current page + */ + public function visit( + string|Page $page, + string|null $languageCode = null + ): Page { + if ($languageCode !== null) { + $this->kirby()->setCurrentTranslation($languageCode); + $this->kirby()->setCurrentLanguage($languageCode); + } + + // convert ids to a Page object + if (is_string($page) === true) { + $page = $this->find($page); + } + + // handle invalid pages + if ($page instanceof Page === false) { + throw new InvalidArgumentException(message: 'Invalid page object'); + } + + // set and return the current active page + return $this->page = $page; + } + + /** + * Checks if any content of the site has been + * modified after the given unix timestamp + * This is mainly used to auto-update the cache + */ + public function wasModifiedAfter(int $time): bool + { + return Dir::wasModifiedAfter($this->root(), $time); + } +} diff --git a/public/kirby/src/Cms/SiteActions.php b/public/kirby/src/Cms/SiteActions.php new file mode 100644 index 0000000..398f32d --- /dev/null +++ b/public/kirby/src/Cms/SiteActions.php @@ -0,0 +1,100 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait SiteActions +{ + /** + * Commits a site action, by following these steps + * + * 1. applies the `before` hook + * 2. checks the action rules + * 3. commits the store action + * 4. applies the `after` hook + * 5. returns the result + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + $commit = new ModelCommit( + model: $this, + action: $action + ); + + return $commit->call($arguments, $callback); + } + + /** + * Change the site title + */ + public function changeTitle( + string $title, + string|null $languageCode = null + ): static { + $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); + } + + return $site->save(['title' => $title], $language->code()); + }); + } + + /** + * Creates a main page + */ + public function createChild(array $props): Page + { + return Page::create([ + ...$props, + 'url' => null, + 'num' => null, + 'parent' => null, + 'site' => $this, + ]); + } + + /** + * Clean internal caches + * + * @return $this + */ + public function purge(): static + { + parent::purge(); + + $this->blueprint = null; + $this->children = null; + $this->childrenAndDrafts = null; + $this->drafts = null; + $this->files = null; + $this->inventory = null; + + return $this; + } +} diff --git a/public/kirby/src/Cms/SiteBlueprint.php b/public/kirby/src/Cms/SiteBlueprint.php new file mode 100644 index 0000000..65b179c --- /dev/null +++ b/public/kirby/src/Cms/SiteBlueprint.php @@ -0,0 +1,56 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class SiteBlueprint extends Blueprint +{ + /** + * Creates a new page blueprint object + * with the given props + */ + public function __construct(array $props) + { + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'changeTitle' => null, + 'update' => null, + ], + // aliases + [ + 'title' => 'changeTitle', + ] + ); + } + + /** + * Returns the preview settings + * The preview setting controls the "Open" + * button in the panel and redirects it to a + * different URL if necessary. + */ + public function preview(): string|bool + { + $preview = $this->props['options']['preview'] ?? true; + + if (is_string($preview) === true) { + return $this->model->toString($preview); + } + + return $this->model->permissions()->can('preview', true); + } +} diff --git a/public/kirby/src/Cms/SitePermissions.php b/public/kirby/src/Cms/SitePermissions.php new file mode 100644 index 0000000..fe64134 --- /dev/null +++ b/public/kirby/src/Cms/SitePermissions.php @@ -0,0 +1,17 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class SitePermissions extends ModelPermissions +{ + protected const CATEGORY = 'site'; +} diff --git a/public/kirby/src/Cms/SiteRules.php b/public/kirby/src/Cms/SiteRules.php new file mode 100644 index 0000000..609c880 --- /dev/null +++ b/public/kirby/src/Cms/SiteRules.php @@ -0,0 +1,54 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class SiteRules +{ + /** + * Validates if the site title can be changed + * + * @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): void + { + 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' + ); + } + } + + /** + * Validates if the site can be updated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the site + */ + public static function update(Site $site, array $content = []): void + { + if ($site->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'site.update.permission' + ); + } + } +} diff --git a/public/kirby/src/Cms/Structure.php b/public/kirby/src/Cms/Structure.php new file mode 100644 index 0000000..9ecd747 --- /dev/null +++ b/public/kirby/src/Cms/Structure.php @@ -0,0 +1,55 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Items<\Kirby\Cms\StructureObject> + */ +class Structure extends Items +{ + public const ITEM_CLASS = StructureObject::class; + + /** + * All registered structure methods + */ + public static array $methods = []; + + /** + * Creates a new structure collection from a + * an array of item props + */ + public static function factory( + array|null $items = null, + array $params = [] + ): static { + if (is_array($items) === true) { + $items = array_map(function ($item, $index) { + if (is_array($item) === true) { + // pass a clean content array without special `Item` keys + $item['content'] = $item; + + // bake-in index as ID for all items + // TODO: remove when adding UUID supports to Structures + $item['id'] ??= $index; + } + + return $item; + }, $items, array_keys($items)); + } + + return parent::factory($items, $params); + } +} diff --git a/public/kirby/src/Cms/StructureObject.php b/public/kirby/src/Cms/StructureObject.php new file mode 100644 index 0000000..57b509c --- /dev/null +++ b/public/kirby/src/Cms/StructureObject.php @@ -0,0 +1,87 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Item<\Kirby\Cms\Structure> + */ +class StructureObject extends Item +{ + use HasMethods; + + public const ITEMS_CLASS = Structure::class; + + protected Content $content; + + /** + * Creates a new StructureObject with the given props + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + $this->content = new Content( + $params['content'] ?? $params['params'] ?? [], + $this->parent + ); + } + + /** + * Modified getter to also return fields + * from the object's content + */ + public function __call(string $method, array $args = []): mixed + { + // structure object methods + if ($this->hasMethod($method) === true) { + return $this->callMethod($method, $args); + } + + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + return $this->content()->get($method); + } + + /** + * Returns the content + */ + public function content(): Content + { + return $this->content; + } + + /** + * Converts all fields in the object to a + * plain associative array. The id is + * injected from the parent into the array + * to make sure it's always present and + * not overloaded by the content. + */ + public function toArray(): array + { + return [ + ...$this->content()->toArray(), + ...parent::toArray() + ]; + } +} diff --git a/public/kirby/src/Cms/System.php b/public/kirby/src/Cms/System.php new file mode 100644 index 0000000..95b97d6 --- /dev/null +++ b/public/kirby/src/Cms/System.php @@ -0,0 +1,523 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class System +{ + // cache + protected License|null $license = null; + protected UpdateStatus|null $updateStatus = null; + + public function __construct(protected App $app) + { + // try to create all folders that could be missing + $this->init(); + } + + /** + * Check for a writable accounts folder + */ + public function accounts(): bool + { + return is_writable($this->app->root('accounts')) === true; + } + + /** + * Check for a writable content folder + */ + public function content(): bool + { + return is_writable($this->app->root('content')) === true; + } + + /** + * Check for an existing curl extension + */ + public function curl(): bool + { + return extension_loaded('curl') === true; + } + + /** + * Returns the URL to the file within a system folder + * if the file is located in the document + * root. Otherwise it will return null. + * + * @param string $folder 'git', 'content', 'site', 'kirby' + */ + public function exposedFileUrl(string $folder): string|null + { + if (!$url = $this->folderUrl($folder)) { + return null; + } + + switch ($folder) { + case 'content': + return $url . '/' . basename($this->app->site()->version('latest')->contentFile()); + case 'git': + return $url . '/config'; + case 'kirby': + return $url . '/LICENSE.md'; + case 'site': + $root = $this->app->root('site'); + $files = glob($root . '/blueprints/*.yml'); + + if (empty($files) === true) { + $files = glob($root . '/templates/*.*'); + } + + if (empty($files) === true) { + $files = glob($root . '/snippets/*.*'); + } + + if (empty($files) === true || empty($files[0]) === true) { + return $url; + } + + $file = $files[0]; + $file = basename(dirname($file)) . '/' . basename($file); + + return $url . '/' . $file; + default: + return null; + } + } + + /** + * Returns the URL to a system folder + * if the folder is located in the document + * root. Otherwise it will return null. + * + * @param string $folder 'git', 'content', 'site', 'kirby' + */ + public function folderUrl(string $folder): string|null + { + $index = $this->app->root('index'); + $root = match ($folder) { + 'git' => $index . '/.git', + default => $this->app->root($folder) + }; + + if ( + $root === null || + is_dir($root) === false || + is_dir($index) === false + ) { + return null; + } + + $root = realpath($root); + $index = realpath($index); + + // windows + $root = str_replace('\\', '/', $root); + $index = str_replace('\\', '/', $index); + + // the folder is not within the document root? + if (Str::startsWith($root, $index) === false) { + return null; + } + + // get the path after the document root + $path = trim(Str::after($root, $index), '/'); + + // build the absolute URL to the folder + return Url::to($path); + } + + /** + * Returns the app's human-readable + * index URL without scheme + */ + public function indexUrl(): string + { + return $this->app->url('index', true) + ->setScheme(null) + ->setSlash(false) + ->toString(); + } + + /** + * Returns an array with relevant system information + * used for debugging + * @since 4.3.0 + */ + public function info(): array + { + return [ + 'kirby' => $this->app->version(), + 'php' => phpversion(), + 'server' => $this->serverSoftware(), + 'license' => $this->license()->label(), + 'languages' => $this->app->languages()->values( + fn ($lang) => $lang->code() + ) + ]; + } + + /** + * Create the most important folders + * if they don't exist yet + * + * @throws \Kirby\Exception\PermissionException + */ + public function init(): void + { + // init /site/accounts + try { + Dir::make($this->app->root('accounts')); + } catch (Throwable) { + 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( + message: 'The sessions directory could not be created' + ); + } + + // init /content + try { + Dir::make($this->app->root('content')); + } catch (Throwable) { + 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( + message: 'The media directory could not be created' + ); + } + } + + /** + * Check if the Panel has 2FA activated + */ + public function is2FA(): bool + { + return ($this->loginMethods()['password']['2fa'] ?? null) === true; + } + + /** + * Check if the Panel has 2FA with TOTP activated + */ + public function is2FAWithTOTP(): bool + { + return + $this->is2FA() === true && + in_array('totp', $this->app->auth()->enabledChallenges(), true) === true; + } + + /** + * Check if the Panel is installable. + * On a public server the panel.install + * option must be explicitly set to true + * to get the installer up and running. + */ + public function isInstallable(): bool + { + return + $this->isLocal() === true || + $this->app->option('panel.install', false) === true; + } + + /** + * Check if Kirby is already installed + */ + public function isInstalled(): bool + { + return $this->app->users()->count() > 0; + } + + /** + * Check if this is a local installation + */ + public function isLocal(): bool + { + return $this->app->environment()->isLocal(); + } + + /** + * Check if all tests pass + */ + public function isOk(): bool + { + return in_array(false, array_values($this->status()), true) === false; + } + + /** + * Loads the license file and returns + * the license information if available + */ + public function license(): License + { + return $this->license ??= License::read(); + } + + /** + * Returns the configured UI modes for the login form + * with their respective options + * + * @throws \Kirby\Exception\InvalidArgumentException If the configuration is invalid + * (only in debug mode) + */ + public function loginMethods(): array + { + $default = ['password' => []]; + $methods = A::wrap($this->app->option('auth.methods', $default)); + + // normalize the syntax variants + $normalized = []; + $uses2fa = false; + foreach ($methods as $key => $value) { + if (is_int($key) === true) { + // ['password'] + $normalized[$value] = []; + } elseif ($value === true) { + // ['password' => true] + $normalized[$key] = []; + } else { + // ['password' => [...]] + $normalized[$key] = $value; + + if (isset($value['2fa']) === true && $value['2fa'] === true) { + $uses2fa = true; + } + } + } + + // 2FA must not be circumvented by code-based modes + foreach (['code', 'password-reset'] as $method) { + if ($uses2fa === true && isset($normalized[$method]) === true) { + unset($normalized[$method]); + + if ($this->app->option('debug') === true) { + $message = 'The "' . $method . '" login method cannot be enabled when 2FA is required'; + throw new InvalidArgumentException($message); + } + } + } + + // only one code-based mode can be active at once + if ( + isset($normalized['code']) === true && + isset($normalized['password-reset']) === true + ) { + unset($normalized['code']); + + if ($this->app->option('debug') === true) { + $message = 'The "code" and "password-reset" login methods cannot be enabled together'; + throw new InvalidArgumentException($message); + } + } + + return $normalized; + } + + /** + * Check for an existing mbstring extension + */ + public function mbString(): bool + { + return extension_loaded('mbstring') === true; + } + + /** + * Check for a writable media folder + */ + public function media(): bool + { + return is_writable($this->app->root('media')) === true; + } + + /** + * Check for a valid PHP version + */ + public function php(): bool + { + return + version_compare(PHP_VERSION, '8.2.0', '>=') === true && + version_compare(PHP_VERSION, '8.5.0', '<') === true; + } + + /** + * Returns a sorted collection of all + * installed plugins + */ + public function plugins(): Collection + { + $plugins = new Collection($this->app->plugins()); + return $plugins->sortBy('name', 'asc'); + } + + /** + * Validates the license key + * and adds it to the .license file in the config + * folder if possible. + * + * @throws \Kirby\Exception\Exception + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function register(string|null $license = null, string|null $email = null): bool + { + $license = new License( + code: $license, + domain: $this->indexUrl(), + email: $email, + ); + + $this->license = $license->register(); + return true; + } + + /** + * Returns the detected server software + */ + public function serverSoftware(): string + { + return $this->app->environment()->get('SERVER_SOFTWARE', '–'); + } + + /** + * Returns the short version of the detected server software + * @since 4.6.0 + */ + public function serverSoftwareShort(): string + { + $software = $this->serverSoftware(); + return strtok($software, ' '); + } + + /** + * Check for a writable sessions folder + */ + public function sessions(): bool + { + return is_writable($this->app->root('sessions')) === true; + } + + /** + * Get an status array of all checks + */ + public function status(): array + { + return [ + 'accounts' => $this->accounts(), + 'content' => $this->content(), + 'curl' => $this->curl(), + 'sessions' => $this->sessions(), + 'mbstring' => $this->mbstring(), + 'media' => $this->media(), + 'php' => $this->php() + ]; + } + + /** + * Returns the site's title as defined in the + * content file or `site.yml` blueprint + * @since 3.6.0 + */ + public function title(): string + { + $site = $this->app->site(); + + if ($site->title()->isNotEmpty() === true) { + return $site->title()->value(); + } + + return $site->blueprint()->title(); + } + + public function toArray(): array + { + return $this->status(); + } + + /** + * Returns the update status object unless + * the update check for Kirby has been disabled + * @since 3.8.0 + * + * @param array|null $data Custom override for the getkirby.com update data + */ + public function updateStatus(array|null $data = null): UpdateStatus|null + { + if ($this->updateStatus !== null) { + return $this->updateStatus; + } + + $kirby = $this->app; + $option = + $kirby->option('updates.kirby') ?? + $kirby->option('updates', true); + + if ($option === false) { + return null; + } + + return $this->updateStatus = new UpdateStatus( + $kirby, + $option === 'security', + $data + ); + } + + /** + * Upgrade to the new folder separator + */ + public static function upgradeContent(string $root): void + { + $index = Dir::read($root); + + foreach ($index as $dir) { + $oldRoot = $root . '/' . $dir; + $newRoot = preg_replace('!\/([0-9]+)\-!', '/$1_', $oldRoot); + + if (is_dir($oldRoot) === true) { + Dir::move($oldRoot, $newRoot); + static::upgradeContent($newRoot); + } + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/public/kirby/src/Cms/System/UpdateStatus.php b/public/kirby/src/Cms/System/UpdateStatus.php new file mode 100644 index 0000000..51e70e0 --- /dev/null +++ b/public/kirby/src/Cms/System/UpdateStatus.php @@ -0,0 +1,838 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UpdateStatus +{ + /** + * Host to request the update data from + */ + public static string $host = 'https://getkirby.com'; + + /** + * Marker that stores whether a previous remote + * request timed out + */ + protected static bool $timedOut = false; + + // props set in constructor + protected App $app; + protected string|null $currentVersion; + protected array|null $data; + protected string|null $pluginName; + protected bool $securityOnly; + + // props updated throughout the class + protected array $exceptions = []; + protected bool|null $noVulns = null; + + // caches + protected array $messages; + protected array $targetData; + protected array|bool $versionEntry; + protected array $vulnerabilities; + + /** + * @param array|null $data Custom override for the getkirby.com update data + */ + public function __construct( + App|Plugin $package, + bool $securityOnly = false, + array|null $data = null + ) { + if ($package instanceof App) { + $this->app = $package; + $this->pluginName = null; + } else { + $this->app = $package->kirby(); + $this->pluginName = $package->name(); + } + + $this->securityOnly = $securityOnly; + $this->currentVersion = $package->version(); + + $this->data = $data ?? $this->loadData(); + } + + /** + * Returns the currently installed version + */ + public function currentVersion(): string|null + { + return $this->currentVersion; + } + + /** + * Returns the list of exception objects that were + * collected during data fetching and processing + */ + public function exceptions(): array + { + return $this->exceptions; + } + + /** + * Returns the list of exception message strings that + * were collected during data fetching and processing + */ + public function exceptionMessages(): array + { + return array_map(fn ($e) => $e->getMessage(), $this->exceptions()); + } + + /** + * Returns the Panel icon for the status value + * + * @return string 'check'|'alert'|'info'|'question' + */ + public function icon(): string + { + return match ($this->status()) { + 'up-to-date', 'not-vulnerable' => 'check', + 'security-update', 'security-upgrade' => 'alert', + 'update', 'upgrade' => 'info', + default => 'question' + }; + } + + /** + * Returns the human-readable and translated label + * for the update status + */ + public function label(): string + { + return I18n::template( + 'system.updateStatus.' . $this->status(), + '?', + ['version' => $this->targetVersion() ?? '?'] + ); + } + + /** + * Returns the latest available version + */ + public function latestVersion(): string|null + { + return $this->data['latest'] ?? null; + } + + /** + * Returns all security messages unless no data + * is available + */ + public function messages(): array|null + { + if (isset($this->messages) === true) { + return $this->messages; + } + + if ( + $this->data === null || + $this->currentVersion === null || + $this->currentVersion === '' + ) { + return null; + } + + $type = $this->pluginName ? 'plugin' : 'kirby'; + + // collect all matching custom messages + $filters = [ + 'kirby' => $this->app->version(), + // some PHP version strings contain extra info that makes them + // invalid so we need to strip it off + 'php' => preg_replace('/^([^~+-]+).*$/', '$1', phpversion()) + ]; + + if ($type === 'plugin') { + $filters['plugin'] = $this->currentVersion; + } + + $messages = $this->filterArrayByVersion( + $this->data['messages'] ?? [], + $filters, + 'while filtering messages' + ); + + // add a message for each vulnerability + // the current version is affected by + foreach ($this->vulnerabilities() as $vulnerability) { + if ($type === 'plugin') { + $vulnerability['plugin'] = $this->pluginName; + } + + $messages[] = [ + 'text' => I18n::template( + 'system.issues.vulnerability.' . $type, + null, + $vulnerability + ), + 'link' => $vulnerability['link'] ?? null, + 'icon' => 'bug' + ]; + } + + // add special message for end-of-life versions + $versionEntry = $this->versionEntry(); + if (($versionEntry['status'] ?? null) === 'end-of-life') { + $messages[] = [ + 'text' => match ($type) { + 'plugin' => I18n::template( + 'system.issues.eol.plugin', + null, + ['plugin' => $this->pluginName] + ), + default => I18n::translate('system.issues.eol.kirby') + }, + 'link' => $versionEntry['status-link'] ?? 'https://getkirby.com/security/end-of-life', + 'icon' => 'bell' + ]; + } + + // add special message for end-of-life PHP versions + $phpMajor = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + $phpEol = $this->data['php'][$phpMajor] ?? null; + if (is_string($phpEol) === true && $eolTime = strtotime($phpEol)) { + // the timestamp is available and valid, now check if it is in the past + if ($eolTime < time()) { + $messages[] = [ + 'text' => I18n::template('system.issues.eol.php', null, ['release' => $phpMajor]), + 'link' => 'https://getkirby.com/security/php-end-of-life', + 'icon' => 'bell' + ]; + } + } + + return $this->messages = $messages; + } + + /** + * Returns the raw status value + * + * @return string 'up-to-date'|'not-vulnerable'|'security-update'| + * 'security-upgrade'|'update'|'upgrade'|'unreleased'|'error' + */ + public function status(): string + { + return $this->targetData()['status']; + } + + /** + * Version that is suggested for the update/upgrade + */ + public function targetVersion(): string|null + { + return $this->targetData()['version']; + } + + /** + * Returns the Panel theme for the status value + * + * @return string 'positive'|'negative'|'info'|'notice' + */ + public function theme(): string + { + return match ($this->status()) { + 'up-to-date', 'not-vulnerable' => 'positive', + 'security-update', 'security-upgrade' => 'negative', + 'update', 'upgrade' => 'info', + default => 'passive' + }; + } + + /** + * Returns the most important human-readable + * status information as array + */ + public function toArray(): array + { + return [ + 'currentVersion' => $this->currentVersion() ?? '?', + 'icon' => $this->icon(), + 'label' => $this->label(), + 'latestVersion' => $this->latestVersion() ?? '?', + 'pluginName' => $this->pluginName, + 'theme' => $this->theme(), + 'url' => $this->url(), + ]; + } + + /** + * URL of the target version with fallback + * to the URL of the current version; + * `null` is returned if no URL is known + */ + public function url(): string|null + { + return $this->targetData()['url']; + } + + /** + * Returns all vulnerabilities the current version + * is affected by unless no data is available + */ + public function vulnerabilities(): array|null + { + if (isset($this->vulnerabilities) === true) { + return $this->vulnerabilities; + } + + if ( + $this->data === null || + $this->currentVersion === null || + $this->currentVersion === '' + ) { + return null; + } + + // shortcut for versions without vulnerabilities + $this->versionEntry(); + if ($this->noVulns === true) { + return $this->vulnerabilities = []; + } + + // unstable releases are released before their respective + // stable release and would not be matched by the constraints, + // but they will likely also contain the same vulnerabilities; + // so we strip off any non-numeric version modifiers from the end + preg_match('/^([0-9.]+)/', $this->currentVersion, $matches); + $currentVersion = $matches[1]; + + $vulnerabilities = $this->filterArrayByVersion( + $this->data['incidents'] ?? [], + ['affected' => $currentVersion], + 'while filtering incidents' + ); + + // sort the vulnerabilities by severity (with critical first) + $severities = array_map( + fn ($vulnerability) => match ($vulnerability['severity'] ?? null) { + 'critical' => 4, + 'high' => 3, + 'medium' => 2, + 'low' => 1, + default => 0 + }, + $vulnerabilities + ); + array_multisort($severities, SORT_DESC, $vulnerabilities); + + return $this->vulnerabilities = $vulnerabilities; + } + + /** + * Compares a version against a Composer version constraint + * and returns whether the constraint is satisfied + * + * @param string $reason Suffix for error messages + */ + protected function checkConstraint(string $version, string $constraint, string $reason): bool + { + try { + return Semver::satisfies($version, $constraint); + } catch (Exception $e) { + $this->exceptions[] = new KirbyException( + previous: $e, + fallback: 'Error comparing version constraint for ' . $this->packageName() . ' ' . $reason . ': ' . $e->getMessage(), + ); + + return false; + } + } + + /** + * Filters a two-level array with one or multiple version constraints + * for each value by one or multiple version filters; + * values that don't contain the filter keys are removed + * + * @param array $array Array that contains associative arrays + * @param array $filters Associative array `field => version` + * @param string $reason Suffix for error messages + */ + protected function filterArrayByVersion(array $array, array $filters, string $reason): array + { + return array_filter($array, function ($item) use ($filters, $reason): bool { + foreach ($filters as $key => $version) { + if (isset($item[$key]) !== true) { + $package = $this->packageName(); + $this->exceptions[] = new KirbyException( + 'Missing constraint ' . $key . ' for ' . $package . ' ' . $reason + ); + + return false; + } + + if ($this->checkConstraint($version, $item[$key], $reason) !== true) { + return false; + } + } + + return true; + }); + } + + /** + * Finds the maximum possible major update + * that is included with the current license + * + * @return string|null Version number of the update or + * `null` if no free update is possible + */ + protected function findMaximumFreeUpdate(): string|null + { + // get the timestamp of included updates + $renewal = $this->app->system()->license()->renewal(); + + if ($renewal === null || $this->data === null) { + return null; + } + + foreach ($this->data['versions'] ?? [] as $entry) { + $initialRelease = $entry['initialRelease'] ?? null; + $latest = $entry['latest'] ?? ''; + + // skip entries of irrelevant releases + if ( + is_string($initialRelease) !== true || + version_compare($latest, $this->currentVersion, '<=') === true + ) { + continue; + } + + $timestamp = strtotime($initialRelease); + + // update is free if the initial release was before the + // license renewal date + if (is_int($timestamp) === true && $timestamp < $renewal) { + return $latest; + } + } + + return null; + } + + /** + * Finds the minimum possible security update + * to fix all known vulnerabilities + * + * @return string|null Version number of the update or + * `null` if no free update is possible + */ + protected function findMinimumSecurityUpdate(): string|null + { + $versionEntry = $this->versionEntry(); + if ($versionEntry === null || isset($versionEntry['latest']) !== true) { + return null; // @codeCoverageIgnore + } + + $affected = $this->vulnerabilities(); + $incidents = $this->data['incidents'] ?? []; + $maxVersion = $versionEntry['latest']; + + // increase the target version number until there are no vulnerabilities + $version = $this->currentVersion; + $iterations = 0; + while (empty($affected) === false) { + // protect against infinite loops if the + // input data is contradicting itself + $iterations++; + if ($iterations > 10) { + return null; + } + + // if we arrived at the `$maxVersion` but still haven't found + // a version without vulnerabilities, we cannot suggest a version + if ($version === $maxVersion) { + return null; + } + + // find the minimum version that fixes all affected vulnerabilities + foreach ($affected as $incident) { + $incidentVersion = null; + foreach (Str::split($incident['fixed'], ',') as $fixed) { + // skip versions of other major releases + if ( + version_compare($fixed, $this->currentVersion, '<') === true || + version_compare($fixed, $maxVersion, '>') === true + ) { + continue; + } + + // find the minimum version that fixes this specific vulnerability + if ( + $incidentVersion === null || + version_compare($fixed, $incidentVersion, '<') === true + ) { + $incidentVersion = $fixed; + } + } + + // verify that we found at least one possible version; + // otherwise try the `$maxVersion` as a last chance before + // concluding at the top that we cannot solve the task + $incidentVersion ??= $maxVersion; + + // we need a version that fixes all vulnerabilities, so use the + // "largest of the smallest" fixed versions + if (version_compare($incidentVersion, $version, '>') === true) { + $version = $incidentVersion; + } + } + + // run another loop to verify that the suggested version + // doesn't have any known vulnerabilities on its own + $affected = $this->filterArrayByVersion( + $incidents, + ['affected' => $version], + 'while filtering incidents' + ); + } + + return $version; + } + + /** + * Loads the getkirby.com update data + * from cache or via HTTP + */ + protected function loadData(): array|null + { + // try to get the data from cache + $cache = $this->app->cache('updates'); + $key = ( + $this->pluginName ? + 'plugins/' . $this->pluginName : + 'security' + ); + + // try to return from cache; + // invalidate the cache after updates + $data = $cache->get($key); + if ( + is_array($data) === true && + $data['_version'] === $this->currentVersion + ) { + return $data; + } + + // exception message (on previous request error) + if (is_string($data) === true) { + // restore the exception to make it visible when debugging + $this->exceptions[] = new KirbyException($data); + + return null; + } + + // 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( + message: 'Cannot check for updates without a working "updates" cache' + ); + + return null; + } + + // first catch every exception; + // we collect it below for debugging + try { + if (static::$timedOut === true) { + throw new Exception(message: 'Previous remote request timed out'); // @codeCoverageIgnore + } + + $response = Remote::get( + static::$host . '/' . $key . '.json', + ['timeout' => 2] + ); + + // 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(message: 'HTTP error ' . $response->code()); // @codeCoverageIgnore + } + + $data = $response->json(); + + if (is_array($data) !== true) { + 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 + ); + $this->exceptions[] = $exception; + + // if the request timed out, prevent additional + // requests for other packages (e.g. plugins) + // to avoid long Panel hangs + if ($e->getCode() === 28) { + static::$timedOut = true; // @codeCoverageIgnore + } elseif (static::$timedOut === false) { + // different error than timeout; + // prevent additional requests in the + // next three days (e.g. if a plugin + // does not have a page on getkirby.com) + // by caching the exception message + // instead of the data array + $cache->set($key, $exception->getMessage(), 3 * 24 * 60); + } + + return null; + } + + // also cache the current version to + // invalidate the cache after updates + // (ensures that the update status is + // fresh directly after the update to + // avoid confusion with outdated info) + $data['_version'] = $this->currentVersion; + + // cache the retrieved data for three days + $cache->set($key, $data, 3 * 24 * 60); + + return $data; + } + + /** + * Returns the human-readable package name for error messages + */ + protected function packageName(): string + { + return $this->pluginName ? 'plugin ' . $this->pluginName : 'Kirby'; + } + + /** + * Performs the update check and returns data for the + * target version (with fallback and error handling) + */ + protected function targetData(): array + { + if (isset($this->targetData) === true) { + return $this->targetData; + } + + // check if we have valid data to compare to + $versionEntry = $this->versionEntry(); + if ($versionEntry === null) { + $version = $this->currentVersion ?? $this->data['latest'] ?? null; + + return $this->targetData = [ + 'status' => 'error', + 'url' => $version ? $this->urlFor($version, 'changes') : null, + 'version' => null + ]; + } + + // check if the current version is the latest available + if (($versionEntry['status'] ?? null) === 'latest') { + return $this->targetData = [ + 'status' => 'up-to-date', + 'url' => $this->urlFor($this->currentVersion, 'changes'), + 'version' => null + ]; + } + + // check if the current version is unreleased + if (($versionEntry['status'] ?? null) === 'unreleased') { + return $this->targetData = [ + 'status' => 'unreleased', + 'url' => null, + 'version' => null + ]; + } + + // check if the installation is vulnerable; + // minimum possible security fixes are preferred + // over all other updates and upgrades + if (count($this->vulnerabilities()) > 0) { + $update = $this->findMinimumSecurityUpdate(); + + if ($update !== null) { + // a free security update was found + return $this->targetData = [ + 'status' => 'security-update', + 'url' => $this->urlFor($update, 'changes'), + 'version' => $update + ]; + } + + // only a paid security upgrade is possible + return $this->targetData = [ + 'status' => 'security-upgrade', + 'url' => $this->urlFor($this->currentVersion, 'upgrade'), + 'version' => $this->data['latest'] ?? null + ]; + } + + // check if the user limited update checking to security updates + if ($this->securityOnly === true) { + return $this->targetData = [ + 'status' => 'not-vulnerable', + 'url' => $this->urlFor($this->currentVersion, 'changes'), + 'version' => null + ]; + } + + // check if updates within the same major version are possible + $latest = $versionEntry['latest'] ?? null; + if (is_string($latest) === true && $latest !== $this->currentVersion) { + return $this->targetData = [ + 'status' => 'update', + 'url' => $this->urlFor($latest, 'changes'), + 'version' => $latest + ]; + } + + // check if the license includes updates to a newer major version + if ($version = $this->findMaximumFreeUpdate()) { + // extract the part before the first dot + // to find the major release page URL + preg_match('/^(\w+)\./', $version, $matches); + + return $this->targetData = [ + 'status' => 'update', + 'url' => $this->urlFor($matches[1] . '.0', 'changes'), + 'version' => $version + ]; + } + + // no free update is possible, but we are not on the latest version, + // so the overall latest version must be an upgrade + return $this->targetData = [ + 'status' => 'upgrade', + 'url' => $this->urlFor($this->currentVersion, 'upgrade'), + 'version' => $this->data['latest'] ?? null + ]; + } + + /** + * Returns the URL for a specific version and purpose + */ + protected function urlFor(string $version, string $purpose): string|null + { + if ($this->data === null) { + return null; + } + + // find the first matching entry + $url = null; + foreach ($this->data['urls'] ?? [] as $constraint => $entry) { + // filter out every entry that does not match the version + if ($this->checkConstraint($version, $constraint, 'while finding URL') !== true) { + continue; + } + + // we found a result + $url = $entry[$purpose] ?? null; + if ($url !== null) { + break; + } + } + + if ($url === null) { + $package = $this->packageName(); + $message = 'No matching URL found for ' . $package . '@' . $version; + + $this->exceptions[] = new KirbyException($message); + + return null; + } + + // insert the URL template placeholders + return Str::template($url, [ + 'current' => $this->currentVersion, + 'version' => $version + ]); + } + + /** + * Extracts the first matching version entry from + * the data array unless no data is available + */ + protected function versionEntry(): array|null + { + if (isset($this->versionEntry) === true) { + // no version entry found on last call + if ($this->versionEntry === false) { + return null; + } + + return $this->versionEntry; + } + + if ( + $this->data === null || + $this->currentVersion === null || + $this->currentVersion === '' + ) { + return null; + } + + // special check for unreleased versions + $latest = $this->data['latest'] ?? null; + if ( + $latest !== null && + version_compare($this->currentVersion, $latest, '>') === true + ) { + return [ + 'status' => 'unreleased' + ]; + } + + $versionEntry = null; + foreach ($this->data['versions'] ?? [] as $constraint => $entry) { + // filter out every entry that does not match the current version + if ($this->checkConstraint($this->currentVersion, $constraint, 'while finding version entry') !== true) { + continue; + } + + if (($entry['status'] ?? null) === 'no-vulnerabilities') { + $this->noVulns = true; + + // use the next matching version entry with + // more specific update information + continue; + } + + if (($entry['status'] ?? null) === 'latest') { + $this->noVulns = true; + } + + // we found a result + $versionEntry = $entry; + break; + } + + if ($versionEntry === null) { + $package = $this->packageName(); + $message = 'No matching version entry found for ' . $package . '@' . $this->currentVersion; + + $this->exceptions[] = new KirbyException($message); + } + + $this->versionEntry = $versionEntry ?? false; + return $versionEntry; + } +} diff --git a/public/kirby/src/Cms/Translation.php b/public/kirby/src/Cms/Translation.php new file mode 100644 index 0000000..13d6df2 --- /dev/null +++ b/public/kirby/src/Cms/Translation.php @@ -0,0 +1,155 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Translation +{ + public function __construct( + protected string $code, + protected array $data + ) { + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the translation author + */ + public function author(): string + { + return $this->get('translation.author', 'Kirby'); + } + + /** + * Returns the official translation code + */ + public function code(): string + { + return $this->code; + } + + /** + * Returns an array with all + * translation strings + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns the translation data and merges + * it with the data from the default translation + */ + public function dataWithFallback(): array + { + if ($this->code === 'en') { + return $this->data; + } + + return [ + // add the fallback array + ...App::instance()->translation('en')->data(), + ...$this->data + ]; + } + + /** + * Returns the writing direction + * (ltr or rtl) + */ + public function direction(): string + { + return $this->get('translation.direction', 'ltr'); + } + + /** + * Returns a single translation + * string by key + */ + public function get(string $key, string|null $default = null): string|null + { + return $this->data[$key] ?? $default; + } + + /** + * Returns the translation id, + * which is also the code + */ + public function id(): string + { + return $this->code; + } + + /** + * Loads the translation from the + * json file in Kirby's translations folder + */ + public static function load( + string $code, + string $root, + array $inject = [] + ): static { + $data = [ + ...Data::read($root, fail: false), + ...$inject + ]; + + return new static($code, $data); + } + + /** + * Returns the PHP locale of the translation + */ + public function locale(): string + { + $default = $this->code; + if (Str::contains($default, '_') !== true) { + $default .= '_' . strtoupper($this->code); + } + + return $this->get('translation.locale', $default); + } + + /** + * Returns the human-readable translation name. + */ + public function name(): string + { + return $this->get('translation.name', $this->code); + } + + /** + * Converts the most important + * properties to an array + */ + public function toArray(): array + { + return [ + 'code' => $this->code(), + 'data' => $this->data(), + 'name' => $this->name(), + 'author' => $this->author(), + ]; + } +} diff --git a/public/kirby/src/Cms/Translations.php b/public/kirby/src/Cms/Translations.php new file mode 100644 index 0000000..916018f --- /dev/null +++ b/public/kirby/src/Cms/Translations.php @@ -0,0 +1,62 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Cms\Translation> + */ +class Translations extends Collection +{ + /** + * All registered translations methods + */ + public static array $methods = []; + + public static function factory(array $translations): static + { + $collection = new static(); + + foreach ($translations as $code => $props) { + $translation = new Translation($code, $props); + $collection->data[$translation->code()] = $translation; + } + + return $collection; + } + + public static function load(string $root, array $inject = []): static + { + $collection = new static(); + + foreach (Dir::read($root) as $filename) { + if (F::extension($filename) !== 'json') { + continue; + } + + $locale = F::name($filename); + $translation = Translation::load( + $locale, + $root . '/' . $filename, + $inject[$locale] ?? [] + ); + + $collection->data[$locale] = $translation; + } + + return $collection; + } +} diff --git a/public/kirby/src/Cms/Url.php b/public/kirby/src/Cms/Url.php new file mode 100644 index 0000000..c8dee99 --- /dev/null +++ b/public/kirby/src/Cms/Url.php @@ -0,0 +1,86 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Url extends BaseUrl +{ + public static string|null $home = null; + + /** + * Returns the Url to the homepage + */ + public static function home(): string + { + return App::instance()->url(); + } + + /** + * Convert a string to a safe version to be used in a URL, + * obeying the `slugs.maxlength` option + * + * @param string $string The unsafe string + * @param string $separator To be used instead of space and + * other non-word characters. + * @param string $allowed List of all allowed characters (regex) + * @param int $maxlength The maximum length of the slug + * @return string The safe string + */ + public static function slug( + string|null $string = null, + string|null $separator = null, + string|null $allowed = null, + ): string { + $maxlength = App::instance()->option('slugs.maxlength', 255); + return Str::slug($string, $separator, $allowed, $maxlength); + } + + /** + * Creates an absolute Url to a template asset if it exists. + * This is used in the `css()` and `js()` helpers + */ + public static function toTemplateAsset( + string $assetPath, + string $extension + ): string|null { + $kirby = App::instance(); + $page = $kirby->site()->page(); + $path = $assetPath . '/' . $page->template() . '.' . $extension; + $root = $kirby->root('assets'); + $file = $root . '/' . $path; + $url = $kirby->url('assets') . '/' . $path; + + return F::exists($file, $root) === true ? $url : null; + } + + /** + * Smart resolver for internal and external urls + * + * @param array|string|null $options Either an array of options for the Uri class or a language string + */ + public static function to( + string|null $path = null, + array|string|null $options = null + ): string { + $kirby = App::instance(); + return ($kirby->component('url'))($kirby, $path, $options); + } +} diff --git a/public/kirby/src/Cms/User.php b/public/kirby/src/Cms/User.php new file mode 100644 index 0000000..6f749e0 --- /dev/null +++ b/public/kirby/src/Cms/User.php @@ -0,0 +1,742 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Cms\Users> + * @method \Kirby\Uuid\UserUuid uuid() + */ +class User extends ModelWithContent +{ + use HasFiles; + use HasMethods; + use HasModels; + use HasSiblings; + use UserActions; + + public const CLASS_ALIAS = 'user'; + + /** + * All registered user methods + * @todo Remove when support for PHP 8.2 is dropped + */ + public static array $methods = []; + + protected UserBlueprint|null $blueprint = null; + protected array $credentials; + protected string|null $email; + protected string $hash; + protected string $id; + protected array|null $inventory = null; + protected string|null $language; + protected Field|string|null $name; + protected string|null $password; + protected Role|string|null $role; + + /** + * Creates a new User object + */ + public function __construct(array $props) + { + // helper function to easily edit values (if not null) + // before assigning them to their properties + $set = static function (string $key, Closure $callback) use ($props) { + if ($value = $props[$key] ?? null) { + $value = $callback($value); + } + + return $value; + }; + + // if no ID passed, generate one; + // do so before calling parent constructor + // so it also gets stored in propertyData prop + $props['id'] ??= $this->createId(); + + $this->id = $props['id']; + $this->email = $set('email', fn ($email) => Str::lower(trim($email))); + $this->language = $set('language', fn ($language) => trim($language)); + $this->name = $set('name', fn ($name) => trim(strip_tags($name))); + $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); + } + + /** + * Modified getter to also return fields + * from the content + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // user methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // return site content otherwise + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + ...$this->toArray(), + 'avatar' => $this->avatar(), + 'content' => $this->content(), + 'role' => $this->role() + ]; + } + + /** + * Returns the url to the api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + if ($relative === true) { + return 'users/' . $this->id(); + } + + return $this->kirby()->url('api') . '/users/' . $this->id(); + } + + /** + * Returns the File object for the avatar or null + */ + public function avatar(): File|null + { + return $this->files()->template('avatar')->first(); + } + + /** + * Returns the UserBlueprint object + */ + public function blueprint(): UserBlueprint + { + try { + return $this->blueprint ??= UserBlueprint::factory( + 'users/' . $this->role(), + 'users/default', + $this + ); + } catch (Exception) { + return $this->blueprint ??= new UserBlueprint([ + 'model' => $this, + 'name' => 'default', + 'title' => 'Default', + ]); + } + } + + /** + * Prepares the content for the write method + * @internal + * + * @param string|null $languageCode Not used so far + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + // remove stuff that has nothing to do in the text files + unset( + $data['email'], + $data['language'], + $data['name'], + $data['password'], + $data['role'] + ); + + return $data; + } + + protected function credentials(): array + { + return $this->credentials ??= $this->readCredentials(); + } + + /** + * Returns the user email address + */ + public function email(): string|null + { + return $this->email ??= $this->credentials()['email'] ?? null; + } + + /** + * Checks if the user exists + */ + public function exists(): bool + { + return $this->version('latest')->exists('default'); + } + + /** + * Constructs a User object and also + * takes User models into account + */ + public static function factory(mixed $props): static + { + return static::model($props['model'] ?? $props['role'] ?? 'default', $props); + } + + /** + * Hashes the provided password unless it is `null`, + * which will leave it as `null` + */ + public static function hashPassword( + #[SensitiveParameter] + string|null $password = null + ): string|null { + if ($password !== null) { + $password = password_hash($password, PASSWORD_DEFAULT); + } + + return $password; + } + + /** + * Returns the user id + */ + public function id(): string + { + return $this->id; + } + + /** + * Returns the inventory of files + * children and content files + */ + public function inventory(): array + { + if ($this->inventory !== null) { + return $this->inventory; + } + + $kirby = $this->kirby(); + + return $this->inventory = Dir::inventory( + $this->root(), + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + } + + /** + * Compares the current object with the given user object + */ + public function is(User|null $user = null): bool + { + if ($user === null) { + return false; + } + + return $this->id() === $user->id(); + } + + /** + * Checks if this user has the admin role + */ + public function isAdmin(): bool + { + return $this->role()->id() === 'admin'; + } + + /** + * Checks if the current user is the virtual + * Kirby user + */ + public function isKirby(): bool + { + return $this->isAdmin() && $this->id() === 'kirby'; + } + + /** + * Checks if the current user is this user + */ + public function isLoggedIn(): bool + { + return $this->is($this->kirby()->user()); + } + + /** + * Checks if the user is the last one + * with the admin role + */ + public function isLastAdmin(): bool + { + return + $this->role()->isAdmin() === true && + $this->kirby()->users()->filter('role', 'admin')->count() <= 1; + } + + /** + * Checks if the user is the last user + */ + public function isLastUser(): bool + { + return $this->kirby()->users()->count() === 1; + } + + /** + * Checks if the current user is the virtual + * Nobody user + */ + public function isNobody(): bool + { + return $this->role()->id() === 'nobody' && $this->id() === 'nobody'; + } + + /** + * Returns the user language + */ + public function language(): string + { + return $this->language ??= + $this->credentials()['language'] ?? + $this->kirby()->panelLanguage(); + } + + /** + * Logs the user in + * + * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in + */ + public function login( + #[SensitiveParameter] + string $password, + $session = null + ): bool { + $this->validatePassword($password); + $this->loginPasswordless($session); + + return true; + } + + /** + * Logs the user in without checking the password + * + * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in + */ + public function loginPasswordless( + Session|array|null $session = null + ): void { + if ($this->id() === 'kirby') { + 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(); + $session = $this->sessionFromOptions($session); + + $kirby->trigger( + 'user.login:before', + ['user' => $this, 'session' => $session] + ); + + $session->regenerateToken(); // privilege change + $session->data()->set('kirby.userId', $this->id()); + + if ($this->passwordTimestamp() !== null) { + $session->data()->set('kirby.loginTimestamp', time()); + } + + $kirby->auth()->setUser($this); + + $kirby->trigger( + 'user.login:after', + ['user' => $this, 'session' => $session] + ); + } + + /** + * Logs the user out + * + * @param \Kirby\Session\Session|array|null $session Session options or session object to unset the user in + */ + public function logout(Session|array|null $session = null): void + { + $kirby = $this->kirby(); + $session = $this->sessionFromOptions($session); + + $kirby->trigger('user.logout:before', ['user' => $this, 'session' => $session]); + + // remove the user from the session for future requests + $session->data()->remove('kirby.userId'); + $session->data()->remove('kirby.loginTimestamp'); + + // clear the cached user object from the app state of the current request + $this->kirby()->auth()->flush(); + + if ($session->data()->get() === []) { + // session is now empty, we might as well destroy it + $session->destroy(); + + $kirby->trigger('user.logout:after', ['user' => $this, 'session' => null]); + } else { + // privilege change + $session->regenerateToken(); + + $kirby->trigger('user.logout:after', ['user' => $this, 'session' => $session]); + } + } + + /** + * Returns the absolute path to the media folder for the user + */ + 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 + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/users/' . $this->id(); + } + + /** + * Returns the last modification date of the user + */ + public function modified( + string $format = 'U', + string|null $handler = null, + string|null $languageCode = null + ): int|string|false { + $modifiedContent = $this->version('latest')->modified($languageCode ?? 'current'); + $modifiedIndex = F::modified($this->root() . '/index.php'); + $modifiedTotal = max([$modifiedContent, $modifiedIndex]); + + return Str::date($modifiedTotal, $format, $handler); + } + + /** + * Returns the user's name + */ + public function name(): Field + { + if (is_string($this->name) === true) { + return new Field($this, 'name', $this->name); + } + + return $this->name ??= new Field($this, 'name', $this->credentials()['name'] ?? null); + } + + /** + * Returns the user's name or, + * if empty, the email address + */ + public function nameOrEmail(): Field + { + return $this->name()->or(new Field($this, 'email', $this->email())); + } + + /** + * Create a dummy nobody + */ + public static function nobody(): static + { + return new static([ + 'email' => 'nobody@getkirby.com', + 'role' => 'nobody' + ]); + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the encrypted user password + */ + public function password(): string|null + { + return $this->password ??= $this->readPassword(); + } + + /** + * Returns the timestamp when the password + * was last changed + */ + public function passwordTimestamp(): int|null + { + $file = $this->secretsFile(); + + // ensure we have the latest information + // to prevent cache attacks + clearstatcache(); + + // user does not have a password + if (is_file($file) === false) { + return null; + } + + return filemtime($file); + } + + public function permissions(): UserPermissions + { + return new UserPermissions($this); + } + + /** + * Returns the user role + */ + public function role(): Role + { + if ($this->role instanceof Role) { + return $this->role; + } + + $name = $this->role ?? $this->credentials()['role'] ?? 'default'; + + return $this->role = + $this->kirby()->roles()->find($name) ?? + Role::defaultNobody(); + } + + /** + * Returns all available roles for this user, + * that the authenticated user can change to. + * + * For all roles the current user can create + * use `$kirby->roles()->canBeCreated()`. + */ + public function roles(): Roles + { + $kirby = $this->kirby(); + $roles = $kirby->roles(); + + // if the authenticated user doesn't have the permission to change + // the role of this user, only the current role is available + if ($this->permissions()->can('changeRole') === false) { + return $roles->filter('id', $this->role()->id()); + } + + return $roles->canBeCreated(); + } + + /** + * The absolute path to the user directory + */ + public function root(): string + { + return $this->kirby()->root('accounts') . '/' . $this->id(); + } + + /** + * Returns the UserRules class to + * validate any important action. + */ + protected function rules(): UserRules + { + return new UserRules(); + } + + /** + * Reads a specific secret from the user secrets file on disk + * @since 4.0.0 + */ + public function secret(string $key): mixed + { + return $this->readSecrets()[$key] ?? null; + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array|null $blueprint = null): static + { + if ($blueprint !== null) { + $this->blueprint = new UserBlueprint([ + ...$blueprint, + 'model' => $this + ]); + } + + return $this; + } + + /** + * Converts session options into a session object + * + * @param \Kirby\Session\Session|array $session Session options or session object to unset the user in + */ + protected function sessionFromOptions(Session|array|null $session): Session + { + // use passed session options or session object if set + $session ??= ['detect' => true]; + + if ($session instanceof Session === false) { + $session = $this->kirby()->session($session); + } + + return $session; + } + + /** + * Returns the parent Users collection + */ + protected function siblingsCollection(): Users + { + return $this->kirby()->users()->sortBy('username', 'asc'); + } + + /** + * Converts the most important user properties + * to an array + */ + public function toArray(): array + { + return [ + ...parent::toArray(), + 'avatar' => $this->avatar()?->toArray(), + 'email' => $this->email(), + 'id' => $this->id(), + 'language' => $this->language(), + 'role' => $this->role()->name(), + 'username' => $this->username() + ]; + } + + /** + * String template builder + * + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced + * (`null` to keep the original token) + */ + public function toString( + string|null $template = null, + array $data = [], + string|null $fallback = '', + string $handler = 'template' + ): string { + return parent::toString( + $template ?? $this->email(), + $data, + $fallback, + $handler + ); + } + + /** + * Returns the username + * which is the given name or the email + * as a fallback + */ + public function username(): string|null + { + return $this->nameOrEmail()->value(); + } + + /** + * Compares the given password with the stored one + * + * @throws \Kirby\Exception\NotFoundException If the user has no password + * @throws \Kirby\Exception\InvalidArgumentException If the entered password is not valid + * or does not match the user password + */ + public function validatePassword( + #[SensitiveParameter] + string|null $password = null + ): bool { + if (empty($this->password()) === true) { + 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' + ); + } + + // too long passwords can cause DoS attacks + if (Str::length($password) > 1000) { + throw new InvalidArgumentException( + key: 'user.password.excessive' + ); + } + + if (password_verify($password, $this->password()) !== true) { + throw new InvalidArgumentException( + key: 'user.password.wrong', + httpCode: 401 + ); + } + + return true; + } + + /** + * Returns the path to the file containing + * all user secrets, including the password + * @since 4.0.0 + */ + protected function secretsFile(): string + { + return $this->root() . '/.htpasswd'; + } +} diff --git a/public/kirby/src/Cms/UserActions.php b/public/kirby/src/Cms/UserActions.php new file mode 100644 index 0000000..296f6ff --- /dev/null +++ b/public/kirby/src/Cms/UserActions.php @@ -0,0 +1,435 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait UserActions +{ + /** + * Changes the user email address + */ + public function changeEmail(string $email): static + { + $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]); + + return $user; + }); + } + + /** + * Changes the user language + */ + 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]); + + return $user; + }); + } + + /** + * Changes the screen name of the user + */ + public function changeName(string $name): static + { + $name = trim($name); + + return $this->commit('changeName', ['user' => $this, 'name' => $name], function ($user, $name) { + $user = $user->clone(['name' => $name]); + $user->updateCredentials(['name' => $name]); + + return $user; + }); + } + + /** + * Changes the user password + * + * If this method is used with user input, it is recommended to also + * confirm the current password by the user via `::validatePassword()` + */ + public function changePassword( + #[SensitiveParameter] + string $password + ): static { + return $this->commit('changePassword', ['user' => $this, 'password' => $password], function ($user, $password) { + $user = $user->clone([ + 'password' => $password = static::hashPassword($password) + ]); + + $user->writePassword($password); + + // keep the user logged in to the current browser + // if they changed their own password + // (regenerate the session token, update the login timestamp) + if ($user->isLoggedIn() === true) { + $user->loginPasswordless(); + } + + return $user; + }); + } + + /** + * Changes the user role + */ + 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]); + + return $user; + }); + } + + /** + * Changes the user's TOTP secret + * @since 4.0.0 + */ + public function changeTotp( + #[SensitiveParameter] + string|null $secret + ): static { + return $this->commit('changeTotp', ['user' => $this, 'secret' => $secret], function ($user, $secret) { + $this->writeSecret('totp', $secret); + + // keep the user logged in to the current browser + // if they changed their own TOTP secret + // (regenerate the session token, update the login timestamp) + if ($user->isLoggedIn() === true) { + $user->loginPasswordless(); + } + + return $user; + }); + } + + /** + * Commits a user action, by following these steps + * + * 1. applies the `before` hook + * 2. checks the action rules + * 3. commits the action + * 4. applies the `after` hook + * 5. returns the result + * + * @throws \Kirby\Exception\PermissionException + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + if ($this->isKirby() === true) { + throw new PermissionException( + message: 'The Kirby user cannot be changed' + ); + } + + $commit = new ModelCommit( + model: $this, + action: $action + ); + + return $commit->call($arguments, $callback); + } + + /** + * Creates a new User from the given props and returns a new User object + */ + public static function create(array $props): User + { + $input = $props; + $props = self::normalizeProps($props); + + // 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->setContent($props['content']); + + // inject the translations + $user->setTranslations($props['translations'] ?? null); + + // run the hook + return $user->commit('create', ['user' => $user, 'input' => $input], function ($user) use ($storage) { + $user->writeCredentials([ + 'email' => $user->email(), + 'language' => $user->language(), + 'name' => $user->name()->value(), + 'role' => $user->role()->id(), + ]); + + $user->writePassword($user->password()); + $user->changeStorage($storage); + + // write the user data + return $user; + }); + } + + /** + * Returns a random user id + */ + public function createId(): string + { + $length = 8; + + do { + try { + $id = Str::random($length); + UserRules::validId($this, $id); + return $id; + + // we can't really test for a random match + // @codeCoverageIgnoreStart + } catch (Throwable) { + $length++; + } + } while (true); + // @codeCoverageIgnoreEnd + } + + /** + * Deletes the user + * + * @throws \Kirby\Exception\LogicException + */ + public function delete(): bool + { + return $this->commit('delete', ['user' => $this], function ($user) { + $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 versions, + // the plain text storage handler will then clean + // up the directory if it's empty + $old->versions()->delete(); + + // 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 + */ + protected function readCredentials(): array + { + $path = $this->root() . '/index.php'; + + if (is_file($path) === true) { + $credentials = F::load($path, allowOutput: false); + + return is_array($credentials) === false ? [] : $credentials; + } + + return []; + } + + /** + * Reads the user password from disk + */ + protected function readPassword(): string|false + { + return $this->secret('password') ?? false; + } + + /** + * Reads the secrets from the user secrets file on disk + * @since 4.0.0 + */ + protected function readSecrets(): array + { + $file = $this->secretsFile(); + $secrets = []; + + if (is_file($file) === true) { + $lines = explode("\n", file_get_contents($file)); + + if (isset($lines[1]) === true) { + $secrets = Json::decode($lines[1]); + } + + $secrets['password'] = $lines[0]; + } + + // an empty password hash means that no password was set + if (($secrets['password'] ?? null) === '') { + unset($secrets['password']); + } + + return $secrets; + } + + /** + * Updates the user data + */ + public function update( + array|null $input = null, + string|null $languageCode = null, + bool $validate = false + ): static { + $user = parent::update($input, $languageCode, $validate); + + // set auth user data only if the current user is this user + if ($user->isLoggedIn() === true) { + $this->kirby()->auth()->setUser($user); + + ModelState::update( + method: 'set', + current: $user, + ); + } + + return $user; + } + + /** + * This always merges the existing credentials + * with the given input. + */ + protected function updateCredentials(array $credentials): bool + { + // normalize the email address + if (isset($credentials['email']) === true) { + $credentials['email'] = Str::lower(trim($credentials['email'])); + } + + return $this->writeCredentials([ + ...$this->credentials(), + ...$credentials + ]); + } + + /** + * Writes the account information to disk + */ + protected function writeCredentials(array $credentials): bool + { + return Data::write($this->root() . '/index.php', $credentials); + } + + /** + * Writes the password to disk + */ + protected function writePassword( + #[SensitiveParameter] + string|null $password = null + ): bool { + return $this->writeSecret('password', $password); + } + + /** + * Writes a specific secret to the user secrets file on disk; + * `password` is the first line, the rest is stored as JSON + * @since 4.0.0 + */ + protected function writeSecret( + string $key, + #[SensitiveParameter] + mixed $secret + ): bool { + $secrets = $this->readSecrets(); + + if ($secret === null) { + unset($secrets[$key]); + } else { + $secrets[$key] = $secret; + } + + // first line is always the password + $lines = $secrets['password'] ?? ''; + + // everything else is for the second line + $secondLine = Json::encode( + A::without($secrets, 'password') + ); + + if ($secondLine !== '[]') { + $lines .= "\n" . $secondLine; + } + + return F::write($this->secretsFile(), $lines); + } +} diff --git a/public/kirby/src/Cms/UserBlueprint.php b/public/kirby/src/Cms/UserBlueprint.php new file mode 100644 index 0000000..d44d852 --- /dev/null +++ b/public/kirby/src/Cms/UserBlueprint.php @@ -0,0 +1,46 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserBlueprint extends Blueprint +{ + /** + * UserBlueprint constructor. + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(array $props) + { + // normalize and translate the description + $props['description'] = $this->i18n($props['description'] ?? null); + + // register the other props + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'create' => null, + 'changeEmail' => null, + 'changeLanguage' => null, + 'changeName' => null, + 'changePassword' => null, + 'changeRole' => null, + 'delete' => null, + 'update' => null, + ] + ); + } +} diff --git a/public/kirby/src/Cms/UserPermissions.php b/public/kirby/src/Cms/UserPermissions.php new file mode 100644 index 0000000..4103fca --- /dev/null +++ b/public/kirby/src/Cms/UserPermissions.php @@ -0,0 +1,68 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserPermissions extends ModelPermissions +{ + /** + * Used to cache once determined permissions in memory + */ + protected static function cacheKey(ModelWithContent|Language $model): string + { + return $model->role()->id(); + } + + protected function canChangeRole(): bool + { + // protect admin from role changes by non-admin + if ( + $this->model->isAdmin() === true && + static::user()->isAdmin() !== true + ) { + return false; + } + + // prevent demoting the last admin + if ($this->model->isLastAdmin() === true) { + return false; + } + + return true; + } + + protected function canCreate(): bool + { + // the admin can always create new users + if (static::user()->isAdmin() === true) { + return true; + } + + // users who are not admins cannot create admins + if ($this->model->isAdmin() === true) { + return false; + } + + return true; + } + + protected function canDelete(): bool + { + 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 new file mode 100644 index 0000000..c79b775 --- /dev/null +++ b/public/kirby/src/Cms/UserPicker.php @@ -0,0 +1,66 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserPicker extends Picker +{ + /** + * Extends the basic defaults + */ + public function defaults(): array + { + return [ + ...parent::defaults(), + 'text' => '{{ user.username }}' + ]; + } + + /** + * Search all users for the picker + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function items(): Users|null + { + $model = $this->options['model']; + + // find the right default query + $query = match (true) { + empty($this->options['query']) === false + => $this->options['query'], + $model instanceof User + => 'user.siblings', + default + => 'kirby.users' + }; + + // fetch all users for the picker + $users = $model->query($query); + + // catch invalid data + if ($users instanceof Users === false) { + throw new InvalidArgumentException( + message: 'Your query must return a set of users' + ); + } + + // 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 new file mode 100644 index 0000000..67cb4f1 --- /dev/null +++ b/public/kirby/src/Cms/UserRules.php @@ -0,0 +1,365 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserRules +{ + /** + * Validates if the email address can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the address + */ + public static function changeEmail(User $user, string $email): void + { + if ($user->permissions()->can('changeEmail') !== true) { + throw new PermissionException( + key: 'user.changeEmail.permission', + data: ['name' => $user->username()] + ); + } + + static::validEmail($user, $email); + } + + /** + * Validates if the language can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the language + */ + public static function changeLanguage(User $user, string $language): void + { + if ($user->permissions()->can('changeLanguage') !== true) { + throw new PermissionException( + key: 'user.changeLanguage.permission', + data: ['name' => $user->username()] + ); + } + + static::validLanguage($user, $language); + } + + /** + * Validates if the name can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the name + */ + public static function changeName(User $user, string $name): void + { + if ($user->permissions()->can('changeName') !== true) { + throw new PermissionException( + key: 'user.changeName.permission', + data: ['name' => $user->username()] + ); + } + } + + /** + * Validates if the password can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the password + */ + public static function changePassword( + User $user, + #[SensitiveParameter] + string $password + ): void { + if ($user->permissions()->can('changePassword') !== true) { + throw new PermissionException( + key: 'user.changePassword.permission', + data: ['name' => $user->username()] + ); + } + + static::validPassword($user, $password); + } + + /** + * Validates if the role can be changed + * + * @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): void + { + // prevent non-admins making a user to admin + if ( + $user->kirby()->user()->isAdmin() === false && + $role === 'admin' + ) { + 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()] + ); + } + + // check permissions + 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', + ); + } + } + + /** + * Validates if the TOTP can be changed + * @since 4.0.0 + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the password + */ + public static function changeTotp( + User $user, + #[SensitiveParameter] + string|null $secret + ): void { + $currentUser = $user->kirby()->user(); + + if ( + $currentUser->is($user) === false && + $currentUser->isAdmin() === false + ) { + throw new PermissionException( + message: 'You cannot change the time-based code for ' . $user->email() + ); + } + + // safety check to avoid accidental insecure secrets; + // throws an exception for secrets of the wrong length + if ($secret !== null) { + new Totp($secret); + } + } + + /** + * Validates if the user can be created + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to create a new user + */ + public static function create(User $user, array $props = []): void + { + static::validId($user, $user->id()); + static::validEmail($user, $user->email(), true); + static::validLanguage($user, $user->language()); + + // the first user must have a password + if ($user->kirby()->users()->count() === 0 && empty($props['password'])) { + // trigger invalid password error + static::validPassword($user, ' '); + } + + if (empty($props['password']) === false) { + static::validPassword($user, $props['password']); + } + + // get the current user if it exists + $currentUser = $user->kirby()->user(); + + // admins are allowed everything + if ($currentUser?->isAdmin() === true) { + return; + } + + // allow to create the first user + if ($user->kirby()->users()->count() === 0) { + return; + } + + // check user permissions + if ($user->permissions()->can('create') !== true) { + throw new PermissionException([ + 'key' => 'user.create.permission' + ]); + } + + $role = $props['role'] ?? null; + + // prevent creating a role that is not available for user + if ( + in_array($role, [null, 'default', 'nobody'], true) === false && + $user->kirby()->roles()->canBeCreated()->find($role) instanceof Role === false + ) { + throw new InvalidArgumentException([ + 'key' => 'user.role.invalid', + ]); + } + } + + /** + * Validates if the user can be deleted + * + * @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): void + { + if ($user->isLastAdmin() === true) { + throw new LogicException( + key: 'user.delete.lastAdmin' + ); + } + + if ($user->isLastUser() === true) { + throw new LogicException( + key: 'user.delete.lastUser' + ); + } + + if ($user->permissions()->can('delete') !== true) { + throw new PermissionException( + key: 'user.delete.permission', + data: ['name' => $user->username()] + ); + } + } + + /** + * Validates if the user can be updated + * + * @throws \Kirby\Exception\PermissionException If the user it not allowed to update this user + */ + public static function update( + User $user, + array $values = [], + array $strings = [] + ): void { + if ($user->permissions()->can('update') !== true) { + throw new PermissionException( + key: 'user.update.permission', + data: ['name' => $user->username()] + ); + } + } + + /** + * Validates an email address + * + * @throws \Kirby\Exception\DuplicateException If the email address already exists + * @throws \Kirby\Exception\InvalidArgumentException If the email address is invalid + */ + public static function validEmail( + User $user, + string $email, + bool $strict = false + ): void { + if (V::email($email ?? null) === false) { + throw new InvalidArgumentException( + key: 'user.email.invalid' + ); + } + + $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] + ); + } + } + + /** + * Validates a user id + * + * @throws \Kirby\Exception\DuplicateException If the user already exists + */ + public static function validId(User $user, string $id): void + { + 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( + message: 'A user with this id exists' + ); + } + } + + /** + * Validates a user language code + * + * @throws \Kirby\Exception\InvalidArgumentException If the language does not exist + */ + 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'); + } + } + + /** + * Validates a password + * + * @throws \Kirby\Exception\InvalidArgumentException If the password is too short + */ + public static function validPassword( + User $user, + #[SensitiveParameter] + string $password + ): void { + // too short passwords are ineffective + if (Str::length($password ?? null) < 8) { + throw new InvalidArgumentException(key: 'user.password.invalid'); + } + + // too long passwords can cause DoS attacks + // and are therefore blocked in the auth system + // (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'); + } + } + + /** + * Validates a user role + * + * @throws \Kirby\Exception\InvalidArgumentException If the user role does not exist + * @deprecated 4.5.0 + */ + public static function validRole(User $user, string $role): void + { + if ($user->kirby()->roles()->find($role) instanceof Role === false) { + throw new InvalidArgumentException( + key: 'user.role.invalid', + ); + } + } +} diff --git a/public/kirby/src/Cms/Users.php b/public/kirby/src/Cms/Users.php new file mode 100644 index 0000000..2f65192 --- /dev/null +++ b/public/kirby/src/Cms/Users.php @@ -0,0 +1,165 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @template TUser of \Kirby\Cms\User + * @extends \Kirby\Cms\Collection + */ +class Users extends Collection +{ + use HasUuids; + + /** + * All registered users methods + */ + public static array $methods = []; + + public function create(array $data): User + { + return User::create($data); + } + + /** + * Adds a single user or + * an entire second collection to the + * current collection + * + * @param \Kirby\Cms\Users|TUser|string $object + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException When no `User` or `Users` object or an ID of an existing user is passed + */ + public function add($object): static + { + // add a users collection + if ($object instanceof self) { + $this->data = [...$this->data, ...$object->data]; + + // add a user by id + } elseif ( + is_string($object) === true && + $user = App::instance()->user($object) + ) { + $this->__set($user->id(), $user); + + // 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 + } elseif (in_array($object, [null, false, true], true) !== true) { + 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; + } + + /** + * Takes an array of user props and creates a nice + * and clean user collection from it + */ + public static function factory(array $users, array $inject = []): static + { + $collection = new static(); + + // read all user blueprints + foreach ($users as $props) { + $user = User::factory($props + $inject); + $collection->set($user->id(), $user); + } + + return $collection; + } + + /** + * Returns all files of all users + */ + public function files(): Files + { + $files = new Files([], $this->parent); + + foreach ($this->data as $user) { + foreach ($user->files() as $fileKey => $file) { + $files->data[$fileKey] = $file; + } + } + + return $files; + } + + /** + * Finds a user in the collection by ID or email address + * @internal Use `$users->find()` instead + * @return TUser|null + */ + public function findByKey(string $key): User|null + { + if ($user = $this->findByUuid($key, 'user')) { + return $user; + } + + if (Str::contains($key, '@') === true) { + return parent::findBy('email', Str::lower($key)); + } + + return parent::findByKey($key); + } + + /** + * Loads a user from disk by passing the absolute path (root) + */ + public static function load(string $root, array $inject = []): static + { + $users = new static(); + + foreach (Dir::read($root) as $userDirectory) { + if (is_dir($root . '/' . $userDirectory) === false) { + continue; + } + + // get role information + $path = $root . '/' . $userDirectory . '/index.php'; + if (is_file($path) === true) { + $credentials = F::load($path, allowOutput: false); + } + + // create user model based on role + $user = User::factory([ + 'id' => $userDirectory, + 'model' => $credentials['role'] ?? null + ] + $inject); + + $users->set($user->id(), $user); + } + + return $users; + } + + /** + * Shortcut for `$users->filter('role', 'admin')` + */ + public function role(string $role): static + { + return $this->filter('role', $role); + } +} diff --git a/public/kirby/src/Cms/Visitor.php b/public/kirby/src/Cms/Visitor.php new file mode 100644 index 0000000..511eb8e --- /dev/null +++ b/public/kirby/src/Cms/Visitor.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Visitor extends Facade +{ + public static function instance(): BaseVisitor + { + return App::instance()->visitor(); + } +} 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 new file mode 100644 index 0000000..0f58525 --- /dev/null +++ b/public/kirby/src/Content/Content.php @@ -0,0 +1,253 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Content +{ + /** + * The raw data array + */ + protected array $data = []; + + /** + * Cached field objects + * Once a field is being fetched + * it is added to this array for + * later reuse + */ + protected array $fields = []; + + /** + * A potential parent object. + * Not necessarily needed. Especially + * for testing, but field methods might + * need it. + */ + protected ModelWithContent|null $parent; + + /** + * Magic getter for content fields + */ + public function __call(string $name, array $arguments = []): Field + { + return $this->get($name); + } + + /** + * Creates a new Content object + * + * @param bool $normalize Set to `false` if the input field keys are already lowercase + */ + public function __construct( + array $data = [], + ModelWithContent|null $parent = null, + bool $normalize = true + ) { + if ($normalize === true) { + $data = array_change_key_case($data, CASE_LOWER); + } + + $this->data = $data; + $this->parent = $parent; + } + + /** + * Same as `self::data()` to improve + * `var_dump` output + * @codeCoverageIgnore + * + * @see self::data() + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Converts the content to a new blueprint + */ + public function convertTo(string $to): array + { + // prepare data + $data = []; + $content = $this; + + // blueprints + $old = $this->parent->blueprint(); + $subfolder = dirname($old->name()); + $new = Blueprint::factory( + $subfolder . '/' . $to, + $subfolder . '/default', + $this->parent + ); + + // forms + $oldForm = new Form( + fields: $old->fields(), + model: $this->parent + ); + + $newForm = new Form( + fields: $new->fields(), + model: $this->parent + ); + + // fields + $oldFields = $oldForm->fields(); + $newFields = $newForm->fields(); + + // go through all fields of new template + foreach ($newFields as $newField) { + $name = $newField->name(); + $oldField = $oldFields->get($name); + + // field name and type matches with old template + if ($oldField?->type() === $newField->type()) { + $data[$name] = $content->get($name)->value(); + } else { + $data[$name] = $newField->default(); + } + } + + // 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 [...$this->data, ...$data]; + } + + /** + * Returns the raw data array + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns all registered field objects + */ + public function fields(): array + { + foreach ($this->data as $key => $value) { + $this->get($key); + } + return $this->fields; + } + + /** + * Returns either a single field object + * or all registered fields + */ + public function get(string|null $key = null): Field|array + { + if ($key === null) { + return $this->fields(); + } + + $key = strtolower($key); + + return $this->fields[$key] ??= new Field( + $this->parent, + $key, + $this->data()[$key] ?? null + ); + } + + /** + * Checks if a content field is set + */ + public function has(string $key): bool + { + return isset($this->data[strtolower($key)]) === true; + } + + /** + * Returns all field keys + */ + public function keys(): array + { + return array_keys($this->data()); + } + + /** + * Returns a clone of the content object + * without the fields, specified by the + * passed key(s) + */ + public function not(string ...$keys): static + { + $copy = clone $this; + $copy->fields = []; + + foreach ($keys as $key) { + unset($copy->data[strtolower($key)]); + } + + return $copy; + } + + /** + * Returns the parent + * Site, Page, File or User object + */ + public function parent(): ModelWithContent|null + { + return $this->parent; + } + + /** + * Set the parent model + * + * @return $this + */ + public function setParent(ModelWithContent $parent): static + { + $this->parent = $parent; + return $this; + } + + /** + * Returns the raw data array + * + * @see self::data() + */ + public function toArray(): array + { + return $this->data(); + } + + /** + * Updates the content in memory. + */ + public function update( + array|null $content = null, + bool $overwrite = false + ): static { + $content = array_change_key_case((array)$content, CASE_LOWER); + $this->data = $overwrite === true ? $content : array_merge($this->data, $content); + + // clear cache of Field objects + $this->fields = []; + + return $this; + } +} diff --git a/public/kirby/src/Content/Field.php b/public/kirby/src/Content/Field.php new file mode 100644 index 0000000..06f0f5d --- /dev/null +++ b/public/kirby/src/Content/Field.php @@ -0,0 +1,212 @@ +myField()->lower(); + * ``` + * + * @package Kirby Content + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Field implements Stringable +{ + /** + * Field method aliases + */ + public static array $aliases = []; + + /** + * Registered field methods + */ + public static array $methods = []; + + /** + * 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( + protected ModelWithContent|null $parent, + protected string $key, + public mixed $value + ) { + } + + /** + * Magic caller for field methods + */ + public function __call(string $method, array $arguments = []): mixed + { + $method = strtolower($method); + + if (isset(static::$methods[$method]) === true) { + return (static::$methods[$method])(clone $this, ...$arguments); + } + + if (isset(static::$aliases[$method]) === true) { + $method = strtolower(static::$aliases[$method]); + + if (isset(static::$methods[$method]) === true) { + return (static::$methods[$method])(clone $this, ...$arguments); + } + } + + return $this; + } + + /** + * Simplifies the var_dump result + * @codeCoverageIgnore + * + * @see self::toArray() + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Makes it possible to simply echo + * or stringify the entire object + * + * @see self::toString() + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Checks if the field exists in the content data array + */ + public function exists(): bool + { + return $this->parent->content()->has($this->key); + } + + /** + * Checks if the field content is empty + */ + public function isEmpty(): bool + { + $value = $this->value; + + if (is_string($value) === true) { + $value = trim($value); + } + + return + $value === null || + $value === '' || + $value === [] || + $value === '[]'; + } + + /** + * Checks if the field content is not empty + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Returns the name of the field + */ + public function key(): string + { + return $this->key; + } + + /** + * @see self::parent() + */ + public function model(): ModelWithContent|null + { + return $this->parent; + } + + /** + * Provides a fallback if the field value is empty + * + * @return $this|static + */ + public function or(mixed $fallback = null): static + { + if ($this->isNotEmpty() === true) { + return $this; + } + + if ($fallback instanceof self) { + return $fallback; + } + + $field = clone $this; + $field->value = $fallback; + return $field; + } + + /** + * Returns the parent object of the field + */ + public function parent(): ModelWithContent|null + { + return $this->parent; + } + + /** + * Converts the Field object to an array + */ + public function toArray(): array + { + return [$this->key => $this->value]; + } + + /** + * Returns the field value as string + */ + public function toString(): string + { + return (string)$this->value; + } + + /** + * Returns the field content. If a new value is passed, + * the modified field will be returned. Otherwise it + * will return the field value. + */ + public function value(string|Closure|null $value = null): mixed + { + if ($value === null) { + return $this->value; + } + + if ($value instanceof Closure) { + $value = $value->call($this, $this->value); + } + + $clone = clone $this; + $clone->value = (string)$value; + + return $clone; + } +} 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/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 new file mode 100644 index 0000000..5a01c90 --- /dev/null +++ b/public/kirby/src/Data/Data.php @@ -0,0 +1,145 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Data +{ + /** + * Handler Type Aliases + */ + public static array $aliases = [ + 'md' => 'txt', + 'mdown' => 'txt', + 'rss' => 'xml', + 'yml' => 'yaml', + ]; + + /** + * All registered handlers + */ + public static array $handlers = [ + 'json' => Json::class, + 'php' => PHP::class, + 'txt' => Txt::class, + 'xml' => Xml::class, + 'yaml' => Yaml::class + ]; + + /** + * Handler getter + */ + public static function handler(string $type): Handler + { + // normalize the type + $type = strtolower($type); + + // find a handler or alias + $handler = static::$handlers[$type] ?? null; + + if ($alias = static::$aliases[$type] ?? null) { + $handler ??= static::$handlers[$alias] ?? null; + } + + if ($handler === null || class_exists($handler) === false) { + throw new Exception( + message: 'Missing handler for type: "' . $type . '"' + ); + } + + $handler = new $handler(); + + if ($handler instanceof Handler === false) { + throw new Exception( + message: 'Handler for type: "' . $type . '" needs to extend ' . Handler::class + ); + } + + return $handler; + } + + /** + * Decodes data with the specified handler + */ + 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; + } + } + + /** + * Encodes data with the specified handler + */ + public static function encode($data, string $type): string + { + return static::handler($type)->encode($data); + } + + /** + * Reads data from a file; + * the data handler is automatically chosen by + * the extension if not specified + */ + 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; + } + } + + /** + * Writes data to a file; + * the data handler is automatically chosen by + * the extension if not specified + */ + public static function write( + string $file, + $data = [], + string|null $type = null + ): bool { + $type ??= F::extension($file); + $handler = static::handler($type); + return $handler->write($file, $data); + } +} diff --git a/public/kirby/src/Data/Handler.php b/public/kirby/src/Data/Handler.php new file mode 100644 index 0000000..776060b --- /dev/null +++ b/public/kirby/src/Data/Handler.php @@ -0,0 +1,56 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Handler +{ + /** + * Parses an encoded string and returns a multi-dimensional array + * + * @throws \Exception if the file can't be parsed + */ + abstract public static function decode($string): array; + + /** + * Converts an array to an encoded string + */ + abstract public static function encode($data): string; + + /** + * Reads data from a file + */ + public static function read(string $file): array + { + $contents = F::read($file); + + if ($contents === false) { + throw new Exception( + message: 'The file "' . $file . '" does not exist or cannot be read' + ); + } + + return static::decode($contents); + } + + /** + * Writes data to a file + */ + public static function write(string $file, $data = []): bool + { + return F::write($file, static::encode($data)); + } +} diff --git a/public/kirby/src/Data/Json.php b/public/kirby/src/Data/Json.php new file mode 100644 index 0000000..124435f --- /dev/null +++ b/public/kirby/src/Data/Json.php @@ -0,0 +1,61 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Json extends Handler +{ + /** + * Converts an array to an encoded JSON string + */ + public static function encode($data, bool $pretty = false): string + { + $constants = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + + if ($pretty === true) { + $constants |= JSON_PRETTY_PRINT; + } + + return json_encode($data, $constants); + } + + /** + * Parses an encoded JSON string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException( + message: 'Invalid JSON data; please pass a string' + ); + } + + $result = json_decode($string, true); + + if (is_array($result) === true) { + return $result; + } + + throw new InvalidArgumentException( + message: 'JSON string is invalid' + ); + } +} diff --git a/public/kirby/src/Data/PHP.php b/public/kirby/src/Data/PHP.php new file mode 100644 index 0000000..6583bb4 --- /dev/null +++ b/public/kirby/src/Data/PHP.php @@ -0,0 +1,98 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class PHP extends Handler +{ + /** + * Converts data to PHP file content + * + * @param string $indent For internal use only + */ + public static function encode($data, string $indent = ''): string + { + return match (gettype($data)) { + 'array' => static::encodeArray($data, $indent), + 'boolean' => $data ? 'true' : 'false', + 'integer', + 'double' => (string)$data, + default => var_export($data, true) + }; + } + + /** + * Converts an array to PHP file content + */ + protected static function encodeArray(array $data, string $indent): string + { + $indexed = array_is_list($data); + $lines = []; + + 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 . ']'; + } + + /** + * PHP strings shouldn't be decoded manually + */ + public static function decode($string): array + { + throw new BadMethodCallException( + message: 'The PHP::decode() method is not implemented' + ); + } + + /** + * Reads data from a file + */ + public static function read(string $file): array + { + if (is_file($file) !== true) { + throw new Exception( + message: 'The file "' . $file . '" does not exist' + ); + } + + return (array)F::load($file, [], allowOutput: false); + } + + /** + * Creates a PHP file with the given data + */ + public static function write(string $file, $data = []): bool + { + $php = static::encode($data); + $php = " + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Txt extends Handler +{ + /** + * Converts an array to an encoded Kirby txt string + */ + public static function encode($data): string + { + $result = []; + + foreach (A::wrap($data) as $key => $value) { + if (empty($key) === true || $value === null) { + continue; + } + + $key = Str::ucfirst(Str::slug($key)); + $value = static::encodeValue($value); + $result[$key] = static::encodeResult($key, $value); + } + + return implode("\n\n----\n\n", $result); + } + + /** + * Helper for converting the value + */ + protected static function encodeValue(array|string|float $value): string + { + // 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); + + return $value; + } + + /** + * Helper for converting the key and value to the result string + */ + protected static function encodeResult(string $key, string $value): string + { + $value = trim($value); + $result = $key . ':'; + + $result .= match (preg_match('!\R!', $value)) { + // multi-line content + 1 => "\n\n", + // single line content, just add space after colon + default => ' ', + }; + + $result .= $value; + + return $result; + } + + /** + * Parses a Kirby txt string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException( + message: 'Invalid TXT data; please pass a string' + ); + } + + // remove Unicode BOM at the beginning of the file + if (Str::startsWith($string, "\xEF\xBB\xBF") === true) { + $string = substr($string, 3); + } + + // explode all fields by the line separator + $fields = preg_split('!\n----\s*\n*!', $string); + + // start the data array + $data = []; + + // loop through all fields and add them to the content + foreach ($fields as $field) { + if ($pos = strpos($field, ':')) { + $key = strtolower(trim(substr($field, 0, $pos))); + $key = str_replace(['-', ' '], '_', $key); + + // Don't add fields with empty keys + if (empty($key) === true) { + continue; + } + + $value = trim(substr($field, $pos + 1)); + + // unescape escaped dividers within a field + $data[$key] = preg_replace( + '!(?<=\n|^)\\\\----!', + '----', + $value + ); + } + } + + return $data; + } +} diff --git a/public/kirby/src/Data/Xml.php b/public/kirby/src/Data/Xml.php new file mode 100644 index 0000000..63d6054 --- /dev/null +++ b/public/kirby/src/Data/Xml.php @@ -0,0 +1,60 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Xml extends Handler +{ + /** + * Converts an array to an encoded XML string + */ + public static function encode($data): string + { + return XmlConverter::create($data, 'data'); + } + + /** + * Parses an encoded XML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException( + message: 'Invalid XML data; please pass a string' + ); + } + + $result = XmlConverter::parse($string); + + if (is_array($result) === true) { + // remove the root's name if it is the default to ensure that + // the decoded data is the same as the input to the encode() method + if ($result['@name'] === 'data') { + unset($result['@name']); + } + + return $result; + } + + throw new InvalidArgumentException(message: 'XML string is invalid'); + } +} diff --git a/public/kirby/src/Data/Yaml.php b/public/kirby/src/Data/Yaml.php new file mode 100644 index 0000000..0c3451d --- /dev/null +++ b/public/kirby/src/Data/Yaml.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Yaml extends Handler +{ + /** + * Converts an array to an encoded YAML string + */ + public static function encode($data): string + { + return match (static::handler()) { + 'symfony' => YamlSymfony::encode($data), + default => YamlSpyc::encode($data), + }; + } + + /** + * Parses an encoded YAML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException( + message: 'Invalid YAML data; please pass a string' + ); + } + + return match (static::handler()) { + 'symfony' => YamlSymfony::decode($string), + default => YamlSpyc::decode($string) + }; + } + + /** + * Returns which YAML parser (`spyc` or `symfony`) + * is configured to be used + */ + public static function handler(): string + { + return App::instance(null, true)?->option('yaml.handler') ?? 'spyc'; + } +} diff --git a/public/kirby/src/Data/YamlSpyc.php b/public/kirby/src/Data/YamlSpyc.php new file mode 100644 index 0000000..cd51e1b --- /dev/null +++ b/public/kirby/src/Data/YamlSpyc.php @@ -0,0 +1,43 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class YamlSpyc +{ + /** + * Converts an array to an encoded YAML string + */ + public static function encode($data): string + { + // $data, $indent, $wordwrap, $no_opening_dashes + return Spyc::YAMLDump($data, false, false, true); + } + + /** + * Parses an encoded YAML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + $result = Spyc::YAMLLoadString($string); + + if (is_array($result) === true) { + return $result; + } + + // apparently Spyc always returns an array, even for invalid YAML syntax + // so this Exception should currently never be thrown + throw new InvalidArgumentException(message: 'The YAML data cannot be parsed'); // @codeCoverageIgnore + } +} diff --git a/public/kirby/src/Data/YamlSymfony.php b/public/kirby/src/Data/YamlSymfony.php new file mode 100644 index 0000000..013a0d0 --- /dev/null +++ b/public/kirby/src/Data/YamlSymfony.php @@ -0,0 +1,44 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class YamlSymfony +{ + /** + * Converts an array to an encoded YAML string + */ + public static function encode($data): string + { + $kirby = App::instance(null, true); + + return Symfony::dump( + $data, + $kirby?->option('yaml.params.inline') ?? 9999, + $kirby?->option('yaml.params.indent') ?? 2, + Symfony::DUMP_MULTI_LINE_LITERAL_BLOCK | Symfony::DUMP_EMPTY_ARRAY_AS_SEQUENCE + ); + } + + /** + * Parses an encoded YAML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + $result = Symfony::parse($string); + $result = A::wrap($result); + return $result; + } +} diff --git a/public/kirby/src/Database/Database.php b/public/kirby/src/Database/Database.php new file mode 100644 index 0000000..eb2f4cc --- /dev/null +++ b/public/kirby/src/Database/Database.php @@ -0,0 +1,613 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Database +{ + /** + * The number of affected rows for the last query + */ + protected int|null $affected = null; + + /** + * Whitelist for column names + */ + protected array $columnWhitelist = []; + + /** + * The established connection + */ + protected PDO|null $connection = null; + + /** + * A global array of started connections + */ + public static array $connections = []; + + /** + * Database name + */ + protected string $database; + + protected string $dsn; + + /** + * Set to true to throw exceptions on failed queries + */ + protected bool $fail = false; + + /** + * The connection id + */ + protected string $id; + + /** + * The last error + */ + protected Throwable|null $lastError = null; + + /** + * The last insert id + */ + protected int|null $lastId = null; + + /** + * The last query + */ + protected string $lastQuery; + + /** + * The last result set + */ + protected mixed $lastResult; + + /** + * Optional prefix for table names + */ + protected string|null $prefix = null; + + /** + * The PDO query statement + */ + protected PDOStatement|null $statement = null; + + /** + * List of existing tables in the database + */ + protected array|null $tables = null; + + /** + * An array with all queries which are being made + */ + protected array $trace = []; + + /** + * The database type (mysql, sqlite) + */ + protected string $type; + + public static array $types = []; + + /** + * Creates a new Database instance + */ + public function __construct(array $params = []) + { + $this->connect($params); + } + + /** + * Returns one of the started instances + */ + public static function instance(string|null $id = null): static|null + { + if ($id === null) { + return A::last(static::$connections); + } + + return static::$connections[$id] ?? null; + } + + /** + * Returns all started instances + */ + public static function instances(): array + { + return static::$connections; + } + + /** + * Connects to a database + * + * @param array|null $params This can either be a config key or an array of parameters for the connection + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function connect(array|null $params = null): PDO|null + { + $options = [ + 'database' => null, + 'type' => 'mysql', + 'prefix' => null, + 'user' => null, + 'password' => null, + 'id' => uniqid(), + ...$params + ]; + + // store the database information + $this->database = $options['database']; + $this->type = $options['type']; + $this->prefix = $options['prefix']; + $this->id = $options['id']; + + if (isset(static::$types[$this->type]) === false) { + throw new InvalidArgumentException( + message: 'Invalid database type: ' . $this->type + ); + } + + // fetch the dsn and store it + $this->dsn = (static::$types[$this->type]['dsn'])($options); + + // try to connect + $this->connection = new PDO($this->dsn, $options['user'], $options['password']); + $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + + // TODO: behavior without this attribute would be preferrable + // (actual types instead of all strings) but would be a breaking change + if ($this->type === 'sqlite') { + $this->connection->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); + } + + // store the connection + static::$connections[$this->id] = $this; + + // return the connection + return $this->connection; + } + + /** + * Returns the currently active connection + */ + public function connection(): PDO|null + { + return $this->connection; + } + + /** + * Sets the exception mode + * + * @return $this + */ + public function fail(bool $fail = true): static + { + $this->fail = $fail; + return $this; + } + + /** + * Returns the used database type + */ + public function type(): string + { + return $this->type; + } + + /** + * Returns the used table name prefix + */ + public function prefix(): string|null + { + return $this->prefix; + } + + /** + * Escapes a value to be used for a safe query + * NOTE: Prepared statements using bound parameters are more secure and solid + */ + public function escape(string $value): string + { + return substr($this->connection()->quote($value), 1, -1); + } + + /** + * Adds a value to the db trace and also + * returns the entire trace if nothing is specified + */ + public function trace(array|null $data = null): array + { + // return the full trace + if ($data === null) { + return $this->trace; + } + + // add a new entry to the trace + $this->trace[] = $data; + + return $this->trace; + } + + /** + * Returns the number of affected rows for the last query + */ + public function affected(): int|null + { + return $this->affected; + } + + /** + * Returns the last id if available + */ + public function lastId(): int|null + { + return $this->lastId; + } + + /** + * Returns the last query + */ + public function lastQuery(): string|null + { + return $this->lastQuery; + } + + /** + * Returns the last set of results + */ + public function lastResult() + { + return $this->lastResult; + } + + /** + * Returns the last db error + */ + public function lastError(): Throwable|null + { + return $this->lastError; + } + + /** + * Returns the name of the database + */ + public function name(): string|null + { + return $this->database; + } + + /** + * Private method to execute database queries. + * This is used by the query() and execute() methods + */ + protected function hit(string $query, array $bindings = []): bool + { + // 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 + if (is_int($parameter)) { + $parameter++; + } + + $type = match (gettype($value)) { + 'integer' => PDO::PARAM_INT, + 'boolean' => PDO::PARAM_BOOL, + 'NULL' => PDO::PARAM_NULL, + default => PDO::PARAM_STR + }; + + $this->statement->bindValue($parameter, $value, $type); + } + $this->statement->execute(); + + $this->affected = $this->statement->rowCount(); + $this->lastId = Str::startsWith($query, 'insert ', true) ? $this->connection->lastInsertId() : null; + $this->lastError = null; + + // store the final sql to add it to the trace later + $this->lastQuery = $this->statement->queryString; + } catch (Throwable $e) { + // store the error + $this->affected = 0; + $this->lastError = $e; + $this->lastId = null; + $this->lastQuery = $query; + + // only throw the extension if failing is allowed + if ($this->fail === true) { + throw $e; + } + } + + // add a new entry to the singleton trace array + $this->trace([ + 'query' => $this->lastQuery, + 'bindings' => $bindings, + 'error' => $this->lastError + ]); + + // return true or false on success or failure + return $this->lastError === null; + } + + /** + * Executes a sql query, which is expected to return a set of results + */ + public function query( + string $query, + array $bindings = [], + array $params = [] + ) { + $options = [ + 'flag' => null, + 'method' => 'fetchAll', + 'fetch' => Obj::class, + 'iterator' => Collection::class, + ...$params + ]; + + if ($this->hit($query, $bindings) === false) { + return false; + } + + // define the default flag for the fetch method + if ( + $options['fetch'] instanceof Closure || + $options['fetch'] === 'array' + ) { + $flags = PDO::FETCH_ASSOC; + } else { + $flags = PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE; + } + + // add optional flags + if (empty($options['flag']) === false) { + $flags |= $options['flag']; + } + + // set the fetch mode + if ( + $options['fetch'] instanceof Closure || + $options['fetch'] === 'array' + ) { + $this->statement->setFetchMode($flags); + } else { + $this->statement->setFetchMode($flags, $options['fetch']); + } + + // fetch that stuff + $results = $this->statement->{$options['method']}(); + + // apply the fetch closure to all results if given + if ($options['fetch'] instanceof Closure) { + if ($options['method'] === 'fetchAll') { + // fetching multiple records + foreach ($results as $key => $result) { + $results[$key] = $options['fetch']($result, $key); + } + } elseif ($options['method'] === 'fetch' && $results !== false) { + // fetching a single record + $results = $options['fetch']($results, null); + } + } + + if ($options['iterator'] === 'array') { + return $this->lastResult = $results; + } + + return $this->lastResult = new $options['iterator']($results); + } + + /** + * Executes a sql query, which is expected + * to not return a set of results + */ + public function execute(string $query, array $bindings = []): bool + { + return $this->lastResult = $this->hit($query, $bindings); + } + + /** + * Returns the correct Sql generator instance + * for the type of database + */ + public function sql(): Sql + { + $className = static::$types[$this->type]['sql'] ?? 'Sql'; + return new $className($this); + } + + /** + * Sets the current table, which should be queried. Returns a + * Query object, which can be used to build a full query + * for that table + */ + public function table(string $table): Query + { + return new Query($this, $this->prefix() . $table); + } + + /** + * Checks if a table exists in the current database + */ + public function validateTable(string $table): bool + { + if ($this->tables === null) { + // Get the list of tables from the database + $sql = $this->sql()->tables(); + $results = $this->query($sql['query'], $sql['bindings']); + + if ($results) { + $this->tables = $results->pluck('name'); + } else { + return false; + } + } + + return in_array($table, $this->tables, true) === true; + } + + /** + * Checks if a column exists in a specified table + */ + public function validateColumn(string $table, string $column): bool + { + if (isset($this->columnWhitelist[$table]) === false) { + if ($this->validateTable($table) === false) { + $this->columnWhitelist[$table] = []; + return false; + } + + // Get the column whitelist from the database + $sql = $this->sql()->columns($table); + $results = $this->query($sql['query'], $sql['bindings']); + + if ($results) { + $this->columnWhitelist[$table] = $results->pluck('name'); + } else { + return false; + } + } + + return in_array($column, $this->columnWhitelist[$table], true) === true; + } + + /** + * Creates a new table + */ + public function createTable(string $table, array $columns = []): bool + { + $sql = $this->sql()->createTable($table, $columns); + $queries = Str::split($sql['query'], ';'); + + foreach ($queries as $query) { + $query = trim($query); + + if ($this->execute($query, $sql['bindings']) === false) { + return false; + } + } + + // update cache + if (in_array($table, $this->tables ?? [], true) !== true) { + $this->tables[] = $table; + } + + return true; + } + + /** + * Drops a table + */ + public function dropTable(string $table): bool + { + $sql = $this->sql()->dropTable($table); + if ($this->execute($sql['query'], $sql['bindings']) !== true) { + return false; + } + + // update cache + $key = array_search($table, $this->tables ?? []); + if ($key !== false) { + unset($this->tables[$key]); + } + + return true; + } + + /** + * Magic way to start queries for tables by + * using a method named like the table. + * I.e. $db->users()->all() + */ + public function __call(string $method, mixed $arguments = null): Query + { + return $this->table($method); + } +} + +/** + * MySQL database connector + */ +Database::$types['mysql'] = [ + 'sql' => Mysql::class, + 'dsn' => function (array $params): string { + 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( + message: 'The mysql connection requires a "database" parameter' + ); + } + + $parts = []; + + if (empty($params['host']) === false) { + $parts[] = 'host=' . $params['host']; + } + + if (empty($params['port']) === false) { + $parts[] = 'port=' . $params['port']; + } + + if (empty($params['socket']) === false) { + $parts[] = 'unix_socket=' . $params['socket']; + } + + if (empty($params['database']) === false) { + $parts[] = 'dbname=' . $params['database']; + } + + $parts[] = 'charset=' . ($params['charset'] ?? 'utf8mb4'); + + return 'mysql:' . implode(';', $parts); + } +]; + +/** + * SQLite database connector + */ +Database::$types['sqlite'] = [ + 'sql' => Sqlite::class, + 'dsn' => function (array $params): string { + if (isset($params['database']) === false) { + 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 new file mode 100644 index 0000000..d7d5c40 --- /dev/null +++ b/public/kirby/src/Database/Db.php @@ -0,0 +1,296 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Db +{ + /** + * Query shortcuts + */ + public static array $queries = []; + + /** + * The singleton Database object + */ + public static Database|null $connection = null; + + /** + * (Re)connect the database + * + * @param array|null $params Pass `[]` to use the default params from the config, + * don't pass any argument to get the current connection + */ + public static function connect(array|null $params = null): Database + { + if ($params === null && static::$connection !== null) { + return static::$connection; + } + + // try to connect with the default + // connection settings if no params are set + $params ??= [ + 'type' => Config::get('db.type', 'mysql'), + 'host' => Config::get('db.host', 'localhost'), + 'user' => Config::get('db.user', 'root'), + 'password' => Config::get('db.password', ''), + 'database' => Config::get('db.database', ''), + 'prefix' => Config::get('db.prefix', ''), + 'port' => Config::get('db.port', ''), + 'charset' => Config::get('db.charset') + ]; + + return static::$connection = new Database($params); + } + + /** + * Returns the current database connection + */ + public static function connection(): Database|null + { + return static::$connection; + } + + /** + * Sets the current table which should be queried. Returns a + * Query object, which can be used to build a full query for + * that table. + */ + public static function table(string $table): Query + { + $db = static::connect(); + return $db->table($table); + } + + /** + * Executes a raw SQL query which expects a set of results + */ + public static function query(string $query, array $bindings = [], array $params = []) + { + $db = static::connect(); + return $db->query($query, $bindings, $params); + } + + /** + * Executes a raw SQL query which expects + * no set of results (i.e. update, insert, delete) + */ + public static function execute(string $query, array $bindings = []): bool + { + $db = static::connect(); + return $db->execute($query, $bindings); + } + + /** + * Magic calls for other static Db methods are + * redirected to either a predefined query or + * the respective method of the Database object + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function __callStatic(string $method, $arguments) + { + if (isset(static::$queries[$method])) { + return (static::$queries[$method])(...$arguments); + } + + if ( + static::$connection !== null && + method_exists(static::$connection, $method) === true + ) { + return call_user_func_array([static::$connection, $method], $arguments); + } + + throw new InvalidArgumentException( + message: 'Invalid static Db method: ' . $method + ); + } +} + +// @codeCoverageIgnoreStart + +/** + * Shortcut for SELECT clauses + * + * @param string $table The name of the table which should be queried + * @param mixed $columns Either a string with columns or an array of column names + * @param mixed $where The WHERE clause; can be a string or an array + */ +Db::$queries['select'] = function ( + string $table, + $columns = '*', + $where = null, + string|null $order = null, + int $offset = 0, + int|null $limit = null +) { + return Db::table($table) + ->select($columns) + ->where($where) + ->order($order) + ->offset($offset) + ->limit($limit) + ->all(); +}; + +/** + * Shortcut for selecting a single row in a table + * + * @param string $table The name of the table which should be queried + * @param mixed $columns Either a string with columns or an array of column names + * @param mixed $where The WHERE clause; can be a string or an array + */ +Db::$queries['first'] = Db::$queries['row'] = Db::$queries['one'] = function ( + string $table, + $columns = '*', + $where = null, + string|null $order = null +) { + return Db::table($table) + ->select($columns) + ->where($where) + ->order($order) + ->first(); +}; + +/** + * Returns only values from a single column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column to select from + * @param mixed $where The WHERE clause; can be a string or an array + */ +Db::$queries['column'] = function ( + string $table, + string $column, + $where = null, + string|null $order = null, + int $offset = 0, + int|null $limit = null +) { + return Db::table($table) + ->where($where) + ->order($order) + ->offset($offset) + ->limit($limit) + ->column($column); +}; + +/** + * Shortcut for inserting a new row into a table + * + * @param string $table The name of the table which should be queried + * @param array $values An array of values which should be inserted + * @return mixed Returns the last inserted id on success or false + */ +Db::$queries['insert'] = function (string $table, array $values): mixed { + return Db::table($table)->insert($values); +}; + +/** + * Shortcut for updating a row in a table + * + * @param string $table The name of the table which should be queried + * @param array $values An array of values which should be inserted + * @param mixed $where An optional WHERE clause + */ +Db::$queries['update'] = function ( + string $table, + array $values, + $where = null +): bool { + return Db::table($table)->where($where)->update($values); +}; + +/** + * Shortcut for deleting rows in a table + * + * @param string $table The name of the table which should be queried + * @param mixed $where An optional WHERE clause + */ +Db::$queries['delete'] = function (string $table, $where = null): bool { + return Db::table($table)->where($where)->delete(); +}; + +/** + * Shortcut for counting rows in a table + * + * @param string $table The name of the table which should be queried + * @param mixed $where An optional WHERE clause + */ +Db::$queries['count'] = function (string $table, mixed $where = null): int { + return Db::table($table)->where($where)->count(); +}; + +/** + * Shortcut for calculating the minimum value in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the minimum should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['min'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->min($column); +}; + +/** + * Shortcut for calculating the maximum value in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the maximum should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['max'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->max($column); +}; + +/** + * Shortcut for calculating the average value in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the average should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['avg'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->avg($column); +}; + +/** + * Shortcut for calculating the sum of all values in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the sum should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['sum'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->sum($column); +}; + +// @codeCoverageIgnoreEnd diff --git a/public/kirby/src/Database/Query.php b/public/kirby/src/Database/Query.php new file mode 100644 index 0000000..659d1d9 --- /dev/null +++ b/public/kirby/src/Database/Query.php @@ -0,0 +1,972 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query +{ + public const ERROR_INVALID_QUERY_METHOD = 0; + + /** + * The object which should be fetched for each row + * or function to call for each row + */ + protected string|Closure $fetch = Obj::class; + + /** + * The iterator class, which should be used for result sets + */ + protected string $iterator = Collection::class; + + /** + * An array of bindings for the final query + */ + protected array $bindings = []; + + /** + * The name of the primary key column + */ + protected string $primaryKeyName = 'id'; + + /** + * An array with additional join parameters + */ + protected array|null $join = null; + + /** + * A list of columns, which should be selected + */ + protected array|string|null $select = null; + + /** + * Boolean for distinct select clauses + */ + protected bool|null $distinct = null; + + /** + * Boolean for if exceptions should be thrown on failing queries + */ + protected bool $fail = false; + + /** + * A list of values for update and insert clauses + */ + protected array|null $values = null; + + /** + * WHERE clause + */ + protected string|null $where = null; + + /** + * GROUP BY clause + */ + protected string|null $group = null; + + /** + * HAVING clause + */ + protected string|null $having = null; + + /** + * ORDER BY clause + */ + protected string|null $order = null; + + /** + * The offset, which should be applied to the select query + */ + protected int $offset = 0; + + /** + * The limit, which should be applied to the select query + */ + protected int|null $limit = null; + + /** + * Boolean to enable query debugging + */ + protected bool $debug = false; + + /** + * @param string $table name of the table, which should be queried + */ + public function __construct( + protected Database $database, + protected string $table + ) { + $this->table($table); + } + + /** + * Reset the query class after each db hit + */ + protected function reset(): void + { + $this->bindings = []; + $this->join = null; + $this->select = null; + $this->distinct = null; + $this->fail = false; + $this->values = null; + $this->where = null; + $this->group = null; + $this->having = null; + $this->order = null; + $this->offset = 0; + $this->limit = null; + $this->debug = false; + } + + /** + * Enables query debugging. + * If enabled, the query will return an array with all important info about + * the query instead of actually executing the query and returning results + * + * @return $this + */ + public function debug(bool $debug = true): static + { + $this->debug = $debug; + return $this; + } + + /** + * Enables distinct select clauses. + * + * @return $this + */ + public function distinct(bool $distinct = true): static + { + $this->distinct = $distinct; + return $this; + } + + /** + * Enables failing queries. + * If enabled queries will no longer fail silently but throw an exception + * + * @return $this + */ + public function fail(bool $fail = true): static + { + $this->fail = $fail; + return $this; + } + + /** + * Sets the object class, which should be fetched; + * set this to `'array'` to get a simple array instead of an object; + * pass a function that receives the `$data` and the `$key` to generate arbitrary data structures + * + * @return $this + */ + public function fetch(string|callable|Closure $fetch): static + { + if (is_callable($fetch) === true) { + $fetch = Closure::fromCallable($fetch); + } + + $this->fetch = $fetch; + return $this; + } + + /** + * Sets the iterator class, which should be used for multiple results + * Set this to array to get a simple array instead of an iterator object + * + * @return $this + */ + public function iterator(string $iterator): static + { + $this->iterator = $iterator; + return $this; + } + + /** + * Sets the name of the table, which should be queried + * + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException if the table does not exist + */ + public function table(string $table): static + { + if ($this->database->validateTable($table) === false) { + throw new InvalidArgumentException( + message: 'Invalid table: ' . $table + ); + } + + $this->table = $table; + return $this; + } + + /** + * Sets the name of the primary key column + * + * @return $this + */ + public function primaryKeyName(string $primaryKeyName): static + { + $this->primaryKeyName = $primaryKeyName; + return $this; + } + + /** + * Sets the columns, which should be selected from the table + * By default all columns will be selected + * + * @param array|string|null $select Pass either a string of columns or an array + * @return $this + */ + public function select(array|string|null $select): static + { + $this->select = $select; + return $this; + } + + /** + * Adds a new join clause to the query + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @param string $type The join type. Uses an inner join by default + * @return $this + */ + public function join( + string $table, + string $on, + string $type = 'JOIN' + ): static { + $join = [ + 'table' => $table, + 'on' => $on, + 'type' => $type + ]; + + $this->join[] = $join; + return $this; + } + + /** + * Shortcut for creating a left join clause + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @return $this + */ + public function leftJoin(string $table, string $on): static + { + return $this->join($table, $on, 'left join'); + } + + /** + * Shortcut for creating a right join clause + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @return $this + */ + public function rightJoin(string $table, string $on): static + { + return $this->join($table, $on, 'right join'); + } + + /** + * Shortcut for creating an inner join clause + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @return $this + */ + public function innerJoin($table, $on): static + { + return $this->join($table, $on, 'inner join'); + } + + /** + * Sets the values which should be used for the update or insert clause + * + * @param mixed $values Can either be a string or an array of values + * @return $this + */ + public function values($values = []): static + { + if ($values !== null) { + $this->values = $values; + } + return $this; + } + + /** + * Attaches additional bindings to the query. + * Also can be used as getter for all attached bindings + * by not passing an argument. + * + * @return array|$this + * @psalm-return ($bindings is array ? $this : array) + */ + public function bindings(array|null $bindings = null): array|static + { + if (is_array($bindings) === true) { + $this->bindings = [...$this->bindings, ...$bindings]; + return $this; + } + + return $this->bindings; + } + + /** + * Attaches an additional where clause + * + * All available ways to add where clauses + * + * ->where('username like "myuser"'); (args: 1) + * ->where(['username' => 'myuser']); (args: 1) + * ->where(function($where) { $where->where('id', '=', 1) }) (args: 1) + * ->where('username like ?', 'myuser') (args: 2) + * ->where('username', 'like', 'myuser'); (args: 3) + * + * @return $this + */ + public function where(...$args): static + { + $this->where = $this->filterQuery($args, $this->where); + return $this; + } + + /** + * Shortcut to attach a where clause with an OR operator. + * Check out the where() method docs for additional info. + * + * @return $this + */ + public function orWhere(...$args): static + { + $this->where = $this->filterQuery($args, $this->where, 'OR'); + return $this; + } + + /** + * Shortcut to attach a where clause with an AND operator. + * Check out the where() method docs for additional info. + * + * @return $this + */ + public function andWhere(...$args): static + { + $this->where = $this->filterQuery($args, $this->where, 'AND'); + return $this; + } + + /** + * Attaches a group by clause + * + * @return $this + */ + public function group(string|null $group = null): static + { + $this->group = $group; + return $this; + } + + /** + * Attaches an additional having clause + * + * All available ways to add having clauses + * + * ->having('username like "myuser"'); (args: 1) + * ->having(['username' => 'myuser']); (args: 1) + * ->having(function($having) { $having->having('id', '=', 1) }) (args: 1) + * ->having('username like ?', 'myuser') (args: 2) + * ->having('username', 'like', 'myuser'); (args: 3) + * + * @return $this + */ + public function having(...$args): static + { + $this->having = $this->filterQuery($args, $this->having); + return $this; + } + + /** + * Attaches an order clause + * + * @return $this + */ + public function order(string|null $order = null) + { + $this->order = $order; + return $this; + } + + /** + * Sets the offset for select clauses + * + * @return $this + */ + public function offset(int $offset): static + { + $this->offset = $offset; + return $this; + } + + /** + * Sets the limit for select clauses + * + * @return $this + */ + public function limit(int|null $limit = null): static + { + $this->limit = $limit; + return $this; + } + + /** + * Builds the different types of SQL queries + * This uses the SQL class to build stuff. + * + * @param string $type (select, update, insert) + * @return array The final query + */ + public function build(string $type): array + { + $sql = $this->database->sql(); + + return match ($type) { + 'select' => $sql->select([ + 'table' => $this->table, + 'columns' => $this->select, + 'join' => $this->join, + 'distinct' => $this->distinct, + 'where' => $this->where, + 'group' => $this->group, + 'having' => $this->having, + 'order' => $this->order, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'bindings' => $this->bindings + ]), + 'update' => $sql->update([ + 'table' => $this->table, + 'where' => $this->where, + 'values' => $this->values, + 'bindings' => $this->bindings + ]), + 'insert' => $sql->insert([ + 'table' => $this->table, + 'values' => $this->values, + 'bindings' => $this->bindings + ]), + 'delete' => $sql->delete([ + 'table' => $this->table, + 'where' => $this->where, + 'bindings' => $this->bindings + ]), + default => null + }; + } + + /** + * Builds a count query + */ + public function count(): int + { + return (int)$this->aggregate('COUNT'); + } + + /** + * Builds a max query + */ + public function max(string $column): float + { + return (float)$this->aggregate('MAX', $column); + } + + /** + * Builds a min query + */ + public function min(string $column): float + { + return (float)$this->aggregate('MIN', $column); + } + + /** + * Builds a sum query + */ + public function sum(string $column): float + { + return (float)$this->aggregate('SUM', $column); + } + + /** + * Builds an average query + */ + public function avg(string $column): float + { + return (float)$this->aggregate('AVG', $column); + } + + /** + * Builds an aggregation query. + * This is used by all the aggregation methods above + * + * @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 + ) { + // reset the sorting to avoid counting issues + $this->order = null; + + // validate column + if ($column !== '*') { + $sql = $this->database->sql(); + $column = $sql->columnName($this->table, $column); + } + + $fetch = $this->fetch; + $row = $this->select($method . '(' . $column . ') as aggregation')->fetch(Obj::class)->first(); + + if ($this->debug === true) { + return $row; + } + + $result = $row?->get('aggregation') ?? $default; + + $this->fetch($fetch); + + return $result; + } + + /** + * Used as an internal shortcut for firing a db query + */ + protected function query(string|array $sql, array $params = []) + { + if (is_string($sql) === true) { + $sql = [ + 'query' => $sql, + 'bindings' => $this->bindings() + ]; + } + + if ($this->debug) { + return [ + 'query' => $sql['query'], + 'bindings' => $this->bindings(), + 'options' => $params + ]; + } + + if ($this->fail) { + $this->database->fail(); + } + + $result = $this->database->query( + $sql['query'], + $sql['bindings'], + $params + ); + + $this->reset(); + + return $result; + } + + /** + * Used as an internal shortcut for executing a db query + */ + protected function execute(string|array $sql, array $params = []) + { + if (is_string($sql) === true) { + $sql = [ + 'query' => $sql, + 'bindings' => $this->bindings() + ]; + } + + if ($this->debug === true) { + return [ + 'query' => $sql['query'], + 'bindings' => $sql['bindings'], + 'options' => $params + ]; + } + + if ($this->fail) { + $this->database->fail(); + } + + $result = $this->database->execute($sql['query'], $sql['bindings']); + + $this->reset(); + + return $result; + } + + /** + * Selects only one row from a table + */ + public function first(): mixed + { + return $this->query($this->offset(0)->limit(1)->build('select'), [ + 'fetch' => $this->fetch, + 'iterator' => 'array', + 'method' => 'fetch', + ]); + } + + /** + * Selects only one row from a table + */ + public function row(): mixed + { + return $this->first(); + } + + /** + * Selects only one row from a table + */ + public function one(): mixed + { + return $this->first(); + } + + /** + * Automatically adds pagination to a query + * + * @param int $limit The number of rows, which should be returned for each page + * @return object Collection iterator with attached pagination object + */ + public function page(int $page, int $limit): object + { + // clone this to create a counter query + $counter = clone $this; + + // count the total number of rows for this query + $count = $counter->debug(false)->count(); + + // pagination + $pagination = new Pagination([ + 'limit' => $limit, + 'page' => $page, + 'total' => $count, + ]); + + // apply it to the dataset and retrieve all rows. make sure to use Collection as the iterator to be able to attach the pagination object + $iterator = $this->iterator; + $collection = $this + ->offset($pagination->offset()) + ->limit($pagination->limit()) + ->iterator(Collection::class) + ->all(); + + $this->iterator($iterator); + + // return debug information if debug mode is active + if ($this->debug) { + $collection['totalcount'] = $count; + return $collection; + } + + // store all pagination vars in a separate object + if ($collection) { + $collection->paginate($pagination); + } + + // return the limited collection + return $collection; + } + + /** + * Returns all matching rows from a table + */ + public function all() + { + return $this->query($this->build('select'), [ + 'fetch' => $this->fetch, + 'iterator' => $this->iterator, + ]); + } + + /** + * Returns only values from a single column + */ + public function column(string $column) + { + // 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(); + $key = $sql->combineIdentifier($this->table, $this->primaryKeyName); + + $this->order($key . ' ASC'); + } + + $results = $this->query($this->select([$column])->build('select'), [ + 'iterator' => 'array', + 'fetch' => 'array', + ]); + + if ($this->debug === true) { + return $results; + } + + $results = array_column($results, $column); + + if ($this->iterator === 'array') { + return $results; + } + + $iterator = $this->iterator; + + return new $iterator($results); + } + + /** + * Find a single row by column and value + */ + public function findBy(string $column, $value) + { + return $this->where([$column => $value])->first(); + } + + /** + * Find a single row by its primary key + */ + public function find($id) + { + return $this->findBy($this->primaryKeyName, $id); + } + + /** + * Fires an insert query + * + * @param mixed $values You can pass values here or set them with ->values() before + * @return mixed Returns the last inserted id on success or false. + */ + public function insert($values = null) + { + $query = $this->execute( + $this->values($values)->build('insert') + ); + + if ($this->debug === true) { + return $query; + } + + return $query ? $this->database->lastId() : false; + } + + /** + * Fires an update query + * + * @param mixed $values You can pass values here or set them with ->values() before + * @param mixed $where You can pass a where clause here or set it with ->where() before + */ + public function update($values = null, $where = null): bool + { + return $this->execute( + $this->values($values)->where($where)->build('update') + ); + } + + /** + * Fires a delete query + * + * @param mixed $where You can pass a where clause here or set it with ->where() before + */ + public function delete($where = null): bool + { + return $this->execute( + $this->where($where)->build('delete') + ); + } + + /** + * Enables magic queries like findByUsername or findByEmail + */ + public function __call(string $method, array $arguments = []) + { + if (preg_match('!^findBy([a-z]+)!i', $method, $match)) { + $column = Str::lower($match[1]); + return $this->findBy($column, $arguments[0]); + } + + throw new InvalidArgumentException( + message: 'Invalid query method: ' . $method, + code: static::ERROR_INVALID_QUERY_METHOD + ); + } + + /** + * Builder for where and having clauses + * + * @param array $args Arguments, see where() description + * @param mixed $current Current value (like $this->where) + */ + protected function filterQuery( + array $args, + $current, + string $mode = 'AND' + ) { + $result = ''; + + switch (count($args)) { + case 1: + + if ($args[0] === null) { + return $current; + } + + // ->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 + $result = $args[0]; + + // ->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); + + $result = $sql['query']; + + $this->bindings($sql['bindings']); + } elseif (is_callable($args[0]) === true) { + $query = clone $this; + + // since the callback uses its own where condition + // it is necessary to clear/reset the cloned where condition + $query->where = null; + + call_user_func($args[0], $query); + + // copy over the bindings from the nested query + $this->bindings = [...$this->bindings, ...$query->bindings]; + + $result = '(' . $query->where . ')'; + } + + break; + case 2: + + // ->where('username like :username', ['username' => 'myuser']) + 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 + ) { + // prepared where clause + $result = $args[0]; + + // store the bindings + $this->bindings([$args[1]]); + } + + break; + case 3: + + // ->where('username', 'like', 'myuser'); + if ( + is_string($args[0]) === true && + is_string($args[1]) === true + ) { + // validate column + $sql = $this->database->sql(); + $key = $sql->columnName($this->table, $args[0]); + + // ->where('username', 'in', ['myuser', 'myotheruser']); + // ->where('quantity', 'between', [10, 50]); + $predicate = trim(strtoupper($args[1])); + if (is_array($args[2]) === true) { + if (in_array($predicate, ['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], true) === false) { + throw new InvalidArgumentException( + message: 'Invalid predicate ' . $predicate + ); + } + + // build a list of bound values + $values = []; + $bindings = []; + + foreach ($args[2] as $value) { + $valueBinding = $sql->bindingName('value'); + $bindings[$valueBinding] = $value; + $values[] = $valueBinding; + } + + // add that to the where clause in parenthesis or seperated by AND + $values = match ($predicate) { + 'IN', + 'NOT IN' => '(' . implode(', ', $values) . ')', + 'BETWEEN', + 'NOT BETWEEN' => $values[0] . ' AND ' . $values[1] + }; + $result = $key . ' ' . $predicate . ' ' . $values; + + // ->where('username', 'like', 'myuser'); + } else { + $predicates = [ + '=', '>=', '>', '<=', '<', '<>', '!=', '<=>', + 'IS', 'IS NOT', + 'LIKE', 'NOT LIKE', + 'SOUNDS LIKE', + 'REGEXP', 'NOT REGEXP' + ]; + + if (in_array($predicate, $predicates, true) === false) { + throw new InvalidArgumentException( + message: 'Invalid predicate/operator ' . $predicate + ); + } + + $valueBinding = $sql->bindingName('value'); + $bindings[$valueBinding] = $args[2]; + + $result = $key . ' ' . $predicate . ' ' . $valueBinding; + } + $this->bindings($bindings); + } + + break; + } + + // attach the where clause + if (empty($current) === false) { + return $current . ' ' . $mode . ' ' . $result; + } + + return $result; + } +} diff --git a/public/kirby/src/Database/Sql.php b/public/kirby/src/Database/Sql.php new file mode 100644 index 0000000..08dfcd1 --- /dev/null +++ b/public/kirby/src/Database/Sql.php @@ -0,0 +1,962 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Sql +{ + /** + * List of literals which should not be escaped in queries + */ + public static array $literals = ['NOW()', null]; + + /** + * List of used bindings; used to avoid + * duplicate binding names + */ + protected array $bindings = []; + + /** + * Constructor + * @codeCoverageIgnore + */ + public function __construct( + protected Database $database + ) { + } + + /** + * Returns a randomly generated binding name + * + * @param string $label String that only contains alphanumeric chars and + * underscores to use as a human-readable identifier + * @return string Binding name that is guaranteed to be unique for this connection + */ + public function bindingName(string $label): string + { + // make sure that the binding name is safe to prevent injections; + // otherwise use a generic label + if (!$label || preg_match('/^[a-zA-Z0-9_]+$/', $label) !== 1) { + $label = 'invalid'; + } + + // generate random bindings until the name is unique + do { + $binding = ':' . $label . '_' . Str::random(8, 'alphaNum'); + } while (in_array($binding, $this->bindings, true) === true); + + // cache the generated binding name for future invocations + $this->bindings[] = $binding; + return $binding; + } + + /** + * Returns a query to list the columns of a specified table; + * the query needs to return rows with a column `name` + * + * @param string $table Table name + */ + abstract public function columns(string $table): array; + + /** + * Returns a query snippet for a column default value + * + * @param string $name Column name + * @param array $column Column definition array with an optional `default` key + * @return array Array with a `query` string and a `bindings` array + */ + public function columnDefault(string $name, array $column): array + { + if (isset($column['default']) === false) { + return [ + 'query' => null, + 'bindings' => [] + ]; + } + + $binding = $this->bindingName($name . '_default'); + + return [ + 'query' => 'DEFAULT ' . $binding, + 'bindings' => [ + $binding => $column['default'] + ] + ]; + } + + /** + * Returns the cleaned identifier based on the table and column name + * + * @param string $table Table name + * @param string $column Column name + * @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 + [$table, $column] = $this->splitIdentifier($table, $column); + + // combine the identifiers again + if ($this->database->validateColumn($table, $column) === true) { + return $this->combineIdentifier( + $table, + $column, + $enforceQualified !== true + ); + } + + // the table or column does not exist + return null; + } + + /** + * Abstracted column types to simplify table + * creation for multiple database drivers + * @codeCoverageIgnore + */ + public function columnTypes(): array + { + return [ + 'id' => '{{ name }} INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY', + 'varchar' => '{{ name }} varchar({{ size }}) {{ null }} {{ default }} {{ unique }}', + 'text' => '{{ name }} TEXT {{ unique }}', + 'int' => '{{ name }} INT(11) {{ unsigned }} {{ null }} {{ default }} {{ unique }}', + 'timestamp' => '{{ name }} TIMESTAMP {{ null }} {{ default }} {{ unique }}', + 'bool' => '{{ name }} TINYINT(1) {{ null }} {{ default }} {{ unique }}', + 'float' => '{{ name }} DOUBLE {{ null }} {{ default }} {{ unique }}', + 'decimal' => '{{ name }} DECIMAL({{ precision }}, {{ decimalPlaces }}) {{ null }} {{ default }} {{ unique }}' + ]; + } + + /** + * Combines an identifier (table and column) + * + * @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 { + return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column); + } + + /** + * Creates the CREATE TABLE syntax for a single column + * + * @param string $name Column name + * @param array $column Column definition array; valid keys: + * - `type` (required): Column template to use + * - `unsigned`: Whether an int column is signed or unsigned (boolean) + * - `size`: The size of varchar (int) + * - `precision`: The precision of a decimal type + * - `decimalPlaces`: The number of decimal places for a decimal type + * - `null`: Whether the column may be NULL (boolean) + * - `key`: Index this column is part of; special values `'primary'` for PRIMARY KEY and `true` for automatic naming + * - `unique`: Whether the index (or if not set the column itself) has a UNIQUE constraint + * - `default`: Default value of this column + * @return array Array with `query` and `key` strings, a `unique` boolean and a `bindings` array + * @throws \Kirby\Exception\InvalidArgumentException if no column type is given or the column type is not supported. + */ + public function createColumn(string $name, array $column): array + { + // column type + if (isset($column['type']) === false) { + throw new InvalidArgumentException( + message: 'No column type given for column ' . $name + ); + } + + $template = $this->columnTypes()[$column['type']] ?? null; + + if (!$template) { + throw new InvalidArgumentException( + message: 'Unsupported column type: ' . $column['type'] + ); + } + + // null option + if (A::get($column, 'null') === false) { + $null = 'NOT NULL'; + } else { + $null = 'NULL'; + } + + // indexes/keys + if (isset($column['key']) === true) { + if (is_string($column['key']) === true) { + $column['key'] = strtolower($column['key']); + } elseif ($column['key'] === true) { + $column['key'] = $name . '_index'; + } + } + + // unsigned (defaults to true for backwards compatibility) + if ( + isset($column['unsigned']) === true && + $column['unsigned'] === false + ) { + $unsigned = ''; + } else { + $unsigned = 'UNSIGNED'; + } + + // unique + $uniqueKey = false; + $uniqueColumn = null; + + 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; + } else { + // make the column itself unique + $uniqueColumn = 'UNIQUE'; + } + } + + // default value + $columnDefault = $this->columnDefault($name, $column); + + $query = trim(Str::template($template, [ + 'name' => $this->quoteIdentifier($name), + 'unsigned' => $unsigned, + 'size' => $column['size'] ?? 255, + 'precision' => $column['precision'] ?? 14, + 'decimalPlaces' => $column['decimalPlaces'] ?? 4, + 'null' => $null, + 'default' => $columnDefault['query'], + 'unique' => $uniqueColumn + ], ['fallback' => ''])); + + return [ + 'query' => $query, + 'bindings' => $columnDefault['bindings'], + 'key' => $column['key'] ?? null, + 'unique' => $uniqueKey + ]; + } + + /** + * Creates the inner query for the columns in a CREATE TABLE query + * + * @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()` + * @return array Array with a `query` string and `bindings`, `keys` and `unique` arrays + */ + public function createTableInner(array $columns): array + { + $query = []; + $bindings = []; + $keys = []; + $unique = []; + + foreach ($columns as $name => $column) { + $sql = $this->createColumn($name, $column); + + // collect query and bindings + $query[] = $sql['query']; + $bindings += $sql['bindings']; + + // make a list of keys per key name + if ($sql['key'] !== null) { + if (isset($keys[$sql['key']]) !== true) { + $keys[$sql['key']] = []; + } + + $keys[$sql['key']][] = $name; + + if ($sql['unique'] === true) { + $unique[$sql['key']] = true; + } + } + } + + return [ + 'query' => implode(',' . PHP_EOL, $query), + 'bindings' => $bindings, + 'keys' => $keys, + 'unique' => $unique + ]; + } + + /** + * Creates a CREATE TABLE query + * + * @param string $table Table name + * @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 + { + $inner = $this->createTableInner($columns); + + // add keys + foreach ($inner['keys'] as $key => $columns) { + // quote each column name and make a list string out of the column names + $columns = implode(', ', array_map( + fn ($name) => $this->quoteIdentifier($name), + $columns + )); + + if ($key === 'primary') { + $key = 'PRIMARY KEY'; + } else { + $unique = isset($inner['unique'][$key]) ? 'UNIQUE ' : ''; + $key = $unique . 'INDEX ' . $this->quoteIdentifier($key); + } + + $inner['query'] .= ',' . PHP_EOL . $key . ' (' . $columns . ')'; + } + + return [ + 'query' => 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner['query'] . PHP_EOL . ')', + 'bindings' => $inner['bindings'] + ]; + } + + /** + * Builds a DELETE clause + * + * @param array $params List of parameters for the DELETE clause. See defaults for more info. + */ + public function delete(array $params = []): array + { + $options = [ + 'table' => '', + 'where' => null, + 'bindings' => [], + ...$params + ]; + + $bindings = $options['bindings']; + $query = ['DELETE']; + + // from + $this->extend($query, $bindings, $this->from($options['table'])); + + // where + $this->extend($query, $bindings, $this->where($options['where'])); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Creates the sql for dropping a single table + */ + public function dropTable(string $table): array + { + return [ + 'query' => 'DROP TABLE ' . $this->tableName($table), + 'bindings' => [] + ]; + } + + /** + * Extends a given query and bindings + * by reference + */ + public function extend( + array &$query, + array &$bindings, + array $input + ): void { + if (empty($input['query']) === false) { + $query[] = $input['query']; + $bindings = [...$bindings, ...$input['bindings']]; + } + } + + /** + * Creates the from syntax + */ + public function from(string $table): array + { + return [ + 'query' => 'FROM ' . $this->tableName($table), + 'bindings' => [] + ]; + } + + /** + * Creates the group by syntax + */ + public function group(string|null $group = null): array + { + if (empty($group) === false) { + $query = 'GROUP BY ' . $group; + } + + return [ + 'query' => $query ?? null, + 'bindings' => [] + ]; + } + + /** + * Creates the having syntax + */ + public function having(string|null $having = null): array + { + if (empty($having) === false) { + $query = 'HAVING ' . $having; + } + + return [ + 'query' => $query ?? null, + 'bindings' => [] + ]; + } + + /** + * Creates an insert query + */ + public function insert(array $params = []): array + { + $table = $params['table'] ?? null; + $values = $params['values'] ?? null; + $bindings = $params['bindings']; + $query = ['INSERT INTO ' . $this->tableName($table)]; + + // add the values + $this->extend( + $query, + $bindings, + $this->values($table, $values, ', ', false) + ); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Creates a join query + * + * @throws \Kirby\Exception\InvalidArgumentException if an invalid join type is given + */ + public function join(string $type, string $table, string $on): array + { + $types = [ + 'JOIN', + 'INNER JOIN', + 'OUTER JOIN', + 'LEFT OUTER JOIN', + 'LEFT JOIN', + 'RIGHT OUTER JOIN', + 'RIGHT JOIN', + 'FULL OUTER JOIN', + 'FULL JOIN', + 'NATURAL JOIN', + 'CROSS JOIN', + 'SELF JOIN' + ]; + + $type = strtoupper(trim($type)); + + // validate join type + if (in_array($type, $types, true) === false) { + throw new InvalidArgumentException( + message: 'Invalid join type ' . $type + ); + } + + return [ + 'query' => $type . ' ' . $this->tableName($table) . ' ON ' . $on, + 'bindings' => [], + ]; + } + + /** + * Create the syntax for multiple joins + */ + public function joins(array|null $joins = null): array + { + $query = []; + $bindings = []; + + foreach ((array)$joins as $join) { + $this->extend( + $query, + $bindings, + $this->join( + $join['type'] ?? 'JOIN', + $join['table'] ?? null, + $join['on'] ?? null + ) + ); + } + + return [ + 'query' => implode(' ', array_filter($query)), + 'bindings' => [], + ]; + } + + /** + * Creates a limit and offset query instruction + */ + public function limit(int $offset = 0, int|null $limit = null): array + { + // no need to add it to the query + if ($offset === 0 && $limit === null) { + return [ + 'query' => null, + 'bindings' => [] + ]; + } + + $limit ??= '18446744073709551615'; + + $offsetBinding = $this->bindingName('offset'); + $limitBinding = $this->bindingName('limit'); + + return [ + 'query' => 'LIMIT ' . $offsetBinding . ', ' . $limitBinding, + 'bindings' => [ + $limitBinding => $limit, + $offsetBinding => $offset, + ] + ]; + } + + /** + * Creates the order by syntax + */ + public function order(string|null $order = null): array + { + if (empty($order) === false) { + $query = 'ORDER BY ' . $order; + } + + return [ + 'query' => $query ?? null, + 'bindings' => [] + ]; + } + + /** + * Converts a query array into a final string + */ + public function query(array $query, string $separator = ' '): string + { + return implode($separator, array_filter($query)); + } + + /** + * Quotes an identifier (table *or* column) + */ + public function quoteIdentifier(string $identifier): string + { + // * is special, don't quote that + if ($identifier === '*') { + return $identifier; + } + + // escape backticks inside the identifier name + $identifier = str_replace('`', '``', $identifier); + + // wrap in backticks + return '`' . $identifier . '`'; + } + + /** + * Builds a select clause + * + * @param array $params List of parameters for the select clause. Check out the defaults for more info. + * @return array An array with the query and the bindings + */ + public function select(array $params = []): array + { + $options = [ + 'table' => '', + 'columns' => '*', + 'join' => null, + 'distinct' => false, + 'where' => null, + 'group' => null, + 'having' => null, + 'order' => null, + 'offset' => 0, + 'limit' => null, + 'bindings' => [], + ...$params + ]; + + $bindings = $options['bindings']; + $query = ['SELECT']; + + // select distinct values + if ($options['distinct'] === true) { + $query[] = 'DISTINCT'; + } + + // columns + $query[] = $this->selected($options['table'], $options['columns']); + + // from + $this->extend($query, $bindings, $this->from($options['table'])); + + // joins + $this->extend($query, $bindings, $this->joins($options['join'])); + + // where + $this->extend($query, $bindings, $this->where($options['where'])); + + // group + $this->extend($query, $bindings, $this->group($options['group'])); + + // having + $this->extend($query, $bindings, $this->having($options['having'])); + + // order + $this->extend($query, $bindings, $this->order($options['order'])); + + // offset and limit + $this->extend($query, $bindings, $this->limit($options['offset'], $options['limit'])); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Creates a columns definition from string or array + */ + public function selected(string $table, array|string|null $columns = null): string + { + // all columns + if (empty($columns) === true) { + return '*'; + } + + // array of columns + if (is_array($columns) === true) { + // validate columns + $result = []; + + foreach ($columns as $column) { + [$table, $columnPart] = $this->splitIdentifier($table, $column); + + if ($this->validateColumn($table, $columnPart) === true) { + $result[] = $this->combineIdentifier($table, $columnPart); + } + } + + return implode(', ', $result); + } + + return $columns; + } + + /** + * Splits a (qualified) identifier into table and column + * + * @param string $table Default table if the identifier is not qualified + * @throws \Kirby\Exception\InvalidArgumentException if an invalid identifier is given + */ + public function splitIdentifier(string $table, string $identifier): array + { + // split by dot, but only outside of quotes + $parts = preg_split('/(?:`[^`]*`|"[^"]*")(*SKIP)(*F)|\./', $identifier); + + return match (count($parts)) { + // non-qualified identifier + 1 => [$table, $this->unquoteIdentifier($parts[0])], + + // qualified identifier + 2 => [ + $this->unquoteIdentifier($parts[0]), + $this->unquoteIdentifier($parts[1]) + ], + + // every other number is an error + default => throw new InvalidArgumentException( + message: 'Invalid identifier ' . $identifier + ) + }; + } + + /** + * Returns a query to list the tables of the current database; + * the query needs to return rows with a column `name` + */ + abstract public function tables(): array; + + /** + * Validates and quotes a table name + * + * @throws \Kirby\Exception\InvalidArgumentException if an invalid table name is given + */ + public function tableName(string $table): string + { + // validate table + if ($this->database->validateTable($table) === false) { + throw new InvalidArgumentException( + message: 'Invalid table ' . $table + ); + } + + return $this->quoteIdentifier($table); + } + + /** + * Unquotes an identifier (table *or* column) + */ + public function unquoteIdentifier(string $identifier): string + { + // remove quotes around the identifier + if ( + str_starts_with($identifier, '"') || + str_starts_with($identifier, '`') + ) { + $identifier = Str::substr($identifier, 1); + } + + if ( + str_ends_with($identifier, '"') || + str_ends_with($identifier, '`') + ) { + $identifier = Str::substr($identifier, 0, -1); + } + + // unescape duplicated quotes + return str_replace(['""', '``'], ['"', '`'], $identifier); + } + + /** + * Builds an update clause + * + * @param array $params List of parameters for the update clause. See defaults for more info. + */ + public function update(array $params = []): array + { + $options = [ + 'table' => null, + 'values' => null, + 'where' => null, + 'bindings' => [], + ...$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']) + ); + + // add the where clause + $this->extend( + $query, + $bindings, + $this->where($options['where']) + ); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Validates a given column name in a table + * + * @throws \Kirby\Exception\InvalidArgumentException If the column is invalid + */ + public function validateColumn(string $table, string $column): bool + { + if ($this->database->validateColumn($table, $column) !== true) { + throw new InvalidArgumentException( + message: 'Invalid column ' . $column + ); + } + + return true; + } + + /** + * Builds a safe list of values for insert, select or update queries + * + * @param string $table Table name + * @param mixed $values A value string or array of values + * @param string $separator A separator which should be used to join values + * @param bool $set If true builds a set list of values for update clauses + * @param bool $enforceQualified Always use fully qualified column names + */ + public function values( + string $table, + $values, + string $separator = ', ', + bool $set = true, + bool $enforceQualified = false + ): array { + if (is_array($values) === false) { + return [ + 'query' => $values, + 'bindings' => [] + ]; + } + + if ($set === true) { + return $this->valueSet( + $table, + $values, + $separator, + $enforceQualified + ); + } + + return $this->valueList( + $table, + $values, + $separator, + $enforceQualified + ); + } + + /** + * Creates a list of fields and values + */ + public function valueList( + string $table, + string|array $values, + string $separator = ',', + bool $enforceQualified = false + ): array { + $fields = []; + $query = []; + $bindings = []; + + foreach ($values as $column => $value) { + $key = $this->columnName($table, $column, $enforceQualified); + + if ($key === null) { + continue; + } + + $fields[] = $key; + + if (in_array($value, static::$literals, true) === true) { + $query[] = $value ?: 'null'; + continue; + } + + if (is_array($value) === true) { + $value = json_encode($value); + } + + // add the binding + $bindings[$bindingName = $this->bindingName('value')] = $value; + + // create the query + $query[] = $bindingName; + } + + return [ + 'query' => '(' . implode($separator, $fields) . ') VALUES (' . implode($separator, $query) . ')', + 'bindings' => $bindings + ]; + } + + /** + * Creates a set of values + */ + public function valueSet( + string $table, + string|array $values, + string $separator = ',', + bool $enforceQualified = false + ): array { + $query = []; + $bindings = []; + + foreach ($values as $column => $value) { + $key = $this->columnName($table, $column, $enforceQualified); + + if ($key === null) { + continue; + } + + if (in_array($value, static::$literals, true) === true) { + $query[] = $key . ' = ' . ($value ?: 'null'); + continue; + } + + if (is_array($value) === true) { + $value = json_encode($value); + } + + // add the binding + $bindings[$bindingName = $this->bindingName('value')] = $value; + + // create the query + $query[] = $key . ' = ' . $bindingName; + } + + return [ + 'query' => implode($separator, $query), + 'bindings' => $bindings + ]; + } + + public function where(string|array|null $where, array $bindings = []): array + { + if (empty($where) === true) { + return [ + 'query' => null, + 'bindings' => [], + ]; + } + + if (is_string($where) === true) { + return [ + 'query' => 'WHERE ' . $where, + 'bindings' => $bindings + ]; + } + + $query = []; + + foreach ($where as $key => $value) { + $binding = $this->bindingName('where_' . $key); + $bindings[$binding] = $value; + $query[] = $key . ' = ' . $binding; + } + + return [ + 'query' => 'WHERE ' . implode(' AND ', $query), + 'bindings' => $bindings + ]; + } +} diff --git a/public/kirby/src/Database/Sql/Mysql.php b/public/kirby/src/Database/Sql/Mysql.php new file mode 100644 index 0000000..02c3939 --- /dev/null +++ b/public/kirby/src/Database/Sql/Mysql.php @@ -0,0 +1,56 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Mysql extends Sql +{ + /** + * Returns a query to list the columns of a specified table; + * the query needs to return rows with a column `name` + * + * @param string $table Table name + */ + public function columns(string $table): array + { + $databaseBinding = $this->bindingName('database'); + $tableBinding = $this->bindingName('table'); + + $query = 'SELECT COLUMN_NAME AS name FROM INFORMATION_SCHEMA.COLUMNS '; + $query .= 'WHERE TABLE_SCHEMA = ' . $databaseBinding . ' AND TABLE_NAME = ' . $tableBinding; + + return [ + 'query' => $query, + 'bindings' => [ + $databaseBinding => $this->database->name(), + $tableBinding => $table, + ] + ]; + } + + /** + * Returns a query to list the tables of the current database; + * the query needs to return rows with a column `name` + */ + public function tables(): array + { + $binding = $this->bindingName('database'); + + return [ + 'query' => 'SELECT TABLE_NAME AS name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ' . $binding, + 'bindings' => [ + $binding => $this->database->name() + ] + ]; + } +} diff --git a/public/kirby/src/Database/Sql/Sqlite.php b/public/kirby/src/Database/Sql/Sqlite.php new file mode 100644 index 0000000..4293bdc --- /dev/null +++ b/public/kirby/src/Database/Sql/Sqlite.php @@ -0,0 +1,144 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Sqlite extends Sql +{ + /** + * Returns a query to list the columns of a specified table; + * the query needs to return rows with a column `name` + * + * @param string $table Table name + */ + public function columns(string $table): array + { + return [ + 'query' => 'PRAGMA table_info(' . $this->tableName($table) . ')', + 'bindings' => [], + ]; + } + + /** + * Abstracted column types to simplify table + * creation for multiple database drivers + * @codeCoverageIgnore + */ + public function columnTypes(): array + { + return [ + 'id' => '{{ name }} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE', + 'varchar' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}', + 'text' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}', + 'int' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}', + 'timestamp' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}', + 'bool' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}', + 'float' => '{{ name }} REAL {{ null }} {{ default }} {{ unique }}', + 'decimal' => '{{ name }} REAL {{ null }} {{ default }} {{ unique }}' + ]; + } + + /** + * Combines an identifier (table and column) + * + * @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 { + // SQLite doesn't support qualified column names for VALUES clauses + if ($values === true) { + return $this->quoteIdentifier($column); + } + + return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column); + } + + /** + * Creates a CREATE TABLE query + * + * @param string $table Table name + * @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 { + $inner = $this->createTableInner($columns); + $keys = []; + + // add keys + foreach ($inner['keys'] as $key => $columns) { + // quote each column name and + // make a list string out of the column names + $columns = implode(', ', array_map( + fn ($name) => $this->quoteIdentifier($name), + $columns + )); + + 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]) ? '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 ($keys !== []) { + $query .= ';' . PHP_EOL . implode(';' . PHP_EOL, $keys); + } + + return [ + 'query' => $query, + 'bindings' => $inner['bindings'] + ]; + } + + /** + * Quotes an identifier (table *or* column) + */ + public function quoteIdentifier(string $identifier): string + { + // * is special + if ($identifier === '*') { + return $identifier; + } + + // escape quotes inside the identifier name + $identifier = str_replace('"', '""', $identifier); + + // wrap in quotes + return '"' . $identifier . '"'; + } + + /** + * Returns a query to list the tables of the current database; + * the query needs to return rows with a column `name` + */ + public function tables(): array + { + return [ + 'query' => 'SELECT name FROM sqlite_master WHERE type = \'table\' OR type = \'view\'', + 'bindings' => [] + ]; + } +} diff --git a/public/kirby/src/Email/Body.php b/public/kirby/src/Email/Body.php new file mode 100644 index 0000000..a25904a --- /dev/null +++ b/public/kirby/src/Email/Body.php @@ -0,0 +1,71 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Body +{ + protected string|null $html; + protected string|null $text; + + /** + * Email body constructor + */ + public function __construct(array $props = []) + { + $this->html = $props['html'] ?? null; + $this->text = $props['text'] ?? null; + } + + /** + * Creates a new instance while + * merging initial and new properties + * @deprecated 4.0.0 + */ + public function clone(array $props = []): static + { + return new static(array_merge_recursive([ + 'html' => $this->html, + 'text' => $this->text + ], $props)); + } + + /** + * Returns the HTML content of the email body + */ + public function html(): string + { + return $this->html ?? ''; + } + + /** + * Returns the plain text content of the email body + */ + public function text(): string + { + return $this->text ?? ''; + } + + /** + * @since 4.0.0 + */ + public function toArray(): array + { + return [ + 'html' => $this->html(), + 'text' => $this->text() + ]; + } +} diff --git a/public/kirby/src/Email/Email.php b/public/kirby/src/Email/Email.php new file mode 100644 index 0000000..0558d62 --- /dev/null +++ b/public/kirby/src/Email/Email.php @@ -0,0 +1,298 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Email +{ + /** + * If set to `true`, the debug mode is enabled + * for all emails + */ + public static bool $debug = false; + + /** + * Store for sent emails when `Email::$debug` + * is set to `true` + */ + public static array $emails = []; + + protected bool $isSent = false; + + protected array $attachments; + protected Body $body; + protected array $bcc; + protected Closure|null $beforeSend; + protected array $cc; + protected string $from; + protected string|null $fromName; + protected string $replyTo; + protected string|null $replyToName; + protected string $subject; + protected array $to; + protected array|null $transport; + + /** + * Email constructor + */ + public function __construct(array $props = [], bool $debug = false) + { + foreach (['body', 'from', 'to', 'subject'] as $required) { + if (isset($props[$required]) === false) { + throw new InvalidArgumentException( + message: 'The property "' . $required . '" is required' + ); + } + } + + if (is_string($props['body']) === true) { + $props['body'] = ['text' => $props['body']]; + } + + $this->attachments = $props['attachments'] ?? []; + $this->bcc = $this->resolveEmail($props['bcc'] ?? null); + $this->beforeSend = $props['beforeSend'] ?? null; + $this->body = new Body($props['body']); + $this->cc = $this->resolveEmail($props['cc'] ?? null); + $this->from = $this->resolveEmail($props['from'], false); + $this->fromName = $props['fromName'] ?? null; + $this->replyTo = $this->resolveEmail($props['replyTo'] ?? null, false); + $this->replyToName = $props['replyToName'] ?? null; + $this->subject = $props['subject']; + $this->to = $this->resolveEmail($props['to']); + $this->transport = $props['transport'] ?? null; + + // @codeCoverageIgnoreStart + if (static::$debug === false && $debug === false) { + $this->send(); + } elseif (static::$debug === true) { + static::$emails[] = $this; + } + // @codeCoverageIgnoreEnd + } + + /** + * Returns the email attachments + */ + public function attachments(): array + { + return $this->attachments; + } + + /** + * Returns the email body + */ + public function body(): Body|null + { + return $this->body; + } + + /** + * Returns "bcc" recipients + */ + public function bcc(): array + { + return $this->bcc; + } + + /** + * Returns the beforeSend callback closure, + * which has access to the PHPMailer instance + */ + public function beforeSend(): Closure|null + { + return $this->beforeSend; + } + + /** + * Returns "cc" recipients + */ + public function cc(): array + { + return $this->cc; + } + + /** + * Creates a new instance while + * merging initial and new properties + * @deprecated 4.0.0 + */ + public function clone(array $props = []): static + { + return new static(array_merge_recursive([ + 'attachments' => $this->attachments, + 'bcc' => $this->bcc, + 'beforeSend' => $this->beforeSend, + 'body' => $this->body->toArray(), + 'cc' => $this->cc, + 'from' => $this->from, + 'fromName' => $this->fromName, + 'replyTo' => $this->replyTo, + 'replyToName' => $this->replyToName, + 'subject' => $this->subject, + 'to' => $this->to, + 'transport' => $this->transport + ], $props)); + } + + /** + * Returns default transport settings + */ + protected function defaultTransport(): array + { + return [ + 'type' => 'mail' + ]; + } + + /** + * Returns the "from" email address + */ + public function from(): string + { + return $this->from; + } + + /** + * Returns the "from" name + */ + public function fromName(): string|null + { + return $this->fromName; + } + + /** + * Checks if the email has an HTML body + */ + public function isHtml(): bool + { + return empty($this->body()->html()) === false; + } + + /** + * Checks if the email has been sent successfully + */ + public function isSent(): bool + { + return $this->isSent; + } + + /** + * Returns the "reply to" email address + */ + public function replyTo(): string + { + return $this->replyTo; + } + + /** + * Returns the "reply to" name + */ + public function replyToName(): string|null + { + return $this->replyToName; + } + + /** + * Converts single or multiple email addresses to a sanitized format + * + * @throws \Exception + */ + protected function resolveEmail( + string|array|null $email = null, + bool $multiple = true + ): array|string { + if ($email === null) { + return $multiple === true ? [] : ''; + } + + if (is_array($email) === false) { + $email = [$email => null]; + } + + $result = []; + foreach ($email as $address => $name) { + // convert simple email arrays to associative arrays + if (is_int($address) === true) { + // the value is the address, there is no name + $address = $name; + $result[$address] = null; + } else { + $result[$address] = $name; + } + + // ensure that the address is valid + if (V::email($address) === false) { + throw new Exception(sprintf('"%s" is not a valid email address', $address)); + } + } + + return $multiple === true ? $result : array_keys($result)[0]; + } + + /** + * Sends the email + */ + public function send(): bool + { + return $this->isSent = true; + } + + /** + * Returns the email subject + */ + public function subject(): string + { + return $this->subject; + } + + /** + * Returns the email recipients + */ + public function to(): array + { + return $this->to; + } + + /** + * Returns the email transports settings + */ + public function transport(): array + { + return $this->transport ?? $this->defaultTransport(); + } + + /** + * @since 4.0.0 + */ + public function toArray(): array + { + return [ + 'attachments' => $this->attachments(), + 'bcc' => $this->bcc(), + 'body' => $this->body()->toArray(), + 'cc' => $this->cc(), + 'from' => $this->from(), + 'fromName' => $this->fromName(), + 'replyTo' => $this->replyTo(), + 'replyToName' => $this->replyToName(), + 'subject' => $this->subject(), + 'to' => $this->to(), + 'transport' => $this->transport() + ]; + } +} diff --git a/public/kirby/src/Email/PHPMailer.php b/public/kirby/src/Email/PHPMailer.php new file mode 100644 index 0000000..c04b36e --- /dev/null +++ b/public/kirby/src/Email/PHPMailer.php @@ -0,0 +1,114 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class PHPMailer extends Email +{ + /** + * Sends email via PHPMailer library + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function send(bool $debug = false): bool + { + $mailer = new Mailer(true); + + // set sender's address + $mailer->setFrom($this->from(), $this->fromName() ?? ''); + + // optional reply-to address + if ($replyTo = $this->replyTo()) { + $mailer->addReplyTo($replyTo, $this->replyToName() ?? ''); + } + + // add (multiple) recipient, CC & BCC addresses + foreach ($this->to() as $email => $name) { + $mailer->addAddress($email, $name ?? ''); + } + foreach ($this->cc() as $email => $name) { + $mailer->addCC($email, $name ?? ''); + } + foreach ($this->bcc() as $email => $name) { + $mailer->addBCC($email, $name ?? ''); + } + + $mailer->Subject = $this->subject(); + $mailer->CharSet = 'UTF-8'; + + // set body according to html/text + if ($this->isHtml()) { + $mailer->isHTML(true); + $mailer->Body = $this->body()->html(); + $mailer->AltBody = $this->body()->text(); + } else { + $mailer->Body = $this->body()->text(); + } + + // add attachments + foreach ($this->attachments() as $attachment) { + $mailer->addAttachment($attachment); + } + + // smtp transport settings + if (($this->transport()['type'] ?? 'mail') === 'smtp') { + $mailer->isSMTP(); + $mailer->Host = $this->transport()['host'] ?? null; + $mailer->SMTPAuth = $this->transport()['auth'] ?? false; + $mailer->Username = $this->transport()['username'] ?? null; + $mailer->Password = $this->transport()['password'] ?? null; + $mailer->SMTPSecure = $this->transport()['security'] ?? 'ssl'; + $mailer->Port = $this->transport()['port'] ?? null; + + if ($mailer->SMTPSecure === true) { + switch ($mailer->Port) { + case null: + case 587: + $mailer->SMTPSecure = 'tls'; + $mailer->Port = 587; + break; + case 465: + $mailer->SMTPSecure = 'ssl'; + break; + default: + throw new InvalidArgumentException( + 'Could not automatically detect the "security" protocol from the ' . + '"port" option, please set it explicitly to "tls" or "ssl".' + ); + } + } + } + + // accessible phpMailer instance + $beforeSend = $this->beforeSend(); + + if ($beforeSend instanceof Closure) { + $mailer = $beforeSend->call($this, $mailer) ?? $mailer; + + if ($mailer instanceof Mailer === false) { + throw new InvalidArgumentException( + message: '"beforeSend" option return should be instance of PHPMailer\PHPMailer\PHPMailer class' + ); + } + } + + if ($debug === true) { + return $this->isSent = true; + } + + return $this->isSent = $mailer->send(); // @codeCoverageIgnore + } +} diff --git a/public/kirby/src/Exception/AuthException.php b/public/kirby/src/Exception/AuthException.php new file mode 100644 index 0000000..325c437 --- /dev/null +++ b/public/kirby/src/Exception/AuthException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class AuthException extends Exception +{ + protected static string $defaultKey = 'auth'; + protected static string $defaultFallback = 'Unauthenticated'; + protected static int $defaultHttpCode = 401; +} diff --git a/public/kirby/src/Exception/BadMethodCallException.php b/public/kirby/src/Exception/BadMethodCallException.php new file mode 100644 index 0000000..58ef466 --- /dev/null +++ b/public/kirby/src/Exception/BadMethodCallException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class BadMethodCallException extends Exception +{ + protected static string $defaultKey = 'invalidMethod'; + protected static string $defaultFallback = 'The method "{ method }" does not exist'; + protected static int $defaultHttpCode = 400; + protected static array $defaultData = ['method' => null]; +} diff --git a/public/kirby/src/Exception/DuplicateException.php b/public/kirby/src/Exception/DuplicateException.php new file mode 100644 index 0000000..c04a6c0 --- /dev/null +++ b/public/kirby/src/Exception/DuplicateException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class DuplicateException extends Exception +{ + protected static string $defaultKey = 'duplicate'; + protected static string $defaultFallback = 'The entry exists'; + protected static int $defaultHttpCode = 400; +} diff --git a/public/kirby/src/Exception/ErrorPageException.php b/public/kirby/src/Exception/ErrorPageException.php new file mode 100644 index 0000000..12bf385 --- /dev/null +++ b/public/kirby/src/Exception/ErrorPageException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * @since 3.3.0 + */ +class ErrorPageException extends Exception +{ + protected static string $defaultKey = 'errorPage'; + protected static string $defaultFallback = 'Triggered error page'; + protected static int $defaultHttpCode = 404; +} diff --git a/public/kirby/src/Exception/Exception.php b/public/kirby/src/Exception/Exception.php new file mode 100644 index 0000000..2b8cbed --- /dev/null +++ b/public/kirby/src/Exception/Exception.php @@ -0,0 +1,230 @@ + + * @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 +{ + /** + * Data variables that can be used inside the exception message + */ + protected array $data; + + /** + * 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 + */ + protected bool $isTranslated = true; + + /** + * Defaults that can be overridden by specific + * exception classes + */ + protected static string $defaultKey = 'general'; + protected static string $defaultFallback = 'An error occurred'; + protected static array $defaultData = []; + protected static int $defaultHttpCode = 500; + protected static array $defaultDetails = []; + + /** + * Prefix for the exception key (e.g. 'error.general') + */ + private static string $prefix = 'error'; + + public function __construct( + array|string $args = [], // @deprecated + + 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; + + $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) { + $message ??= $args; + } + + 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); + } + + /** + * Returns the file in which the Exception was created + * relative to the document root + */ + final public function getFileRelative(): string + { + $file = $this->getFile(); + $root = Environment::getGlobally('DOCUMENT_ROOT'); + + if (empty($root) === true) { + return $file; + } + + return ltrim(Str::after($file, $root), '/'); + } + + /** + * Returns the data variables from the message + */ + final public function getData(): array + { + return $this->data; + } + + /** + * Returns the additional details that are + * not included in the message + */ + final public function getDetails(): array + { + $details = $this->details; + + foreach ($details as $key => $detail) { + if ($detail instanceof Throwable) { + $details[$key] = [ + 'label' => $key, + 'message' => $detail->getMessage(), + ]; + } + } + + return $details; + } + + /** + * Returns the exception key (error type) + */ + final public function getKey(): string + { + return $this->getCode(); + } + + /** + * Returns the HTTP code that corresponds + * with the exception + */ + final public function getHttpCode(): int + { + return $this->httpCode; + } + + /** + * Returns whether the exception message could + * be translated into the user's language + */ + final public function isTranslated(): bool + { + return $this->isTranslated; + } + + /** + * Converts the object to an array + */ + public function toArray(): array + { + return [ + 'exception' => static::class, + 'message' => $this->getMessage(), + 'key' => $this->getKey(), + 'file' => $this->getFileRelative(), + 'line' => $this->getLine(), + 'details' => $this->getDetails(), + 'code' => $this->getHttpCode() + ]; + } +} diff --git a/public/kirby/src/Exception/InvalidArgumentException.php b/public/kirby/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..b7172a7 --- /dev/null +++ b/public/kirby/src/Exception/InvalidArgumentException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class InvalidArgumentException extends Exception +{ + protected static string $defaultKey = 'invalidArgument'; + protected static string $defaultFallback = 'Invalid argument "{ argument }" in method "{ method }"'; + protected static int $defaultHttpCode = 400; + protected static array $defaultData = ['argument' => null, 'method' => null]; +} diff --git a/public/kirby/src/Exception/LogicException.php b/public/kirby/src/Exception/LogicException.php new file mode 100644 index 0000000..fde83f2 --- /dev/null +++ b/public/kirby/src/Exception/LogicException.php @@ -0,0 +1,19 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class LogicException extends Exception +{ + protected static string $defaultKey = 'logic'; + protected static string $defaultFallback = 'This task cannot be finished'; + protected static int $defaultHttpCode = 400; +} diff --git a/public/kirby/src/Exception/NotFoundException.php b/public/kirby/src/Exception/NotFoundException.php new file mode 100644 index 0000000..f417f42 --- /dev/null +++ b/public/kirby/src/Exception/NotFoundException.php @@ -0,0 +1,19 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class NotFoundException extends Exception +{ + protected static string $defaultKey = 'notFound'; + protected static string $defaultFallback = 'Not found'; + protected static int $defaultHttpCode = 404; +} diff --git a/public/kirby/src/Exception/PermissionException.php b/public/kirby/src/Exception/PermissionException.php new file mode 100644 index 0000000..352f889 --- /dev/null +++ b/public/kirby/src/Exception/PermissionException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class PermissionException extends Exception +{ + protected static string $defaultKey = 'permission'; + protected static string $defaultFallback = 'You are not allowed to do this'; + protected static int $defaultHttpCode = 403; +} diff --git a/public/kirby/src/Field/FieldOptions.php b/public/kirby/src/Field/FieldOptions.php new file mode 100644 index 0000000..395a051 --- /dev/null +++ b/public/kirby/src/Field/FieldOptions.php @@ -0,0 +1,96 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class FieldOptions +{ + public function __construct( + /** + * The option source, either a fixed collection or + * a dynamic provider + */ + public Options|OptionsProvider $options = new Options(), + + /** + * Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + public bool $safeMode = true + ) { + } + + public static function factory(array $props, bool $safeMode = true): static + { + $options = match ($props['type']) { + 'api' => OptionsApi::factory($props), + 'query' => OptionsQuery::factory($props), + default => Options::factory($props['options'] ?? []) + }; + + return new static($options, $safeMode); + } + + public static function polyfill(array $props = []): array + { + if (is_string($props['options'] ?? null) === true) { + $props['options'] = match ($props['options']) { + 'api' => + ['type' => 'api'] + + OptionsApi::polyfill($props['api'] ?? null), + + 'query' => + ['type' => 'query'] + + OptionsQuery::polyfill($props['query'] ?? null), + + default => + ['type' => 'query', 'query' => $props['options']] + }; + } + + unset($props['api'], $props['query']); + + if (($props['options']['type'] ?? null) !== null) { + return $props; + } + + if (($props['options'] ?? null) !== null) { + $props['options'] = [ + 'type' => 'array', + 'options' => $props['options'] + ]; + } + + return $props; + } + + 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 new file mode 100644 index 0000000..4a3cccb --- /dev/null +++ b/public/kirby/src/Filesystem/Asset.php @@ -0,0 +1,135 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Asset +{ + use IsFile; + use FileModifications; + use HasMethods; + + /** + * Relative file path + */ + protected string $path; + + + /** + * Creates a new Asset object for the given path. + */ + public function __construct(string $path) + { + $this->root = $this->kirby()->root('index') . '/' . $path; + $this->url = $this->kirby()->url('base') . '/' . $path; + + // set relative file path + $this->path = dirname($path); + + if ($this->path === '.') { + $this->path = ''; + } + } + + /** + * Magic caller for asset methods + * + * @throws \Kirby\Exception\BadMethodCallException + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // asset method proxy + if (method_exists($this->asset(), $method)) { + return $this->asset()->$method(...$arguments); + } + + // asset methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + throw new BadMethodCallException( + message: 'The method: "' . $method . '" does not exist' + ); + } + + /** + * Returns a unique id for the asset + */ + public function id(): string + { + 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 + */ + public function mediaHash(): string + { + return crc32($this->filename()) . '-' . $this->modified(); + } + + /** + * Returns the relative path starting at the media folder + */ + public function mediaPath(string|null $filename = null): string + { + $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|null $filename = null): string + { + return $this->kirby()->root('media') . '/' . $this->mediaPath($filename); + } + + /** + * Returns the absolute Url to the file in the public media folder + */ + public function mediaUrl(string|null $filename = null): string + { + return $this->kirby()->url('media') . '/' . $this->mediaPath($filename); + } + + /** + * Returns the path of the file from the web root, + * excluding the filename + */ + public function path(): string + { + return $this->path; + } +} diff --git a/public/kirby/src/Filesystem/Dir.php b/public/kirby/src/Filesystem/Dir.php new file mode 100644 index 0000000..34db8bd --- /dev/null +++ b/public/kirby/src/Filesystem/Dir.php @@ -0,0 +1,648 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Dir +{ + /** + * Ignore when scanning directories + */ + public static array $ignore = [ + '.', + '..', + '.DS_Store', + '.gitignore', + '.git', + '.svn', + '.htaccess', + 'Thumb.db', + '@eaDir' + ]; + + public static string $numSeparator = '_'; + + /** + * Copy the directory to a new destination + * + * @param array|false $ignore List of full paths to skip during copying + * or `false` to copy all files, including + * those listed in `Dir::$ignore` + */ + public static function copy( + string $dir, + string $target, + bool $recursive = true, + array|false $ignore = [] + ): bool { + if (is_dir($dir) === false) { + throw new Exception('The directory "' . $dir . '" does not exist'); + } + + if (is_dir($target) === true) { + throw new Exception('The target directory "' . $target . '" exists'); + } + + if (static::make($target) !== true) { + throw new Exception('The target directory "' . $target . '" could not be created'); + } + + foreach (static::read($dir, $ignore === false ? [] : null) as $name) { + $root = $dir . '/' . $name; + + if ( + is_array($ignore) === true && + in_array($root, $ignore, true) === true + ) { + continue; + } + + if (is_dir($root) === true) { + if ($recursive === true) { + static::copy($root, $target . '/' . $name, true, $ignore); + } + } else { + F::copy($root, $target . '/' . $name); + } + } + + return true; + } + + /** + * Get all subdirectories + */ + public static function dirs( + string $dir, + array|null $ignore = null, + bool $absolute = false + ): array { + $scan = static::read($dir, $ignore, true); + $result = array_values(array_filter($scan, 'is_dir')); + + if ($absolute !== true) { + $result = array_map('basename', $result); + } + + return $result; + } + + /** + * Checks if the directory exists on disk + */ + public static function exists(string $dir, string|null $in = null): bool + { + try { + static::realpath($dir, $in); + return true; + } catch (Exception) { + return false; + } + } + + /** + * Get all files + */ + public static function files( + string $dir, + array|null $ignore = null, + bool $absolute = false + ): array { + $scan = static::read($dir, $ignore, true); + $result = array_values(array_filter($scan, 'is_file')); + + if ($absolute !== true) { + $result = array_map('basename', $result); + } + + return $result; + } + + /** + * Read the directory and all subdirectories + * + * @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) + */ + public static function index( + string $dir, + bool $recursive = false, + array|false|null $ignore = [], + string|null $path = null + ): array { + $result = []; + $dir = realpath($dir); + $items = static::read($dir, $ignore === false ? [] : null); + + foreach ($items as $item) { + $root = $dir . '/' . $item; + + if ( + is_array($ignore) === true && + in_array($root, $ignore, true) === true + ) { + continue; + } + + $entry = $path !== null ? $path . '/' . $item : $item; + $result[] = $entry; + + if ($recursive === true && is_dir($root) === true) { + $result = [ + ...$result, + ...static::index($root, true, $ignore, $entry) + ]; + } + } + + return $result; + } + + /** + * Checks if the folder has any contents + */ + public static function isEmpty(string $dir): bool + { + return static::read($dir) === []; + } + + /** + * Checks if the directory is readable + */ + public static function isReadable(string $dir): bool + { + return is_readable($dir); + } + + /** + * Checks if the directory is writable + */ + public static function isWritable(string $dir): bool + { + return is_writable($dir); + } + + /** + * Scans the directory and analyzes files, + * content, meta info and children. This is used + * in `Kirby\Cms\Page`, `Kirby\Cms\Site` and + * `Kirby\Cms\User` objects to fetch all + * relevant information. + * + * Don't use outside the Cms context. + */ + public static function inventory( + string $dir, + string $contentExtension = 'txt', + array|null $contentIgnore = null, + bool $multilang = false + ): array { + $inventory = [ + 'children' => [], + 'files' => [], + 'template' => 'default', + ]; + + $dir = realpath($dir); + + if ($dir === false) { + return $inventory; + } + + // a temporary store for all content files + $content = []; + + // read and sort all items naturally to avoid sorting issues later + $items = static::read($dir, $contentIgnore); + natsort($items); + + // loop through all directory items and collect all relevant information + foreach ($items as $item) { + // ignore all items with a leading dot or underscore + if ( + str_starts_with($item, '.') || + str_starts_with($item, '_') + ) { + continue; + } + + $root = $dir . '/' . $item; + + // collect all directories as children + if (is_dir($root) === true) { + $inventory['children'][] = static::inventoryChild( + $item, + $root, + $contentExtension, + $multilang + ); + continue; + } + + $extension = pathinfo($item, PATHINFO_EXTENSION); + + // don't track files with these extensions + if (in_array($extension, ['htm', 'html', 'php'], true) === true) { + continue; + } + + // collect all content files separately, + // not as inventory entries + if ($extension === $contentExtension) { + $filename = pathinfo($item, PATHINFO_FILENAME); + + // remove the language codes from all content filenames + if ($multilang === true) { + $filename = pathinfo($filename, PATHINFO_FILENAME); + } + + $content[] = $filename; + continue; + } + + // collect all other files + $inventory['files'][$item] = [ + 'filename' => $item, + 'extension' => $extension, + 'root' => $root, + ]; + } + + $content = array_unique($content); + + $inventory['template'] = static::inventoryTemplate( + $content, + $inventory['files'] + ); + + return $inventory; + } + + /** + * Collect information for a child for the inventory + */ + protected static function inventoryChild( + string $item, + string $root, + string $contentExtension = 'txt', + bool $multilang = false + ): array { + // extract the slug and num of the directory + if ($separator = strpos($item, static::$numSeparator)) { + $num = (int)substr($item, 0, $separator); + $slug = substr($item, $separator + 1); + } + + // determine the model + if (Page::$models !== []) { + if ($multilang === true) { + $code = App::instance()->defaultLanguage()->code(); + $contentExtension = $code . '.' . $contentExtension; + } + + // look if a content file can be found + // for any of the available models + foreach (Page::$models as $modelName => $modelClass) { + if (is_file($root . '/' . $modelName . '.' . $contentExtension) === true) { + $model = $modelName; + break; + } + } + } + + return [ + 'dirname' => $item, + 'model' => $model ?? null, + 'num' => $num ?? null, + 'root' => $root, + 'slug' => $slug ?? $item, + ]; + } + + /** + * Determines the main template for the inventory + * from all collected content files, ignore file meta files + */ + protected static function inventoryTemplate( + array $content, + array $files, + ): string { + foreach ($content as $name) { + // is a meta file corresponding to an actual file, i.e. cover.jpg + if (isset($files[$name]) === true) { + continue; + } + + // it's most likely the template + // (will overwrite and use the last match for historic reasons) + $template = $name; + } + + return $template ?? 'default'; + } + + /** + * Create a (symbolic) link to a directory + */ + public static function link(string $source, string $link): bool + { + static::make(dirname($link), true); + + if (is_dir($link) === true) { + return true; + } + + if (is_dir($source) === false) { + throw new Exception(sprintf('The directory "%s" does not exist and cannot be linked', $source)); + } + + try { + return symlink($source, $link) === true; + } catch (Throwable) { + return false; + } + } + + /** + * Creates a new directory + * + * @param string $dir The path for the new directory + * @param bool $recursive Create all parent directories, which don't exist + * @return bool True: the dir has been created, false: creating failed + * @throws \Exception If a file with the provided path already exists or the parent directory is not writable + */ + public static function make(string $dir, bool $recursive = true): bool + { + if (empty($dir) === true) { + return false; + } + + if (is_dir($dir) === true) { + return true; + } + + if (is_file($dir) === true) { + throw new Exception(sprintf('A file with the name "%s" already exists', $dir)); + } + + $parent = dirname($dir); + + if ($recursive === true && is_dir($parent) === false) { + static::make($parent, true); + } + + if (is_writable($parent) === false) { + throw new Exception(sprintf('The directory "%s" cannot be created', $dir)); + } + + return Helpers::handleErrors( + fn (): bool => mkdir($dir), + // if the dir was already created (race condition), + fn (int $errno, string $errstr): bool => Str::endsWith($errstr, 'File exists'), + // consider it a success + true + ); + } + + /** + * Recursively check when the dir and all + * subfolders have been modified for the last time. + * + * @param string $dir The path of the directory + * @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null` + * for the globally configured one + */ + public static function modified( + string $dir, + string|null $format = null, + string|null $handler = null + ): int|string { + $modified = filemtime($dir); + $items = static::read($dir); + + foreach ($items as $item) { + $newModified = match (is_file($dir . '/' . $item)) { + true => filemtime($dir . '/' . $item), + false => static::modified($dir . '/' . $item) + }; + + if ($newModified > $modified) { + $modified = $newModified; + } + } + + return Str::date($modified, $format, $handler); + } + + /** + * Moves a directory to a new location + * + * @param string $old The current path of the directory + * @param string $new The desired path where the dir should be moved to + * @return bool true: the directory has been moved, false: moving failed + */ + public static function move(string $old, string $new): bool + { + if ($old === $new) { + return true; + } + + if (is_dir($old) === false || is_dir($new) === true) { + return false; + } + + if (static::make(dirname($new), true) !== true) { + throw new Exception('The parent directory cannot be created'); + } + + return rename($old, $new); + } + + /** + * Returns a nicely formatted size of all the contents of the folder + * + * @param string $dir The path of the directory + * @param string|false|null $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public static function niceSize( + string $dir, + string|false|null $locale = null + ): string { + return F::niceSize(static::size($dir), $locale); + } + + /** + * Reads all files from a directory and returns them as an array. + * It skips unwanted invisible stuff. + * + * @param string $dir The path of directory + * @param array $ignore Optional array with filenames, which should be ignored + * @param bool $absolute If true, the full path for each item will be returned + * @return array An array of filenames + */ + public static function read( + string $dir, + array|null $ignore = null, + bool $absolute = false + ): array { + if (is_dir($dir) === false) { + return []; + } + + // create the ignore pattern + $ignore ??= static::$ignore; + $ignore = [...$ignore, '.', '..']; + + // scan for all files and dirs + $result = array_values((array)array_diff(scandir($dir), $ignore)); + + // add absolute paths + if ($absolute === true) { + $result = array_map(fn ($item) => $dir . '/' . $item, $result); + } + + 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 + */ + public static function remove(string $dir): bool + { + $dir = realpath($dir); + + if (is_dir($dir) === false) { + return true; + } + + if (is_link($dir) === true) { + return F::unlink($dir); + } + + foreach (scandir($dir) as $childName) { + if (in_array($childName, ['.', '..'], true) === true) { + continue; + } + + $child = $dir . '/' . $childName; + + if (is_dir($child) === true && is_link($child) === false) { + static::remove($child); + } else { + F::unlink($child); + } + } + + return rmdir($dir); + } + + /** + * Gets the size of the directory + * + * @param string $dir The path of the directory + * @param bool $recursive Include all subfolders and their files + */ + public static function size(string $dir, bool $recursive = true): int|false + { + if (is_dir($dir) === false) { + return false; + } + + // Get size for all direct files + $size = F::size(static::files($dir, null, true)); + + // if recursive, add sizes of all subdirectories + if ($recursive === true) { + foreach (static::dirs($dir, null, true) as $subdir) { + $size += static::size($subdir); + } + } + + return $size; + } + + /** + * Checks if the directory or any subdirectory has been + * modified after the given timestamp + */ + public static function wasModifiedAfter(string $dir, int $time): bool + { + if (filemtime($dir) > $time) { + return true; + } + + $content = static::read($dir); + + foreach ($content as $item) { + $subdir = $dir . '/' . $item; + + if (filemtime($subdir) > $time) { + return true; + } + + if ( + is_dir($subdir) === true && + static::wasModifiedAfter($subdir, $time) === true + ) { + return true; + } + } + + return false; + } +} diff --git a/public/kirby/src/Filesystem/F.php b/public/kirby/src/Filesystem/F.php new file mode 100644 index 0000000..87d8d18 --- /dev/null +++ b/public/kirby/src/Filesystem/F.php @@ -0,0 +1,970 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class F +{ + public static array $types = [ + 'archive' => [ + 'gz', + 'gzip', + 'tar', + 'tgz', + 'zip', + ], + 'audio' => [ + 'aif', + 'aiff', + 'm4a', + 'midi', + 'mp3', + 'wav', + ], + 'code' => [ + 'css', + 'js', + 'json', + 'java', + 'htm', + 'html', + 'php', + 'rb', + 'py', + 'scss', + 'xml', + 'yaml', + 'yml', + ], + 'document' => [ + 'csv', + 'doc', + 'docx', + 'dotx', + 'indd', + 'md', + 'mdown', + 'pdf', + 'ppt', + 'pptx', + 'rtf', + 'txt', + 'xl', + 'xls', + 'xlsx', + 'xltx', + ], + 'image' => [ + 'ai', + 'avif', + 'bmp', + 'gif', + 'eps', + 'ico', + 'j2k', + 'jp2', + 'jpeg', + 'jpg', + 'jpe', + 'png', + 'ps', + 'psd', + 'svg', + 'tif', + 'tiff', + 'webp' + ], + 'video' => [ + 'avi', + 'flv', + 'm4v', + 'mov', + 'movie', + 'mpe', + 'mpg', + 'mp4', + 'ogg', + 'ogv', + 'swf', + 'webm', + ], + ]; + + public static array $units = [ + 'B', + 'KB', + 'MB', + 'GB', + 'TB', + 'PB', + 'EB', + 'ZB', + 'YB' + ]; + + /** + * Appends new content to an existing file + * + * @param string $file The path for the file + * @param mixed $content Either a string or an array. Arrays will be converted to JSON. + */ + public static function append(string $file, $content): bool + { + return static::write($file, $content, true); + } + + /** + * Returns the file content as base64 encoded string + * + * @param string $file The path for the file + */ + public static function base64(string $file): string + { + return base64_encode(static::read($file)); + } + + /** + * Copy a file to a new location. + */ + 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; + } + + $directory = dirname($target); + + // create the parent directory if it does not exist + if (is_dir($directory) === false) { + Dir::make($directory, true); + } + + return copy($source, $target); + } + + /** + * 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 + */ + public static function dirname(string $file): string + { + return dirname($file); + } + + /** + * Checks if the file exists on disk + */ + public static function exists(string $file, string|null $in = null): bool + { + try { + static::realpath($file, $in); + return true; + } catch (Exception) { + return false; + } + } + + /** + * Gets the extension of a file + * + * @param string|null $file The filename or path + * @param string|null $extension Set an optional extension to overwrite the current one + */ + public static function extension( + string|null $file = null, + string|null $extension = null + ): string { + // overwrite the current extension + if ($extension !== null) { + return static::name($file) . '.' . $extension; + } + + // return the current extension + return Str::lower(pathinfo($file, PATHINFO_EXTENSION)); + } + + /** + * Converts a file extension to a mime type + */ + public static function extensionToMime(string $extension): string|null + { + return Mime::fromExtension($extension); + } + + /** + * Returns the file type for a passed extension + */ + public static function extensionToType(string $extension): string|false + { + foreach (static::$types as $type => $extensions) { + if (in_array($extension, $extensions, true) === true) { + return $type; + } + } + + return false; + } + + /** + * Returns all extensions for a certain file type + */ + public static function extensions(string|null $type = null): array + { + if ($type === null) { + return array_keys(Mime::types()); + } + + return static::$types[$type] ?? []; + } + + /** + * Extracts the filename from a file path + * + * ```php + * $filename = F::filename('/var/www/test.txt'); + * // filename is test.txt + * ``` + * + * @param string $name The path + */ + public static function filename(string $name): string + { + return pathinfo($name, PATHINFO_BASENAME); + } + + /** + * Invalidate opcode cache for file. + * + * @param string $file The path of the file + */ + public static function invalidateOpcodeCache(string $file): bool + { + if ( + function_exists('opcache_invalidate') && + strlen(ini_get('opcache.restrict_api')) === 0 + ) { + return opcache_invalidate($file, true); + } + + return false; + } + + /** + * Checks if a file is of a certain type + * + * @param string $file Full path to the file + * @param string $value An extension or mime type + */ + public static function is(string $file, string $value): bool + { + // check for the extension + if (in_array($value, static::extensions(), true) === true) { + return static::extension($file) === $value; + } + + // check for the mime type + if (str_contains($value, '/') === true) { + return static::mime($file) === $value; + } + + return false; + } + + /** + * Checks if the file is readable + */ + public static function isReadable(string $file): bool + { + return is_readable($file); + } + + /** + * Checks if the file is writable + */ + public static function isWritable(string $file): bool + { + if (file_exists($file) === false) { + return is_writable(dirname($file)); + } + + return is_writable($file); + } + + /** + * Create a (symbolic) link to a file + */ + public static function link( + string $source, + string $link, + string $method = 'link' + ): bool { + Dir::make(dirname($link), true); + + if (is_file($link) === true) { + return true; + } + + if (is_file($source) === false) { + throw new Exception(sprintf('The file "%s" does not exist and cannot be linked', $source)); + } + + try { + return $method($source, $link) === true; + } catch (Throwable) { + return false; + } + } + + /** + * Loads a file and returns the result or `false` if the + * file to load does not exist + * + * @param array $data Optional array of variables to extract in the variable scope + */ + public static function load( + string $file, + mixed $fallback = null, + array $data = [], + bool $allowOutput = true + ) { + if (is_file($file) === false) { + return $fallback; + } + + // we use the loadIsolated() method here to prevent the included + // file from overwriting our $fallback in this variable scope; see + // https://www.php.net/manual/en/function.include.php#example-124 + $callback = fn () => static::loadIsolated($file, $data); + + // 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 + $result = match ($allowOutput) { + true => $callback(), + false => Response::guardAgainstOutput($callback), + }; + + if ( + $fallback !== null && + gettype($result) !== gettype($fallback) + ) { + return $fallback; + } + + return $result; + } + + /** + * A super simple class autoloader + * @since 3.7.0 + */ + public static function loadClasses( + array $classmap, + string|null $base = null + ): void { + // convert all classnames to lowercase + $classmap = array_change_key_case($classmap); + + spl_autoload_register( + fn ($class) => Response::guardAgainstOutput(function () use ($class, $classmap, $base) { + $class = strtolower($class); + + if (isset($classmap[$class]) === false) { + return false; + } + + if ($base) { + include $base . '/' . $classmap[$class]; + } else { + include $classmap[$class]; + } + }) + ); + } + + /** + * Loads a file with as little as possible in the variable scope + * + * @param array $data Optional array of variables to extract in the variable scope + */ + protected static function loadIsolated(string $file, array $data = []) + { + // extract the $data variables in this scope to be accessed by the included file; + // protect $file against being overwritten by a $data variable + $___file___ = $file; + extract($data); + + return include $___file___; + } + + /** + * Loads a file using `include_once()` and + * returns whether loading was successful + */ + public static function loadOnce( + string $file, + bool $allowOutput = true + ): bool { + if (is_file($file) === false) { + return false; + } + + $callback = fn () => include_once $file; + + if ($allowOutput === false) { + Response::guardAgainstOutput($callback); + } else { + $callback(); + } + + return true; + } + + /** + * Returns the mime type of a file + */ + public static function mime(string $file): string|null + { + return Mime::type($file); + } + + /** + * Converts a mime type to a file extension + */ + public static function mimeToExtension( + string|null $mime = null + ): string|false { + return Mime::toExtension($mime); + } + + /** + * Returns the type for a given mime + */ + public static function mimeToType(string $mime): string|false + { + return static::extensionToType(Mime::toExtension($mime)); + } + + /** + * Get the file's last modification time. + * + * @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null` + * for the globally configured one + */ + public static function modified( + string $file, + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): string|int|false { + if (file_exists($file) !== true) { + return false; + } + + $modified = filemtime($file); + + return Str::date($modified, $format, $handler); + } + + /** + * Moves a file to a new location + * + * @param string $oldRoot The current path for the file + * @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 { + // check if the file exists + if (file_exists($oldRoot) === false) { + return false; + } + + if (file_exists($newRoot) === true) { + if ($force === false) { + return false; + } + + // delete the existing file + static::remove($newRoot); + } + + $directory = dirname($newRoot); + + // create the parent directory if it does not exist + if (is_dir($directory) === false) { + Dir::make($directory, true); + } + + // atomically moving the file will only work if + // source and target are on the same filesystem + if (stat($oldRoot)['dev'] === stat($directory)['dev']) { + // same filesystem, we can move the file + return rename($oldRoot, $newRoot) === true; + } + + // @codeCoverageIgnoreStart + // not the same filesystem; we need to copy + // the file and unlink the source afterwards + if (copy($oldRoot, $newRoot) === true) { + return unlink($oldRoot) === true; + } + + // copying failed, ensure the new root isn't there + // (e.g. if the file could be created but there's no + // more remaining disk space to write its contents) + static::remove($newRoot); + return false; + // @codeCoverageIgnoreEnd + } + + /** + * Extracts the name from a file path or filename without extension + * + * @param string $name The path or filename + */ + public static function name(string $name): string + { + return pathinfo($name, PATHINFO_FILENAME); + } + + /** + * Converts an integer size into a human readable format + * + * @param int|string|array $size The file size, a file path or array of paths + * @param string|false|null $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public static function niceSize( + int|string|array $size, + string|false|null $locale = null + ): string { + // file mode + if (is_string($size) === true || is_array($size) === true) { + $size = static::size($size); + } + + // make sure it's an int + $size = (int)$size; + + // avoid errors for invalid sizes + if ($size <= 0) { + return '0 KB'; + } + + // the math magic + $size = round($size / 1024 ** ($unit = floor(log($size, 1024))), 2); + + // format the number if requested + if ($locale !== false) { + $size = I18n::formatNumber($size, $locale); + } + + return $size . ' ' . static::$units[$unit]; + } + + /** + * Reads the content of a file or requests the + * contents of a remote HTTP or HTTPS URL + * + * @param string $file The path for the file or an absolute URL + */ + public static function read(string $file): string|false + { + if (str_contains($file, '://') === true) { + return false; + } + + // 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 + ); + } + + /** + * Changes the name of the file without + * touching the extension + * + * @param bool $overwrite Force overwrite existing files + */ + public static function rename( + string $file, + string $newName, + bool $overwrite = false + ): string|false { + // create the new name + $name = static::safeName(basename($newName)); + + // overwrite the root + $newRoot = rtrim(dirname($file) . '/' . $name . '.' . F::extension($file), '.'); + + // nothing has changed + if ($newRoot === $file) { + return $newRoot; + } + + if (F::move($file, $newRoot, $overwrite) !== true) { + return false; + } + + return $newRoot; + } + + /** + * Returns the absolute path to the file if the file can be found. + */ + public static function realpath( + string $file, + string|null $in = null + ): string { + $realpath = realpath($file); + + if ($realpath === false || is_file($realpath) === false) { + throw new Exception(sprintf('The file does not exist at the given path: "%s"', $file)); + } + + 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 (str_starts_with($realpath, $parent) === false) { + throw new Exception('The file is not within the parent directory'); + } + } + + return $realpath; + } + + /** + * Returns the relative path of the file + * starting after $in + * + * @SuppressWarnings(PHPMD.CountInLoopExpression) + */ + public static function relativepath( + string $file, + string|null $in = null + ): string { + if (empty($in) === true) { + return basename($file); + } + + // windows + $file = str_replace('\\', '/', $file); + $in = str_replace('\\', '/', $in); + + // trim trailing slashes + $file = rtrim($file, '/'); + $in = rtrim($in, '/'); + + if (Str::contains($file, $in . '/') === false) { + // 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]) + ) { + array_shift($fileParts); + array_shift($inParts); + } + + return str_repeat('../', count($inParts)) . implode('/', $fileParts); + } + + return '/' . Str::after($file, $in . '/'); + } + + /** + * Deletes a file + * + * ```php + * $remove = F::remove('test.txt'); + * if ($remove) echo 'The file has been removed'; + * ``` + * + * @param string $file The path for the file + */ + public static function remove(string $file): bool + { + if (str_contains($file, '*') === true) { + foreach (glob($file) as $f) { + static::remove($f); + } + + return true; + } + + $file = realpath($file); + + if (is_string($file) === false) { + return true; + } + + return static::unlink($file); + } + + /** + * 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 + */ + public static function safeName(string $string): string + { + $basename = static::safeBasename($string); + $extension = static::safeExtension($string); + + if (empty($extension) === false) { + $extension = '.' . $extension; + } + + return $basename . $extension; + } + + /** + * Sanitize a file's name (without extension) + * @since 4.0.0 + */ + public static function safeBasename( + string $string, + bool $extract = true + ): string { + // extract only the name part from whole filename string + if ($extract === true) { + $string = static::name($string); + } + + return Str::slug($string, '-', 'a-z0-9@._-'); + } + + /** + * Sanitize a file's extension + * @since 4.0.0 + */ + public static function safeExtension( + string $string, + bool $extract = true + ): string { + // extract only the extension part from whole filename string + if ($extract === true) { + $string = static::extension($string); + } + + return Str::slug($string); + } + + /** + * Tries to find similar or the same file by + * building a glob based on the path + */ + public static function similar(string $path, string $pattern = '*'): array + { + $dir = dirname($path); + $name = static::name($path); + $extension = static::extension($path); + $glob = $dir . '/' . $name . $pattern . '.' . $extension; + return glob($glob); + } + + /** + * Returns the size of a file or an array of files. + * + * @param string|array $file file path or array of paths + */ + public static function size(string|array $file): int + { + if (is_array($file) === true) { + return array_reduce( + $file, + fn ($total, $file) => $total + F::size($file), + 0 + ); + } + + if ($size = @filesize($file)) { + return $size; + } + + return 0; + } + + /** + * Categorize the file + * + * @param string $file Either the file path or extension + */ + public static function type(string $file): string|null + { + $length = strlen($file); + $extension = match ($length >= 2 && $length <= 4) { + // use the file name as extension + true => $file, + // get the extension from the filename + false => pathinfo($file, PATHINFO_EXTENSION) + }; + + 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); + } + + // sanitize extension + $extension = strtolower($extension); + + foreach (static::$types as $type => $extensions) { + if (in_array($extension, $extensions, true) === true) { + return $type; + } + } + + return null; + } + + /** + * Returns all extensions of a given file type + * or `null` if the file type is unknown + */ + public static function typeToExtensions(string $type): array|null + { + return static::$types[$type] ?? null; + } + + /** + * Ensures that a file or link is deleted (with race condition handling) + * @since 3.7.4 + */ + public static function unlink(string $file): bool + { + return Helpers::handleErrors( + fn (): bool => unlink($file), + // if the file or link was already deleted (race condition), + fn (int $errno, string $errstr): bool => Str::endsWith($errstr, 'No such file or directory'), + // consider it a success + true + ); + } + + /** + * Unzips a zip file + */ + public static function unzip(string $file, string $to): bool + { + if (class_exists('ZipArchive') === false) { + throw new Exception('The ZipArchive class is not available'); + } + + $zip = new ZipArchive(); + + if ($zip->open($file) === true) { + $zip->extractTo($to); + $zip->close(); + return true; + } + + return false; + } + + /** + * Returns the file as data uri + * + * @param string $file The path for the file + */ + public static function uri(string $file): string|false + { + if ($mime = static::mime($file)) { + return 'data:' . $mime . ';base64,' . static::base64($file); + } + + return false; + } + + /** + * Creates a new file + * + * @param string $file The path for the new file + * @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 { + if (is_array($content) === true || is_object($content) === true) { + $content = serialize($content); + } + + $mode = $append === true ? FILE_APPEND | LOCK_EX : LOCK_EX; + + // if the parent directory does not exist, create it + if (is_dir(dirname($file)) === false) { + if (Dir::make(dirname($file)) === false) { + return false; + } + } + + if (static::isWritable($file) === false) { + throw new Exception('The file "' . $file . '" is not writable'); + } + + return file_put_contents($file, $content, $mode) !== false; + } +} diff --git a/public/kirby/src/Filesystem/File.php b/public/kirby/src/Filesystem/File.php new file mode 100644 index 0000000..4c5de06 --- /dev/null +++ b/public/kirby/src/Filesystem/File.php @@ -0,0 +1,581 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class File implements Stringable +{ + /** + * Parent file model + * The model object must use the `\Kirby\Filesystem\IsFile` trait + */ + protected object|null $model; + + /** + * Absolute file path + */ + protected string|null $root; + + /** + * Absolute file URL + */ + protected string|null $url; + + /** + * Validation rules to be used for `::match()` + */ + public static array $validations = [ + 'maxsize' => ['size', 'max'], + 'minsize' => ['size', 'min'] + ]; + + /** + * Constructor sets all file properties + * + * @param array|string|null $props Properties or deprecated `$root` string + * @param string|null $url Deprecated argument, use `$props['url']` instead + * + * @throws \Kirby\Exception\InvalidArgumentException When the model does not use the `Kirby\Filesystem\IsFile` trait + */ + public function __construct( + array|string|null $props = null, + string|null $url = null + ) { + // Legacy support for old constructor of + // the `Kirby\Image\Image` class + if (is_array($props) === false) { + $props = [ + 'root' => $props, + 'url' => $url + ]; + } + + $this->root = $props['root'] ?? null; + $this->url = $props['url'] ?? null; + $this->model = $props['model'] ?? null; + + if ( + $this->model !== null && + method_exists($this->model, 'hasIsFileTrait') !== true + ) { + throw new InvalidArgumentException( + message: 'The model object must use the "Kirby\Filesystem\IsFile" trait' + ); + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the URL for the file object + */ + public function __toString(): string + { + return $this->url() ?? $this->root() ?? ''; + } + + /** + * Returns the file content as base64 encoded string + */ + public function base64(): string + { + return base64_encode($this->read()); + } + + /** + * Copy a file to a new location. + */ + public function copy(string $target, bool $force = false): static + { + if (F::copy($this->root(), $target, $force) !== true) { + throw new Exception( + message: 'The file "' . $this->root() . '" could not be copied' + ); + } + + return new static($target); + } + + /** + * Returns the file as data uri + * + * @param bool $base64 Whether the data should be base64 encoded or not + */ + public function dataUri(bool $base64 = true): string + { + return match ($base64) { + true => 'data:' . $this->mime() . ';base64,' . $this->base64(), + false => 'data:' . $this->mime() . ',' . Escape::url($this->read()) + }; + } + + /** + * Deletes the file + */ + public function delete(): bool + { + if (F::remove($this->root()) !== true) { + throw new Exception( + message: 'The file "' . $this->root() . '" could not be deleted' + ); + } + + return true; + } + + /* + * Automatically sends all needed headers + * for the file to be downloaded and + * echos the file's content + * + * @param string|null $filename Optional filename for the download + */ + public function download(string|null $filename = null): string + { + return Response::download( + $this->root(), + $filename ?? $this->filename() + ); + } + + /** + * Checks if the file actually exists + */ + public function exists(): bool + { + return file_exists($this->root()) === true; + } + + /** + * Returns the current lowercase extension (without .) + */ + public function extension(): string + { + return F::extension($this->root()); + } + + /** + * Returns the filename + */ + public function filename(): string + { + return basename($this->root()); + } + + /** + * Returns a md5 hash of the root + */ + public function hash(): string + { + return md5($this->root()); + } + + /** + * Sends an appropriate header for the asset + */ + public function header(bool $send = true): Response|null + { + $response = new Response('', $this->mime()); + + if ($send !== true) { + return $response; + } + + $response->send(); + return null; + } + + /** + * Converts the file to html + */ + public function html(array $attr = []): string + { + return Html::a($this->url() ?? '', $attr); + } + + /** + * Checks if a file is of a certain type + * + * @param string $value An extension or mime type + */ + public function is(string $value): bool + { + return F::is($this->root(), $value); + } + + /** + * Checks if the file is readable + */ + public function isReadable(): bool + { + return is_readable($this->root()) === true; + } + + /** + * Checks if the file is a resizable image + */ + public function isResizable(): bool + { + return false; + } + + /** + * Checks if a preview can be displayed for the file + * in the Panel or in the frontend + */ + public function isViewable(): bool + { + return false; + } + + /** + * Checks if the file is writable + */ + public function isWritable(): bool + { + return F::isWritable($this->root()); + } + + /** + * Returns the app instance if it exists + */ + public function kirby(): App|null + { + return App::instance(null, true); + } + + /** + * Runs a set of validations on the file object + * (mainly for images). + * + * @throws \Kirby\Exception\Exception + */ + public function match(array $rules): bool + { + $rules = array_change_key_case($rules); + + if (is_array($rules['mime'] ?? null) === true) { + $mime = $this->mime(); + + // 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()] + ); + } + + // determine if any pattern matches the MIME type; + // once any pattern matches, `$carry` is `true` and the rest is skipped + $matches = array_reduce( + $rules['mime'], + fn ($carry, $pattern) => $carry || Mime::matches($mime, $pattern), + false + ); + + if ($matches !== true) { + 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) !== 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) !== true) { + throw new Exception( + key: 'file.type.invalid', + data: compact('type') + ); + } + } + + foreach (static::$validations as $key => $arguments) { + $rule = $rules[$key] ?? null; + + if ($rule !== null) { + $property = $arguments[0]; + $validator = $arguments[1]; + + if (V::$validator($this->$property(), $rule) === false) { + throw new Exception( + key: 'file.' . $key, + data: [$property => $rule] + ); + } + } + } + + return true; + } + + /** + * Detects the mime type of the file + */ + public function mime(): string|null + { + return Mime::type($this->root()); + } + + /** + * Returns the parent file model, which uses this instance as proxied file asset + */ + public function model(): object|null + { + return $this->model; + } + + /** + * Returns the file's last modification time + * + * @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null` + * for the globally configured one + */ + public function modified( + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): string|int|false { + return F::modified($this->root(), $format, $handler); + } + + /** + * Move the file to a new location + * + * @param bool $overwrite Force overwriting any existing files + */ + public function move(string $newRoot, bool $overwrite = false): static + { + if (F::move($this->root(), $newRoot, $overwrite) !== true) { + throw new Exception( + message: 'The file: "' . $this->root() . '" could not be moved to: "' . $newRoot . '"' + ); + } + + return new static($newRoot); + } + + /** + * Getter for the name of the file + * without the extension + */ + public function name(): string + { + return pathinfo($this->root(), PATHINFO_FILENAME); + } + + /** + * Returns the file size in a + * human-readable format + * + * @param string|false|null $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public function niceSize(string|false|null $locale = null): string + { + return F::niceSize($this->root(), $locale); + } + + /** + * Reads the file content and returns it. + */ + public function read(): string|false + { + return F::read($this->root()); + } + + /** + * Returns the absolute path to the file + */ + public function realpath(): string + { + return realpath($this->root()); + } + + /** + * Changes the name of the file without + * touching the extension + * + * @param bool $overwrite Force overwrite existing files + */ + public function rename(string $newName, bool $overwrite = false): static + { + $newRoot = F::rename($this->root(), $newName, $overwrite); + + if ($newRoot === false) { + throw new Exception( + message: 'The file: "' . $this->root() . '" could not be renamed to: "' . $newName . '"' + ); + } + + return new static($newRoot); + } + + /** + * Returns the given file path + */ + public function root(): string|null + { + return $this->root ??= $this->model?->root(); + } + + /** + * Returns the absolute url for the file + */ + public function url(): string|null + { + // lazily determine the URL from the model object + // only if it's needed to avoid breaking custom file::url + // components that rely on `$cmsFile->asset()` methods + return $this->url ??= $this->model?->url(); + } + + /** + * Sanitizes the file contents depending on the file type + * by overwriting the file with the sanitized version + * @since 3.6.0 + * + * @param string|bool $typeLazy Explicit sane handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\LogicException If more than one handler applies + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public function sanitizeContents(string|bool $typeLazy = false): void + { + Sane::sanitizeFile($this->root(), $typeLazy); + } + + /** + * Returns the sha1 hash of the file + * @since 3.6.0 + */ + public function sha1(): string + { + return sha1_file($this->root()); + } + + /** + * Returns the raw size of the file + */ + public function size(): int + { + return F::size($this->root()); + } + + /** + * Converts the media object to a + * plain PHP array + */ + public function toArray(): array + { + return [ + 'extension' => $this->extension(), + 'filename' => $this->filename(), + 'hash' => $this->hash(), + 'isReadable' => $this->isReadable(), + 'isResizable' => $this->isResizable(), + 'isWritable' => $this->isWritable(), + 'mime' => $this->mime(), + 'modified' => $this->modified('c'), + 'name' => $this->name(), + 'niceSize' => $this->niceSize(), + 'root' => $this->root(), + 'safeName' => F::safeName($this->name()), + 'size' => $this->size(), + 'type' => $this->type(), + 'url' => $this->url() + ]; + } + + /** + * Converts the entire file array into + * a json string + */ + public function toJson(): string + { + return json_encode($this->toArray()); + } + + /** + * Returns the file type. + */ + public function type(): string|null + { + return F::type($this->root()); + } + + /** + * Validates the file contents depending on the file type + * + * @param string|bool $typeLazy Explicit sane handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public function validateContents(string|bool $typeLazy = false): void + { + Sane::validateFile($this->root(), $typeLazy); + } + + /** + * Writes content to the file + */ + public function write(string $content): bool + { + if (F::write($this->root(), $content) !== true) { + 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 new file mode 100644 index 0000000..0001792 --- /dev/null +++ b/public/kirby/src/Filesystem/Filename.php @@ -0,0 +1,285 @@ + 'top left', + * 'width' => 300, + * 'height' => 200 + * 'quality' => 80 + * ]); + * + * echo $filename->toString(); + * // result: some-file-300x200-crop-top-left-q80.jpg + * + * @package Kirby Filesystem + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Filename implements Stringable +{ + /** + * The sanitized file extension + */ + protected string $extension; + + /** + * The sanitized file name + */ + protected string $name; + + /** + * Creates a new Filename object + * + * @param string $template for the final name + * @param array $attributes List of all applicable attributes + */ + public function __construct( + protected string $filename, + protected string $template, + protected array $attributes = [], + protected string|null $language = null + ) { + $this->name = $this->sanitizeName($filename); + $this->extension = $this->sanitizeExtension( + $attributes['format'] ?? + pathinfo($filename, PATHINFO_EXTENSION) + ); + } + + /** + * Converts the entire object to a string + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Converts all processed attributes + * to an array. The array keys are already + * the shortened versions for the filename + */ + public function attributesToArray(): array + { + $array = [ + 'dimensions' => implode('x', $this->dimensions()), + 'crop' => $this->crop(), + 'blur' => $this->blur(), + 'bw' => $this->grayscale(), + 'q' => $this->quality(), + 'sharpen' => $this->sharpen(), + ]; + + $array = array_filter( + $array, + fn ($item) => $item !== null && $item !== false && $item !== '' + ); + + return $array; + } + + /** + * Converts all processed attributes + * to a string, that can be used in the + * new filename + * + * @param string|null $prefix The prefix will be used in the filename creation + */ + public function attributesToString(string|null $prefix = null): string + { + $array = $this->attributesToArray(); + $result = []; + + foreach ($array as $key => $value) { + if ($value === true) { + $value = ''; + } + + $result[] = match ($key) { + 'dimensions' => $value, + 'crop' => match ($value) { + 'center' => 'crop', + default => $key . '-' . $value + }, + default => $key . $value + }; + } + + $result = array_filter($result); + $attributes = implode('-', $result); + + if (empty($attributes) === true) { + return ''; + } + + return $prefix . $attributes; + } + + /** + * Normalizes the blur option value + */ + public function blur(): int|false + { + $value = $this->attributes['blur'] ?? false; + + if ($value === false) { + return false; + } + + return (int)$value; + } + + /** + * Normalizes the crop option value + */ + public function crop(): string|false + { + // get the crop value + $crop = $this->attributes['crop'] ?? false; + + if ($crop === false) { + return false; + } + + return Str::slug($crop); + } + + /** + * Returns a normalized array + * with width and height values + * if available + */ + public function dimensions(): array + { + if (empty($this->attributes['width']) === true && empty($this->attributes['height']) === true) { + return []; + } + + return [ + 'width' => $this->attributes['width'] ?? null, + 'height' => $this->attributes['height'] ?? null + ]; + } + + /** + * Returns the sanitized extension + */ + public function extension(): string + { + return $this->extension; + } + + /** + * Normalizes the grayscale option value + * and also the available ways to write + * the option. You can use `grayscale`, + * `greyscale` or simply `bw`. The function + * will always return `grayscale` + */ + public function grayscale(): bool + { + // normalize options + $value = + $this->attributes['grayscale'] ?? + $this->attributes['greyscale'] ?? + $this->attributes['bw'] ?? + false; + + // turn anything into boolean + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Returns the filename without extension + */ + public function name(): string + { + return $this->name; + } + + /** + * Normalizes the quality option value + */ + public function quality(): int|false + { + $value = $this->attributes['quality'] ?? false; + + if ($value === false || $value === true) { + return false; + } + + return (int)$value; + } + + /** + * Sanitizes the file extension. + * It also replaces `jpeg` with `jpg`. + */ + protected function sanitizeExtension(string $extension): string + { + $extension = F::safeExtension('test.' . $extension); + $extension = str_replace('jpeg', 'jpg', $extension); + return $extension; + } + + /** + * Sanitizes the file name + */ + protected function sanitizeName(string $name): string + { + // temporarily store language rules + $rules = Str::$language; + + // add rules for a particular language to `Str` class + if ($this->language !== null) { + Str::$language = [ + ...Str::$language, + ...Language::loadRules($this->language)]; + } + + // sanitize name + $name = F::safeBasename($this->filename); + + // restore language rules + Str::$language = $rules; + + return $name; + } + + /** + * Normalizes the sharpen option value + */ + public function sharpen(): int|false + { + return match ($this->attributes['sharpen'] ?? false) { + false => false, + true => 50, + default => (int)$this->attributes['sharpen'] + }; + } + + /** + * Returns the converted filename as string + */ + public function toString(): string + { + return Str::template($this->template, [ + 'name' => $this->name(), + 'attributes' => $this->attributesToString('-'), + 'extension' => $this->extension() + ], ['fallback' => '']); + } +} diff --git a/public/kirby/src/Filesystem/IsFile.php b/public/kirby/src/Filesystem/IsFile.php new file mode 100644 index 0000000..883f4f7 --- /dev/null +++ b/public/kirby/src/Filesystem/IsFile.php @@ -0,0 +1,161 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait IsFile +{ + /** + * File asset object + */ + protected File|null $asset = null; + + /** + * Absolute file path + */ + protected string|null $root; + + /** + * Absolute file URL + */ + protected string|null $url; + + /** + * Constructor sets all file properties + */ + public function __construct(array $props) + { + $this->root = $props['root'] ?? null; + $this->url = $props['url'] ?? null; + } + + /** + * Magic caller for asset methods + * + * @throws \Kirby\Exception\BadMethodCallException + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // asset method proxy + if (method_exists($this->asset(), $method)) { + return $this->asset()->$method(...$arguments); + } + + throw new BadMethodCallException( + message: 'The method: "' . $method . '" does not exist' + ); + } + + /** + * Converts the asset to a string + */ + public function __toString(): string + { + return (string)$this->asset(); + } + + /** + * 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 + { + 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 match ($this->type()) { + 'image' => new Image($props), + default => new File($props) + }; + } + + /** + * Checks if the file exists on disk + */ + public function exists(): bool + { + // Important to include this in the trait + // to avoid infinite loops when trying + // to proxy the method from the asset object + return file_exists($this->root()) === true; + } + + /** + * To check the existence of the IsFile trait + * + * @todo Switch to class constant in traits when min PHP version 8.2 required + * @codeCoverageIgnore + */ + protected function hasIsFileTrait(): bool + { + return true; + } + + /** + * Returns the app instance + */ + public function kirby(): App + { + return App::instance(); + } + + /** + * Returns the given file path + */ + public function root(): string|null + { + return $this->root; + } + + /** + * Returns the file type + */ + public function type(): string|null + { + // Important to include this in the trait + // to avoid infinite loops when trying + // to proxy the method from the asset object + return F::type($this->root() ?? $this->url()); + } + + /** + * Returns the absolute url for the file + */ + public function url(): string|null + { + return $this->url; + } +} diff --git a/public/kirby/src/Filesystem/Mime.php b/public/kirby/src/Filesystem/Mime.php new file mode 100644 index 0000000..aa23c63 --- /dev/null +++ b/public/kirby/src/Filesystem/Mime.php @@ -0,0 +1,350 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Mime +{ + /** + * Extension to MIME type map + */ + public static array $types = [ + 'ai' => 'application/postscript', + 'aif' => 'audio/x-aiff', + 'aifc' => 'audio/x-aiff', + 'aiff' => 'audio/x-aiff', + 'avi' => 'video/x-msvideo', + 'avif' => 'image/avif', + 'bmp' => 'image/bmp', + 'css' => 'text/css', + 'csv' => ['text/csv', 'text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream'], + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + 'dvi' => 'application/x-dvi', + 'eml' => 'message/rfc822', + 'eps' => 'application/postscript', + 'exe' => ['application/octet-stream', 'application/x-msdownload'], + 'gif' => 'image/gif', + 'gtar' => 'application/x-gtar', + 'gz' => 'application/x-gzip', + 'htm' => 'text/html', + 'html' => 'text/html', + 'ico' => 'image/x-icon', + 'ics' => 'text/calendar', + 'js' => ['application/javascript', 'application/x-javascript'], + 'json' => ['application/json', 'text/json'], + 'j2k' => ['image/jp2'], + 'jp2' => ['image/jp2'], + 'jpg' => ['image/jpeg', 'image/pjpeg'], + 'jpeg' => ['image/jpeg', 'image/pjpeg'], + 'jpe' => ['image/jpeg', 'image/pjpeg'], + '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', + 'mjs' => 'text/javascript', + 'mov' => 'video/quicktime', + 'movie' => 'video/x-sgi-movie', + 'mp2' => 'audio/mpeg', + 'mp3' => ['audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3'], + 'mp4' => 'video/mp4', + 'mpe' => 'video/mpeg', + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mpga' => 'audio/mpeg', + 'odc' => 'application/vnd.oasis.opendocument.chart', + 'odp' => 'application/vnd.oasis.opendocument.presentation', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'pdf' => ['application/pdf', 'application/x-download'], + 'php' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'php3' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'phps' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'pht' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'phtml' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'png' => 'image/png', + 'ppt' => ['application/powerpoint', 'application/vnd.ms-powerpoint'], + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', + 'ps' => 'application/postscript', + 'psd' => 'application/x-photoshop', + 'qt' => 'video/quicktime', + 'rss' => 'application/rss+xml', + 'rtf' => 'text/rtf', + 'rtx' => 'text/richtext', + 'shtml' => 'text/html', + 'svg' => 'image/svg+xml', + 'swf' => 'application/x-shockwave-flash', + 'tar' => 'application/x-tar', + 'text' => 'text/plain', + 'txt' => 'text/plain', + 'tgz' => ['application/x-tar', 'application/x-gzip-compressed'], + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'wav' => ['audio/wav', 'audio/x-wav', 'audio/vnd.wave', 'audio/wave'], + 'wbxml' => 'application/wbxml', + 'webm' => ['video/webm', 'audio/webm'], + 'webp' => 'image/webp', + 'word' => ['application/msword', 'application/octet-stream'], + 'xhtml' => 'application/xhtml+xml', + 'xht' => 'application/xhtml+xml', + 'xml' => 'text/xml', + 'xl' => 'application/excel', + 'xls' => ['application/excel', 'application/vnd.ms-excel', 'application/msexcel'], + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + 'xsl' => 'text/xml', + 'yaml' => ['application/yaml', 'text/yaml'], + 'yml' => ['application/yaml', 'text/yaml'], + 'zip' => ['application/x-zip', 'application/zip', 'application/x-zip-compressed'], + ]; + + /** + * Fixes an invalid MIME type guess for the given file + */ + public static function fix( + string $file, + string|null $mime = null, + string|null $extension = null + ): string|null { + // fixing map + $map = [ + 'text/html' => [ + 'svg' => Mime::fromSvg(...), + ], + 'text/plain' => [ + 'css' => 'text/css', + 'json' => 'application/json', + 'mjs' => 'text/javascript', + 'svg' => Mime::fromSvg(...), + ], + 'text/x-asm' => [ + 'css' => 'text/css' + ], + 'text/x-java' => [ + 'mjs' => 'text/javascript', + ], + 'image/svg' => [ + 'svg' => 'image/svg+xml' + ], + 'application/octet-stream' => [ + 'mjs' => 'text/javascript' + ] + ]; + + if ($mode = $map[$mime][$extension] ?? null) { + if (is_callable($mode) === true) { + return $mode($file, $mime, $extension); + } + + if (is_string($mode) === true) { + return $mode; + } + } + + return $mime; + } + + /** + * Guesses a MIME type by extension + */ + public static function fromExtension(string $extension): string|null + { + $mime = static::$types[$extension] ?? null; + + if (is_array($mime) === true) { + $mime = array_shift($mime); + } + + return $mime; + } + + /** + * Returns the MIME type of a file + */ + public static function fromFileInfo(string $file): string|false + { + if ( + function_exists('finfo_file') === true && + file_exists($file) === true + ) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file); + finfo_close($finfo); + return $mime; + } + + return false; + } + + /** + * Returns the MIME type of a file + */ + public static function fromMimeContentType(string $file): string|false + { + if ( + function_exists('mime_content_type') === true && + file_exists($file) === true + ) { + return mime_content_type($file); + } + + return false; + } + + /** + * Tries to detect a valid SVG and returns the MIME type accordingly + */ + public static function fromSvg(string $file): string|false + { + if (file_exists($file) === true) { + libxml_use_internal_errors(true); + + $svg = new SimpleXMLElement(file_get_contents($file)); + + if ( + $svg !== false && + $svg->getName() === 'svg' + ) { + return 'image/svg+xml'; + } + } + + return false; + } + + /** + * Tests if a given MIME type is matched by an `Accept` header + * pattern; returns true if the MIME type is contained at all + */ + public static function isAccepted(string $mime, string $pattern): bool + { + $accepted = Str::accepted($pattern); + + foreach ($accepted as $m) { + if (static::matches($mime, $m['value']) === true) { + return true; + } + } + + return false; + } + + /** + * Tests if a MIME wildcard pattern from an `Accept` header + * matches a given type + * @since 3.3.0 + */ + public static function matches(string $test, string $wildcard): bool + { + return fnmatch($wildcard, $test, FNM_PATHNAME) === true; + } + + /** + * Returns the extension for a given MIME type + */ + 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) === true + ) { + return $key; + } + + if ($value === $mime) { + return $key; + } + } + + return false; + } + + /** + * Returns all available extensions for a given MIME type + */ + 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]); + + if ($matchWildcards === true) { + // 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); + } + ); + + // renumber array with consecutive keys + return array_values($extensions); + } + + /** + * Returns the MIME type of a file + */ + public static function type( + string $file, + string|null $extension = null + ): string|null { + // use the standard finfo extension + $mime = static::fromFileInfo($file); + + // use the mime_content_type function + if ($mime === false) { + $mime = static::fromMimeContentType($file); + } + + // get the extension or extract it from the filename + $extension ??= F::extension($file); + + // try to guess the mime type at least + if ($mime === false) { + $mime = static::fromExtension($extension); + } + + // fix broken mime detection + return static::fix($file, $mime, $extension); + } + + /** + * Returns all detectable MIME types + */ + public static function types(): array + { + return static::$types; + } +} diff --git a/public/kirby/src/Form/Field.php b/public/kirby/src/Form/Field.php new file mode 100644 index 0000000..a0b74ca --- /dev/null +++ b/public/kirby/src/Form/Field.php @@ -0,0 +1,423 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @use \Kirby\Cms\HasSiblings<\Kirby\Form\Fields> + */ +class Field extends Component +{ + 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 $siblings; + + /** + * Registry for all component mixins + */ + public static array $mixins = []; + + /** + * Registry for all component types + */ + public static array $types = []; + + /** + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct( + string $type, + array $attrs = [], + Fields|null $siblings = null + ) { + if (isset(static::$types[$type]) === false) { + throw new InvalidArgumentException( + key: 'field.type.missing', + data: [ + 'name' => $attrs['name'] ?? '-', + 'type' => $type + ] + ); + } + + // 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); + + // set the siblings collection + $this->siblings = $siblings ?? new Fields([$this]); + } + + /** + * Default props and computed of the field + */ + public static function defaults(): array + { + return [ + 'props' => [ + /** + * Optional text that will be shown after the input + */ + 'after' => function ($after = null) { + return I18n::translate($after, $after); + }, + /** + * Sets the focus on this field when the form loads. Only the first field with this label gets + */ + 'autofocus' => function (bool|null $autofocus = null): bool { + return $autofocus ?? false; + }, + /** + * Optional text that will be shown before the input + */ + 'before' => function ($before = null) { + return I18n::translate($before, $before); + }, + /** + * Default value for the field, which will be used when a page/file/user is created + */ + 'default' => function ($default = null) { + return $default; + }, + /** + * If `true`, the field is no longer editable and will not be saved + */ + 'disabled' => function (bool|null $disabled = null): bool { + return $disabled ?? false; + }, + /** + * Optional help text below the field + */ + 'help' => function ($help = null) { + return I18n::translate($help, $help); + }, + /** + * Optional icon that will be shown at the end of the field + */ + 'icon' => function (string|null $icon = null) { + return $icon; + }, + /** + * The field label can be set as string or associative array with translations + */ + 'label' => function ($label = null) { + return I18n::translate($label, $label); + }, + /** + * Optional placeholder value that will be shown when the field is empty + */ + 'placeholder' => function ($placeholder = null) { + return I18n::translate($placeholder, $placeholder); + }, + /** + * If `true`, the field has to be filled in correctly to be saved. + */ + 'required' => function (bool|null $required = null): bool { + return $required ?? false; + }, + /** + * If `false`, the field will be disabled in non-default languages and cannot be translated. This is only relevant in multi-language setups. + */ + 'translate' => function (bool $translate = true): bool { + return $translate; + }, + /** + * Conditions when the field will be shown (since 3.1.0) + */ + 'when' => function ($when = null) { + return $when; + }, + /** + * 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; + }, + 'value' => function ($value = null) { + return $value; + } + ], + 'computed' => [ + 'after' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->after !== null) { + return $this->model()->toString($this->after); + } + }, + 'before' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->before !== null) { + return $this->model()->toString($this->before); + } + }, + 'default' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->default === null) { + return; + } + + if (is_string($this->default) === false) { + return $this->default; + } + + return $this->model()->toString($this->default); + }, + 'help' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->help) { + $help = $this->model()->toSafeString($this->help); + $help = $this->kirby()->kirbytext($help); + return $help; + } + }, + 'label' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->label !== null) { + return $this->model()->toString($this->label); + } + }, + 'placeholder' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->placeholder !== null) { + return $this->model()->toString($this->placeholder); + } + } + ] + ]; + } + + /** + * Returns optional dialog routes for the field + */ + public function dialogs(): array + { + if ( + isset($this->options['dialogs']) === true && + $this->options['dialogs'] instanceof Closure + ) { + return $this->options['dialogs']->call($this); + } + + return []; + } + + /** + * Returns optional drawer routes for the field + */ + public function drawers(): array + { + if ( + isset($this->options['drawers']) === true && + $this->options['drawers'] instanceof Closure + ) { + return $this->options['drawers']->call($this); + } + + return []; + } + + /** + * Creates a new field instance + */ + public static function factory( + string $type, + array $attrs = [], + Fields|null $siblings = null + ): static|FieldClass { + $field = static::$types[$type] ?? null; + + if (is_string($field) && class_exists($field) === true) { + $attrs['siblings'] = $siblings; + return new $field($attrs); + } + + return new static($type, $attrs, $siblings); + } + + /** + * Sets a new value for the field + */ + public function fill(mixed $value): static + { + // 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; + } + + /** + * @deprecated 5.0.0 Use `::siblings() instead + */ + public function formFields(): Fields + { + 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->isEmptyValueFromMixin($value); + } + + /** + * Checks if the field is hidden + */ + public function isHidden(): bool + { + return ($this->options['hidden'] ?? false) === true; + } + + /** + * Returns field api routes + */ + public function routes(): array + { + if ( + isset($this->options['api']) === true && + $this->options['api'] instanceof Closure + ) { + return $this->options['api']->call($this); + } + + return []; + } + + /** + * Parent collection with all fields of the current form + */ + public function siblings(): Fields + { + return $this->siblings; + } + + /** + * Returns all sibling fields for the HasSiblings trait + */ + protected function siblingsCollection(): Fields + { + return $this->siblings; + } + + /** + * Converts the field to a plain array + */ + public function toArray(): array + { + $array = parent::toArray(); + + unset($array['model']); + + $array['hidden'] = $this->isHidden(); + $array['saveable'] = $this->hasValue(); + + ksort($array); + + return array_filter( + $array, + fn ($item) => $item !== null && is_object($item) === false + ); + } + + /** + * Returns the value of the field in a format to be stored by our storage classes + */ + public function toStoredValue(): mixed + { + $value = $this->toFormValue(); + $store = $this->options['save'] ?? true; + + if ($store === false) { + return null; + } + + if ($store instanceof Closure) { + return $store->call($this, $value); + } + + return $value; + } + + /** + * Defines all validation rules + */ + protected function validations(): array + { + return $this->options['validations'] ?? []; + } +} diff --git a/public/kirby/src/Form/Field/BlocksField.php b/public/kirby/src/Form/Field/BlocksField.php new file mode 100644 index 0000000..56b5739 --- /dev/null +++ b/public/kirby/src/Form/Field/BlocksField.php @@ -0,0 +1,364 @@ +setFieldsets( + $params['fieldsets'] ?? null, + $params['model'] ?? App::instance()->site() + ); + + parent::__construct($params); + + $this->setEmpty($params['empty'] ?? null); + $this->setGroup($params['group'] ?? 'blocks'); + $this->setMax($params['max'] ?? null); + $this->setMin($params['min'] ?? null); + $this->setPretty($params['pretty'] ?? false); + } + + public function blocksToValues( + array $blocks, + string $to = 'toFormValues' + ): array { + $result = []; + $fields = []; + $forms = []; + + foreach ($blocks as $block) { + try { + $type = $block['type']; + + // 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'] = $forms[$type]->reset()->fill(input: $block['content'])->$to(); + + // create id if not exists + $block['id'] ??= Str::uuid(); + } catch (Throwable) { + // skip invalid blocks + } finally { + $result[] = $block; + } + } + + return $result; + } + + public function fields(string $type): array + { + return $this->fieldset($type)->fields(); + } + + public function fieldset(string $type): Fieldset + { + if ($fieldset = $this->fieldsets->find($type)) { + return $fieldset; + } + + throw new NotFoundException( + 'The fieldset ' . $type . ' could not be found' + ); + } + + public function fieldsets(): Fieldsets + { + return $this->fieldsets; + } + + public function fieldsetGroups(): array|null + { + $groups = $this->fieldsets()->groups(); + return $groups === [] ? null : $groups; + } + + /** + * @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): Form + { + return new Form( + fields: $fields, + model: $this->model, + language: 'current' + ); + } + + public function isEmpty(): bool + { + return count($this->value()) === 0; + } + + public function group(): string + { + return $this->group; + } + + public function pretty(): bool + { + return $this->pretty; + } + + /** + * Paste action for blocks: + * - generates new uuids for the blocks + * - filters only supported fieldsets + * - applies max limit if defined + */ + public function pasteBlocks(array $blocks): array + { + $blocks = $this->blocksToValues($blocks); + + foreach ($blocks as $index => &$block) { + $block['id'] = Str::uuid(); + + // remove the block if it's not available + try { + $this->fieldset($block['type']); + } catch (Throwable) { + unset($blocks[$index]); + } + } + + return array_values($blocks); + } + + public function props(): array + { + return [ + 'empty' => $this->empty(), + 'fieldsets' => $this->fieldsets()->toArray(), + 'fieldsetGroups' => $this->fieldsetGroups(), + 'group' => $this->group(), + 'max' => $this->max(), + 'min' => $this->min(), + ] + parent::props(); + } + + public function routes(): array + { + $field = $this; + + return [ + [ + 'pattern' => 'uuid', + 'action' => fn (): array => ['uuid' => Str::uuid()] + ], + [ + 'pattern' => 'paste', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + $value = BlocksCollection::parse($request->get('html')); + $blocks = BlocksCollection::factory($value); + + return $field->pasteBlocks($blocks->toArray()); + } + ], + [ + 'pattern' => 'fieldsets/(:any)', + 'method' => 'GET', + 'action' => function ( + string $fieldsetType + ) use ($field): array { + $fields = $field->fields($fieldsetType); + $form = $field->form($fields); + + $form->fill(input: $form->defaults()); + + return Block::factory([ + 'content' => $form->toFormValues(), + 'type' => $fieldsetType + ])->toArray(); + } + ], + [ + 'pattern' => 'fieldsets/(:any)/fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function ( + string $fieldsetType, + string $fieldName, + string|null $path = null + ) use ($field) { + $fields = $field->fields($fieldsetType); + $field = $field->form($fields)->field($fieldName); + + $fieldApi = $this->clone([ + 'routes' => $field->api(), + 'data' => [ + ...$this->data(), + 'field' => $field + ] + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + ], + ]; + } + + protected function setDefault(mixed $default = null): void + { + // set id for blocks if not exists + if (is_array($default) === true) { + array_walk($default, function (&$block) { + $block['id'] ??= Str::uuid(); + }); + } + + parent::setDefault($default); + } + + protected function setFieldsets( + string|array|null $fieldsets, + ModelWithContent $model + ): void { + if (is_string($fieldsets) === true) { + $fieldsets = []; + } + + $this->fieldsets = Fieldsets::factory( + $fieldsets, + ['parent' => $model] + ); + } + + protected function setGroup(string|null $group = null): void + { + $this->group = $group; + } + + protected function setPretty(bool $pretty = false): void + { + $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: 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: match ($this->max) { + 1 => 'blocks.max.singular', + default => 'blocks.max.plural' + }, + data: ['max' => $this->max] + ); + } + + $forms = []; + $index = 0; + + foreach ($value as $block) { + $index++; + $type = $block['type']; + + // 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; + } + } + + // overwrite the content with the serialized form + $form = $forms[$type]->reset()->fill($block['content']); + + foreach ($form->fields() as $field) { + $errors = $field->errors(); + + // rough first validation + if (count($errors) > 0) { + throw new InvalidArgumentException( + key:'blocks.validation', + data: [ + 'field' => $field->label(), + 'fieldset' => $fieldset->name(), + 'index' => $index + ] + ); + } + } + } + + return true; + } + ]; + } +} 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 new file mode 100644 index 0000000..e920788 --- /dev/null +++ b/public/kirby/src/Form/Field/LayoutField.php @@ -0,0 +1,371 @@ +setModel($params['model'] ?? App::instance()->site()); + $this->setLayouts($params['layouts'] ?? ['1/1']); + $this->setSelector($params['selector'] ?? null); + $this->setSettings($params['settings'] ?? null); + + parent::__construct($params); + } + + /** + * @psalm-suppress MethodSignatureMismatch + * @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed + */ + public function fill(mixed $value): static + { + $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'] = $attrs->reset()->fill($layout['attrs'])->toFormValues(); + } + + foreach ($layout['columns'] as $columnIndex => $column) { + $layouts[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks']); + } + } + + $this->value = $layouts; + + return $this; + } + + public function attrsForm(): Form + { + return new Form( + fields: $this->settings()?->fields() ?? [], + model: $this->model + ); + } + + public function layouts(): array|null + { + return $this->layouts; + } + + /** + * Creates form values for each layout + */ + public function layoutsToValues(array $layouts): array + { + foreach ($layouts as &$layout) { + $layout['id'] ??= Str::uuid(); + $layout['columns'] ??= []; + + array_walk($layout['columns'], function (&$column) { + $column['id'] ??= Str::uuid(); + $column['blocks'] = $this->blocksToValues($column['blocks'] ?? []); + }); + } + + return $layouts; + } + + /** + * Paste action for layouts: + * - generates new uuids for layout, column and blocks + * - filters only supported layouts + * - filters only supported fieldsets + */ + public function pasteLayouts(array $layouts): array + { + $layouts = $this->layoutsToValues($layouts); + + foreach ($layouts as $layoutIndex => &$layout) { + $layout['id'] = Str::uuid(); + + // remove the row if layout not available for the pasted layout field + $columns = array_column($layout['columns'], 'width'); + if (in_array($columns, $this->layouts(), true) === false) { + unset($layouts[$layoutIndex]); + continue; + } + + array_walk($layout['columns'], function (&$column) { + $column['id'] = Str::uuid(); + + array_walk($column['blocks'], function (&$block, $index) use ($column) { + $block['id'] = Str::uuid(); + + // remove the block if it's not available + try { + $this->fieldset($block['type']); + } catch (Throwable) { + unset($column['blocks'][$index]); + } + }); + }); + } + + return $layouts; + } + + public function props(): array + { + return [ + ...parent::props(), + 'layouts' => $this->layouts(), + 'selector' => $this->selector(), + 'settings' => $this->settings()?->toArray() + ]; + } + + public function routes(): array + { + $field = $this; + $routes = parent::routes(); + + $routes[] = [ + 'pattern' => 'layout', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + + $columns = $request->get('columns') ?? ['1/1']; + $form = $field->attrsForm(); + + $form->fill(input: $form->defaults()); + $form->submit(input: $request->get('attrs') ?? []); + + return Layout::factory([ + 'attrs' => $form->toFormValues(), + 'columns' => array_map(fn ($width) => [ + 'blocks' => [], + 'id' => Str::uuid(), + 'width' => $width, + ], $columns) + ])->toArray(); + }, + ]; + + $routes[] = [ + 'pattern' => 'layout/paste', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + $value = Layouts::parse($request->get('json')); + $layouts = Layouts::factory($value); + + return $field->pasteLayouts($layouts->toArray()); + } + ]; + + $routes[] = [ + 'pattern' => 'fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function ( + string $fieldName, + string|null $path = null + ) use ($field): array { + $form = $field->attrsForm(); + $field = $form->field($fieldName); + + $fieldApi = $this->clone([ + 'routes' => $field->api(), + 'data' => [ + ...$this->data(), + 'field' => $field + ] + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + ]; + + return $routes; + } + + public function selector(): array|null + { + return $this->selector; + } + + protected function setDefault(mixed $default = null): void + { + // set id for layouts, columns and blocks within layout if not exists + if (is_array($default) === true) { + array_walk($default, function (&$layout) { + $layout['id'] ??= Str::uuid(); + + // set columns id within layout + if (isset($layout['columns']) === true) { + array_walk($layout['columns'], function (&$column) { + $column['id'] ??= Str::uuid(); + + // set blocks id within column + if (isset($column['blocks']) === true) { + array_walk($column['blocks'], function (&$block) { + $block['id'] ??= Str::uuid(); + }); + } + }); + } + }); + } + + parent::setDefault($default); + } + + protected function setLayouts(array $layouts = []): void + { + $this->layouts = array_map( + fn ($layout) => Str::split($layout), + $layouts + ); + } + + /** + * Layout selector's styles such as size (`small`, `medium`, `large` or `huge`) and columns + */ + protected function setSelector(array|null $selector = null): void + { + $this->selector = $selector; + } + + protected function setSettings(array|string|null $settings = null): void + { + if (empty($settings) === true) { + $this->settings = null; + return; + } + + $settings = Blueprint::extend($settings); + + $settings['icon'] = 'dashboard'; + $settings['type'] = 'layout'; + $settings['parent'] = $this->model(); + + $this->settings = Fieldset::factory($settings); + } + + public function settings(): Fieldset|null + { + return $this->settings; + } + + 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 ($value === []) { + return ''; + } + + foreach ($value as $layoutIndex => $layout) { + if ($this->settings !== null) { + $value[$layoutIndex]['attrs'] = $attrs->reset()->fill($layout['attrs'])->toStoredValues(); + } + + foreach ($layout['columns'] as $columnIndex => $column) { + $value[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks'] ?? [], 'content'); + } + } + + return Json::encode($value, pretty: $this->pretty()); + } + + public function validations(): array + { + return [ + 'layout' => function ($value) { + $attrsForm = $this->attrsForm(); + $blockForms = []; + $layoutIndex = 0; + + foreach ($value as $layout) { + $layoutIndex++; + + // validate settings form + $form = $attrsForm->reset()->fill($layout['attrs'] ?? []); + + foreach ($form->fields() as $field) { + $errors = $field->errors(); + + if (count($errors) > 0) { + throw new InvalidArgumentException( + key:'layout.validation.settings', + data: ['index' => $layoutIndex] + ); + } + } + + // validate blocks in the layout + $blockIndex = 0; + + foreach ($layout['columns'] ?? [] as $column) { + foreach ($column['blocks'] ?? [] as $block) { + $blockIndex++; + $blockType = $block['type']; + + 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; + } + } + + // overwrite the content with the serialized form + $form = $blockForms[$blockType]->reset()->fill($block['content']); + + foreach ($form->fields() as $field) { + $errors = $field->errors(); + + // rough first validation + if (count($errors) > 0) { + throw new InvalidArgumentException( + key: 'layout.validation.block', + data: [ + 'blockIndex' => $blockIndex, + 'field' => $field->label(), + 'fieldset' => $fieldset->name(), + 'layoutIndex' => $layoutIndex + ] + ); + } + } + } + } + } + + return true; + } + ]; + } +} diff --git a/public/kirby/src/Form/Field/StatsField.php b/public/kirby/src/Form/Field/StatsField.php new file mode 100644 index 0000000..4d7b8d8 --- /dev/null +++ b/public/kirby/src/Form/Field/StatsField.php @@ -0,0 +1,74 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class StatsField extends FieldClass +{ + /** + * Array or query string for reports. Each report needs a `label` and `value` and can have additional `info`, `link`, `icon` and `theme` settings. + */ + protected array|string $reports; + + /** + * The size of the report cards. Available sizes: `tiny`, `small`, `medium`, `large` + */ + protected string $size; + + /** + * Cache for the Stats UI component + */ + protected Stats $stats; + + public function __construct(array $params) + { + parent::__construct($params); + + $this->reports = $params['reports'] ?? []; + $this->size = $params['size'] ?? 'large'; + } + + public function hasValue(): bool + { + return false; + } + + public function reports(): array + { + return $this->stats()->reports(); + } + + public function size(): string + { + return $this->stats()->size(); + } + + public function stats(): Stats + { + return $this->stats ??= Stats::from( + model: $this->model, + reports: $this->reports, + size: $this->size + ); + } + + public function props(): array + { + return [ + ...parent::props(), + ...$this->stats()->props() + ]; + } +} diff --git a/public/kirby/src/Form/FieldClass.php b/public/kirby/src/Form/FieldClass.php new file mode 100644 index 0000000..6844821 --- /dev/null +++ b/public/kirby/src/Form/FieldClass.php @@ -0,0 +1,215 @@ + + * @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\After; + use Mixin\Api; + use Mixin\Autofocus; + use Mixin\Before; + use Mixin\Help; + use Mixin\Icon; + use Mixin\Label; + use Mixin\Model; + use Mixin\Placeholder; + use Mixin\Translatable; + use Mixin\Validation; + use Mixin\Value; + use Mixin\When; + use Mixin\Width; + + protected bool $disabled; + protected string|null $name; + protected Fields $siblings; + + public function __construct( + protected array $params = [] + ) { + $this->setAfter($params['after'] ?? null); + $this->setAutofocus($params['autofocus'] ?? false); + $this->setBefore($params['before'] ?? null); + $this->setDefault($params['default'] ?? null); + $this->setDisabled($params['disabled'] ?? false); + $this->setHelp($params['help'] ?? null); + $this->setIcon($params['icon'] ?? null); + $this->setLabel($params['label'] ?? null); + $this->setModel($params['model'] ?? null); + $this->setName($params['name'] ?? null); + $this->setPlaceholder($params['placeholder'] ?? null); + $this->setRequired($params['required'] ?? false); + $this->setSiblings($params['siblings'] ?? null); + $this->setTranslate($params['translate'] ?? true); + $this->setWhen($params['when'] ?? null); + $this->setWidth($params['width'] ?? null); + + if (array_key_exists('value', $params) === true) { + $this->fill($params['value']); + } + } + + public function __call(string $param, array $args): mixed + { + if (isset($this->$param) === true) { + return $this->$param; + } + + return $this->params[$param] ?? null; + } + + /** + * Returns optional dialog routes for the field + */ + public function dialogs(): array + { + return []; + } + + /** + * If `true`, the field is no longer editable and will not be saved + */ + public function disabled(): bool + { + return $this->disabled; + } + + /** + * Returns optional drawer routes for the field + */ + public function drawers(): array + { + return []; + } + + protected function i18n(string|array|null $param = null): string|null + { + return empty($param) === false ? I18n::translate($param, $param) : null; + } + + public function id(): string + { + return $this->name(); + } + + public function isDisabled(): bool + { + return $this->disabled; + } + + public function isHidden(): bool + { + return false; + } + + /** + * Returns the field name + */ + public function name(): string + { + return $this->name ?? $this->type(); + } + + /** + * Returns all original params for the field + */ + public function params(): array + { + return $this->params; + } + + /** + * Define the props that will be sent to + * the Vue component + */ + public function props(): array + { + return [ + 'after' => $this->after(), + 'autofocus' => $this->autofocus(), + 'before' => $this->before(), + 'default' => $this->default(), + 'disabled' => $this->isDisabled(), + 'help' => $this->help(), + 'hidden' => $this->isHidden(), + 'icon' => $this->icon(), + 'label' => $this->label(), + 'name' => $this->name(), + 'placeholder' => $this->placeholder(), + 'required' => $this->isRequired(), + 'saveable' => $this->hasValue(), + 'translate' => $this->translate(), + 'type' => $this->type(), + 'when' => $this->when(), + 'width' => $this->width(), + ]; + } + + protected function setDisabled(bool $disabled = false): void + { + $this->disabled = $disabled; + } + + protected function setName(string|null $name = null): void + { + $this->name = strtolower($name ?? $this->type()); + } + + protected function setSiblings(Fields|null $siblings = null): void + { + $this->siblings = $siblings ?? new Fields([$this]); + } + + protected function siblingsCollection(): Fields + { + return $this->siblings; + } + + /** + * Parses a string template in the given value + */ + protected function stringTemplate(string|null $string = null): string|null + { + if ($string !== null) { + return $this->model->toString($string); + } + + return null; + } + + /** + * Converts the field to a plain array + */ + public function toArray(): array + { + $props = $this->props(); + + ksort($props); + + return array_filter($props, fn ($item) => $item !== null); + } + + /** + * Returns the field type + */ + public function type(): string + { + return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class))); + } +} diff --git a/public/kirby/src/Form/Fields.php b/public/kirby/src/Form/Fields.php new file mode 100644 index 0000000..7787f48 --- /dev/null +++ b/public/kirby/src/Form/Fields.php @@ -0,0 +1,432 @@ + + * @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 \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['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 + * included field. + */ + public function toArray(Closure|null $map = null): array + { + return A::map($this->data, $map ?? fn ($field) => $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 $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 new file mode 100644 index 0000000..a75906a --- /dev/null +++ b/public/kirby/src/Form/Form.php @@ -0,0 +1,400 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Form +{ + /** + * Fields in the form + */ + protected Fields $fields; + + /** + * Form constructor + */ + public function __construct( + array $props = [], + array $fields = [], + ModelWithContent|null $model = null, + Language|string|null $language = null + ) { + if ($props !== []) { + $this->legacyConstruct(...$props); + return; + } + + $this->fields = new Fields( + fields: $fields, + model: $model, + language: $language + ); + } + + /** + * 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 + { + return $this->data(false, false); + } + + /** + * Returns data for all fields in the form + * + * @deprecated 5.0.0 Use `::toStoredValues()` instead + */ + public function data($defaults = false, bool $includeNulls = true): array + { + $data = []; + $language = $this->fields->language(); + + foreach ($this->fields as $field) { + if ($field->isStorable($language) === false) { + if ($includeNulls === true) { + $data[$field->name()] = null; + } + + 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 + { + return $this->fields->errors(); + } + + /** + * Get the field object by name + * and handle nested fields correctly + * + * @throws \Kirby\Exception\NotFoundException + */ + public function field(string $name): Field|FieldClass + { + return $this->fields->field($name); + } + + /** + * Returns form fields + */ + 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 = [], + Language|string|null $language = null, + ): static { + if ($props !== []) { + return static::legacyFor( + $model, + ...$props + ); + } + + $form = new static( + fields: $model->blueprint()->fields(), + model: $model, + language: $language + ); + + // fill the form with the latest content of the model + $form->fill(input: $model->content($form->language())->toArray()); + + return $form; + } + + /** + * Checks if the form is invalid + */ + public function isInvalid(): bool + { + return $this->isValid() === false; + } + + /** + * Checks if the form is valid + */ + public function isValid(): bool + { + return $this->fields->errors() === []; + } + + /** + * Returns the language of the form + * + * @since 5.0.0 + */ + public function language(): Language + { + return $this->fields->language(); + } + + /** + * 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(); + } + + $this->fields->passthrough( + values: $values + ); + + return $this; + } + + /** + * 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 + * + * @deprecated 5.0.0 Use `::toStoredValues()` instead + */ + public function strings($defaults = false): array + { + return A::map( + $this->data($defaults), + fn ($value) => match (true) { + is_array($value) => Data::encode($value, 'yaml'), + default => $value + } + ); + } + + /** + * 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->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->fields->toFormValues(); + } +} diff --git a/public/kirby/src/Form/Mixin/After.php b/public/kirby/src/Form/Mixin/After.php new file mode 100644 index 0000000..3199ddc --- /dev/null +++ b/public/kirby/src/Form/Mixin/After.php @@ -0,0 +1,21 @@ +stringTemplate($this->after); + } + + protected function setAfter(array|string|null $after = null): void + { + $this->after = $this->i18n($after); + } +} 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/Autofocus.php b/public/kirby/src/Form/Mixin/Autofocus.php new file mode 100644 index 0000000..2b57688 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Autofocus.php @@ -0,0 +1,21 @@ +autofocus; + } + + protected function setAutofocus(bool $autofocus = false): void + { + $this->autofocus = $autofocus; + } +} diff --git a/public/kirby/src/Form/Mixin/Before.php b/public/kirby/src/Form/Mixin/Before.php new file mode 100644 index 0000000..4335a65 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Before.php @@ -0,0 +1,21 @@ +stringTemplate($this->before); + } + + protected function setBefore(array|string|null $before = null): void + { + $this->before = $this->i18n($before); + } +} diff --git a/public/kirby/src/Form/Mixin/EmptyState.php b/public/kirby/src/Form/Mixin/EmptyState.php new file mode 100644 index 0000000..a553648 --- /dev/null +++ b/public/kirby/src/Form/Mixin/EmptyState.php @@ -0,0 +1,21 @@ +empty = $this->i18n($empty); + } + + public function empty(): string|null + { + return $this->stringTemplate($this->empty); + } +} diff --git a/public/kirby/src/Form/Mixin/Help.php b/public/kirby/src/Form/Mixin/Help.php new file mode 100644 index 0000000..790e9eb --- /dev/null +++ b/public/kirby/src/Form/Mixin/Help.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Help +{ + /** + * Optional help text below the field + */ + protected string|null $help; + + public function help(): string|null + { + if (empty($this->help) === false) { + $help = $this->stringTemplate($this->help); + $help = $this->kirby()->kirbytext($help); + return $help; + } + + return null; + } + + protected function setHelp(array|string|null $help = null): void + { + $this->help = $this->i18n($help); + } +} diff --git a/public/kirby/src/Form/Mixin/Icon.php b/public/kirby/src/Form/Mixin/Icon.php new file mode 100644 index 0000000..b8a5491 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Icon.php @@ -0,0 +1,28 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Icon +{ + /** + * Optional icon that will be shown at the end of the field + */ + protected string|null $icon; + + public function icon(): string|null + { + return $this->icon; + } + + protected function setIcon(string|null $icon = null): void + { + $this->icon = $icon; + } +} diff --git a/public/kirby/src/Form/Mixin/Label.php b/public/kirby/src/Form/Mixin/Label.php new file mode 100644 index 0000000..3691459 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Label.php @@ -0,0 +1,32 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Label +{ + /** + * The field label can be set as string or associative array with translations + */ + protected string|null $label; + + public function label(): string|null + { + return $this->stringTemplate( + $this->label ?? Str::ucfirst($this->name()) + ); + } + + protected function setLabel(array|string|null $label = null): void + { + $this->label = $this->i18n($label); + } +} diff --git a/public/kirby/src/Form/Mixin/Max.php b/public/kirby/src/Form/Mixin/Max.php new file mode 100644 index 0000000..07706a3 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Max.php @@ -0,0 +1,21 @@ +max; + } + + protected function setMax(int|null $max = null) + { + $this->max = $max; + } +} diff --git a/public/kirby/src/Form/Mixin/Min.php b/public/kirby/src/Form/Mixin/Min.php new file mode 100644 index 0000000..ba875c4 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Min.php @@ -0,0 +1,36 @@ +required === true) { + return $this->min ?? 1; + } + + return $this->min; + } + + protected function setMin(int|null $min = null) + { + $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/Placeholder.php b/public/kirby/src/Form/Mixin/Placeholder.php new file mode 100644 index 0000000..0692627 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Placeholder.php @@ -0,0 +1,30 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Placeholder +{ + /** + * Optional placeholder value that will be shown when the field is empty + */ + protected array|string|null $placeholder; + + public function placeholder(): string|null + { + return $this->stringTemplate( + $this->placeholder + ); + } + + protected function setPlaceholder(array|string|null $placeholder = null): void + { + $this->placeholder = $this->i18n($placeholder); + } +} diff --git a/public/kirby/src/Form/Mixin/Translatable.php b/public/kirby/src/Form/Mixin/Translatable.php new file mode 100644 index 0000000..a71a6fd --- /dev/null +++ b/public/kirby/src/Form/Mixin/Translatable.php @@ -0,0 +1,44 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Translatable +{ + /** + * Should the field be 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; + } + + protected function setTranslate(bool $translate = true): void + { + $this->translate = $translate; + } + + 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..58e9ad9 --- /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 +{ + /** + * If `true`, the field has to be filled in correctly to be saved. + */ + 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() === []; + } + + 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..5da8567 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Value.php @@ -0,0 +1,219 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Value +{ + /** + * Default value for the field, which will be used when a page/file/user is created + */ + protected mixed $default = null; + + /** + * The value of the field + */ + protected mixed $value = null; + + /** + * @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..f0952a6 --- /dev/null +++ b/public/kirby/src/Form/Mixin/When.php @@ -0,0 +1,57 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait When +{ + /** + * Conditions when the field will be shown + * + * @since 3.1.0 + */ + 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; + } + + protected function setWhen(array|null $when = null): void + { + $this->when = $when; + } + + public function when(): array|null + { + return $this->when; + } +} diff --git a/public/kirby/src/Form/Mixin/Width.php b/public/kirby/src/Form/Mixin/Width.php new file mode 100644 index 0000000..6366ea6 --- /dev/null +++ b/public/kirby/src/Form/Mixin/Width.php @@ -0,0 +1,29 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Width +{ + /** + * The width of the field in the field grid. + * Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4` + */ + protected string|null $width; + + protected function setWidth(string|null $width = null): void + { + $this->width = $width; + } + + public function width(): string + { + return $this->width ?? '1/1'; + } +} diff --git a/public/kirby/src/Form/Validations.php b/public/kirby/src/Form/Validations.php new file mode 100644 index 0000000..80b7737 --- /dev/null +++ b/public/kirby/src/Form/Validations.php @@ -0,0 +1,279 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Validations +{ + /** + * Validates if the field value is boolean + * + * @param \Kirby\Form\Field|\Kirby\Form\FieldClass $field + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function boolean($field, $value): bool + { + if ($field->isEmptyValue($value) === false) { + if (is_bool($value) === false) { + throw new InvalidArgumentException( + key: 'validation.boolean' + ); + } + } + + return true; + } + + /** + * Validates if the field value is valid date + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function date(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmptyValue($value) === false) { + if (V::date($value) !== true) { + throw new InvalidArgumentException( + message: V::message('date', $value) + ); + } + } + + return true; + } + + /** + * Validates if the field value is valid email + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function email(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmptyValue($value) === false) { + if (V::email($value) === false) { + throw new InvalidArgumentException( + message: V::message('email', $value) + ); + } + } + + return true; + } + + /** + * Validates if the field value is maximum + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function max(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmptyValue($value) === false && + $field->max() !== null + ) { + if (V::max($value, $field->max()) === false) { + throw new InvalidArgumentException( + message: V::message('max', $value, $field->max()) + ); + } + } + + return true; + } + + /** + * Validates if the field value is max length + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function maxlength(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmptyValue($value) === false && + $field->maxlength() !== null + ) { + if (V::maxLength($value, $field->maxlength()) === false) { + throw new InvalidArgumentException( + message: V::message('maxlength', $value, $field->maxlength()) + ); + } + } + + return true; + } + + /** + * Validates if the field value is minimum + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function min(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmptyValue($value) === false && + $field->min() !== null + ) { + if (V::min($value, $field->min()) === false) { + throw new InvalidArgumentException( + message: V::message('min', $value, $field->min()) + ); + } + } + + return true; + } + + /** + * Validates if the field value is min length + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function minlength(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmptyValue($value) === false && + $field->minlength() !== null + ) { + if (V::minLength($value, $field->minlength()) === false) { + throw new InvalidArgumentException( + message: V::message('minlength', $value, $field->minlength()) + ); + } + } + + return true; + } + + /** + * Validates if the field value matches defined pattern + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function pattern(Field|FieldClass $field, mixed $value): bool + { + 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 + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern#overview + $pattern = '^(?:' . $pattern . ')$'; + + if (V::match($value, '/' . $pattern . '/i') === false) { + throw new InvalidArgumentException( + message: V::message('match') + ); + } + } + } + + return true; + } + + /** + * Validates if the field value is required + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function required(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->hasValue() === true && + $field->isRequired() === true && + $field->isEmptyValue($value) === true + ) { + throw new InvalidArgumentException( + key: 'validation.required' + ); + } + + return true; + } + + /** + * Validates if the field value is in defined options + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function option(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmptyValue($value) === false) { + $values = array_column($field->options(), 'value'); + + if (in_array($value, $values, true) !== true) { + throw new InvalidArgumentException( + key: 'validation.option' + ); + } + } + + return true; + } + + /** + * Validates if the field values is in defined options + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function options(Field|FieldClass $field, mixed $value): bool + { + 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' + ); + } + } + } + + return true; + } + + /** + * Validates if the field value is valid time + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function time(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmptyValue($value) === false) { + if (V::time($value) !== true) { + throw new InvalidArgumentException( + message: V::message('time', $value) + ); + } + } + + return true; + } + + /** + * Validates if the field value is valid url + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function url(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmptyValue($value) === false) { + if (V::url($value) === false) { + throw new InvalidArgumentException( + message: V::message('url', $value) + ); + } + } + + return true; + } +} diff --git a/public/kirby/src/Http/Cookie.php b/public/kirby/src/Http/Cookie.php new file mode 100644 index 0000000..fa0c010 --- /dev/null +++ b/public/kirby/src/Http/Cookie.php @@ -0,0 +1,227 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Cookie +{ + /** + * Key to use for cookie signing + */ + public static string $key = 'KirbyHttpCookieKey'; + + /** + * Set a new cookie + * + * ```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 + * @param array $options Array of options: + * lifetime, path, domain, secure, httpOnly, sameSite + * @return bool true: cookie was created, + * false: cookie creation failed + */ + public static function set( + string $key, + string $value, + array $options = [] + ): bool { + // modify CMS caching behavior + static::trackUsage($key); + + // extract options + $expires = static::lifetime($options['lifetime'] ?? 0); + $path = $options['path'] ?? '/'; + $domain = $options['domain'] ?? null; + $secure = $options['secure'] ?? false; + $httponly = $options['httpOnly'] ?? true; + $samesite = $options['sameSite'] ?? 'Lax'; + + // add an HMAC signature of the value + $value = static::hmac($value) . '+' . $value; + + // store that thing in the cookie global + $_COOKIE[$key] = $value; + + // store the cookie + return setcookie( + $key, + $value, + compact('expires', 'path', 'domain', 'secure', 'httponly', 'samesite') + ); + } + + /** + * Calculates the lifetime for a cookie + * + * @param int $minutes Number of minutes or timestamp + */ + public static function lifetime(int $minutes): int + { + // absolute timestamp + if ($minutes > 1000000000) { + return $minutes; + } + + // minutes from now + if ($minutes > 0) { + return time() + ($minutes * 60); + } + + return 0; + } + + /** + * Stores a cookie forever + * + * ```php + * // never expires + * Cookie::forever('mycookie', 'hello'); + * ``` + * + * @param string $key The name of the cookie + * @param string $value The cookie content + * @param array $options Array of options: + * path, domain, secure, httpOnly + * @return bool true: cookie was created, + * false: cookie creation failed + */ + public static function forever( + string $key, + string $value, + array $options = [] + ): bool { + // 9999-12-31 if supported (lower on 32-bit servers) + $options['lifetime'] = min(253402214400, PHP_INT_MAX); + return static::set($key, $value, $options); + } + + /** + * Get a cookie value + * + * ```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 + * if the cookie has not been found + * @return string|array|null The found value + */ + public static function get( + string|null $key = null, + string|null $default = null + ): string|array|null { + if ($key === null) { + return $_COOKIE; + } + + // modify CMS caching behavior + static::trackUsage($key); + + if ($value = $_COOKIE[$key] ?? null) { + return static::parse($value); + } + + return $default; + } + + /** + * Checks if a cookie exists + */ + public static function exists(string $key): bool + { + return static::get($key) !== null; + } + + /** + * Creates a HMAC for the cookie value + * Used as a cookie signature to prevent easy tampering with cookie data + */ + protected static function hmac(string $value): string + { + return hash_hmac('sha1', $value, static::$key); + } + + /** + * Parses the hashed value from a cookie + * and tries to extract the value + */ + protected static function parse(string $string): string|null + { + // if no hash-value separator is present, we can't parse the value + if (str_contains($string, '+') === false) { + return null; + } + + // extract hash and value + $hash = Str::before($string, '+'); + $value = Str::after($string, '+'); + + // if the hash or the value is missing at all return null + // $value can be an empty string, $hash can't be! + if ($hash === '') { + return null; + } + + // compare the extracted hash with the hashed value + // don't accept value if the hash is invalid + if (hash_equals(static::hmac($value), $hash) !== true) { + return null; + } + + return $value; + } + + /** + * Remove a cookie + * + * ```php + * // mycookie is now gone + * Cookie::remove('mycookie'); + * ``` + * + * @param string $key The name of the cookie + * @return bool true: the cookie has been removed, + * false: the cookie could not be removed + */ + public static function remove(string $key): bool + { + if (isset($_COOKIE[$key]) === true) { + unset($_COOKIE[$key]); + return setcookie($key, '', 1, '/') && setcookie($key, false); + } + + return false; + } + + /** + * Tells the CMS responder that the response relies on a cookie and + * its value (even if the cookie isn't set in the current request); + * this ensures that the response is only cached for visitors who don't + * have this cookie set; + * https://github.com/getkirby/kirby/issues/4423#issuecomment-1166300526 + */ + protected static function trackUsage(string $key): void + { + // lazily request the instance for non-CMS use cases + App::instance(lazy: true)?->response()->usesCookie($key); + } +} diff --git a/public/kirby/src/Http/Environment.php b/public/kirby/src/Http/Environment.php new file mode 100644 index 0000000..1672f75 --- /dev/null +++ b/public/kirby/src/Http/Environment.php @@ -0,0 +1,1022 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * @since 3.7.0 + */ +class Environment +{ + /** + * Full base URL object + */ + protected Uri $baseUri; + + /** + * Full base URL + */ + protected string $baseUrl; + + /** + * Whether the request is being served by the CLI + */ + protected bool $cli; + + /** + * Current host name + */ + protected string|null $host; + + /** + * Whether the HTTPS protocol is used + */ + protected bool $https; + + /** + * Sanitized `$_SERVER` data + */ + protected array $info; + + /** + * Current server's IP address + */ + protected string|null $ip; + + /** + * Whether the site is behind a reverse proxy; + * `null` if not known (fixed allowed URL setup) + */ + protected bool|null $isBehindProxy; + + /** + * URI path to the base + */ + protected string $path; + + /** + * Port number in the site URL + */ + protected int|null $port; + + /** + * Intermediary value of the port + * extracted from the host name + */ + protected int|null $portInHost = null; + + /** + * Uri object for the full request URI. + * It is a combination of the base URL and `REQUEST_URI` + */ + protected Uri $requestUri; + + /** + * Full request URL + */ + protected string $requestUrl; + + /** + * Path to the php script within the + * document root without the + * filename of the script + */ + protected string $scriptPath; + + /** + * Class constructor + * + * @param array|null $info Optional override for `$_SERVER` + */ + public function __construct( + array|null $options = null, + array|null $info = null + ) { + $this->detect($options, $info); + } + + /** + * Returns the server's IP address + * @see self::ip() + */ + public function address(): string|null + { + return $this->ip(); + } + + /** + * Returns the full base URL object + */ + public function baseUri(): Uri + { + return $this->baseUri; + } + + /** + * Returns the full base URL + */ + public function baseUrl(): string + { + return $this->baseUrl; + } + + /** + * Checks if the request is being served by the CLI + */ + public function cli(): bool + { + return $this->cli; + } + + /** + * Sanitizes the server info and detects + * all relevant parts. This can be called + * again at a later point to overwrite all + * the stored information and re-detect the + * environment if necessary. + * + * @param array|null $info Optional override for `$_SERVER` + */ + public function detect( + array|null $options = null, + array|null $info = null + ): array { + $options = [ + 'cli' => null, + 'allowed' => null, + ...$options ?? [] + ]; + + $info ??= $_SERVER; + + $this->info = static::sanitize($info); + $this->cli = $this->detectCli($options['cli']); + $this->ip = $this->detectIp(); + $this->host = null; + $this->https = false; + $this->isBehindProxy = null; + $this->scriptPath = $this->detectScriptPath($this->get('SCRIPT_NAME')); + $this->path = $this->detectPath($this->scriptPath); + $this->port = null; + + // insecure auto-detection + if ($options['allowed'] === '*' || $options['allowed'] === ['*']) { + $this->detectAuto(true); + + // fixed environments + } elseif (empty($options['allowed']) === false) { + $this->detectAllowed($options['allowed']); + + // secure auto-detection + } else { + $this->detectAuto(); + } + + // build the URI based on the detected params + $this->detectBaseUri(); + + // build the request URI based on the detected URL + $this->detectRequestUri($this->get('REQUEST_URI')); + + // return the sanitized $_SERVER array + return $this->info; + } + + /** + * Sets the host name, port, path and protocol from the + * fixed list of allowed URLs + */ + protected function detectAllowed(array|string $allowed): void + { + $allowed = A::wrap($allowed); + + // with a single allowed URL, the entire + // environment will be based on that + if (count($allowed) === 1) { + $baseUrl = A::first($allowed); + + if (is_string($baseUrl) === false) { + throw new InvalidArgumentException( + message: 'Invalid allow list setup for base URLs' + ); + } + + $uri = new Uri($baseUrl, ['slash' => false]); + + $this->host = $uri->host(); + $this->https = $uri->https(); + $this->port = $uri->port(); + $this->path = $uri->path()->toString(); + return; + } + + // run insecure auto detection to get + // host, port and https from the environment; + // security is achieved by checking against + // the fixed allowlist below + $this->detectAuto(true); + + // build the baseUrl based on the detected environment + // to compare it against what is allowed + $this->detectBaseUri(); + + foreach ($allowed as $url) { + // skip invalid URLs + if (is_string($url) === false) { + continue; + } + + $uri = new Uri($url, ['slash' => false]); + + // the current environment is allowed, + // stop before the exception below is thrown + if ($uri->toString() === $this->baseUrl) { + return; + } + } + + throw new InvalidArgumentException( + message: 'The environment is not allowed' + ); + } + + /** + * Sets the host name, port and protocol without configuration + * + * @param bool $insecure Include the `Host`, `Forwarded` and `X-Forwarded-*` headers in the search + */ + protected function detectAuto(bool $insecure = false): void + { + // proxy server setup + if ($insecure === true) { + $forwarded = $this->detectForwarded(); + + $host = $forwarded['host']; + $port = $forwarded['port']; + $https = $forwarded['https']; + + if ($host || $port || $https) { + $this->isBehindProxy = true; + + // if a port or scheme is defined but no host, assume + // that the host is the same as PHP's own hostname + // (which is often the case with reverse proxies) + $this->host = $host ?? $this->detectHost($insecure); + $this->port = $port; + $this->https = $https; + + return; + } + } + + // local server setup + $this->isBehindProxy = false; + + $this->host = $this->detectHost($insecure); + $this->https = $this->detectHttps(); + $this->port = $this->detectPort(); + } + + /** + * Builds the base URL based on the + * given environment params + */ + protected function detectBaseUri(): Uri + { + $this->baseUri = new Uri([ + 'host' => $this->host, + 'path' => $this->path, + 'port' => $this->port, + 'scheme' => $this->https ? 'https' : 'http', + ]); + + $this->baseUrl = $this->baseUri->toString(); + + return $this->baseUri; + } + + /** + * Detects if the request is served by the CLI + * + * @param bool|null $override Set to a boolean to override detection (for testing) + */ + protected function detectCli(bool|null $override = null): bool + { + if (is_bool($override) === true) { + return $override; + } + + if (defined('STDIN') === true) { + return true; + } + + // @codeCoverageIgnoreStart + $sapi = php_sapi_name(); + if ($sapi === 'cli') { + return true; + } + + $term = getenv('TERM'); + + if ( + str_starts_with($sapi, 'cgi') === true && + $term && + $term !== 'unknown' + ) { + return true; + } + + return false; + // @codeCoverageIgnoreEnd + } + + /** + * Detects the host, protocol, port and client IP + * from the `Forwarded` and `X-Forwarded-*` headers + */ + protected function detectForwarded(): array + { + $data = [ + 'for' => null, + 'host' => null, + 'https' => false, + 'port' => null + ]; + + // prefer the standardized `Forwarded` header if defined + if ($forwarded = $this->get('HTTP_FORWARDED')) { + // only use the first (outermost) proxy by using the first set of values + // before the first comma (but only a comma outside of quotes) + if (Str::contains($forwarded, ',') === true) { + $forwarded = preg_split('/"[^"]*"(*SKIP)(*F)|,/', $forwarded)[0]; + } + + // split into separate key=value;key=value fields by semicolon, + // but only split outside of quotes + $rawFields = preg_split('/"[^"]*"(*SKIP)(*F)|;/', $forwarded); + + // split key and value into an associative array + $fields = []; + foreach ($rawFields as $field) { + $key = Str::lower(Str::before($field, '=')); + $value = Str::after($field, '='); + + // trim the surrounding quotes + if (Str::substr($value, 0, 1) === '"') { + $value = Str::substr($value, 1, -1); + } + + $fields[$key] = $value; + } + + // assemble the normalized data + if (isset($fields['host']) === true) { + $parts = $this->detectPortInHost($fields['host']); + $data['host'] = $parts['host']; + $data['port'] = $parts['port']; + } + + if (isset($fields['proto']) === true) { + $data['https'] = $this->detectHttpsProtocol($fields['proto']); + } + + if ($data['https'] === true) { + $data['port'] ??= 443; + } + + $data['for'] = $parts['for'] ?? null; + + return $data; + } + + // no success, try the `X-Forwarded-*` headers + $data['host'] = $this->detectForwardedHost(); + $data['https'] = $this->detectForwardedHttps(); + $data['port'] = $this->detectForwardedPort($data['https']); + $data['for'] = $this->get('HTTP_X_FORWARDED_FOR'); + + return $data; + } + + /** + * Detects the host name of the reverse proxy + * from the `X-Forwarded-Host` header + */ + protected function detectForwardedHost(): string|null + { + $host = $this->get('HTTP_X_FORWARDED_HOST'); + $parts = $this->detectPortInHost($host); + + $this->portInHost = $parts['port']; + + return $parts['host']; + } + + /** + * Detects the protocol of the reverse proxy from the + * `X-Forwarded-SSL` or `X-Forwarded-Proto` header + */ + protected function detectForwardedHttps(): bool + { + if ($this->detectHttpsOn($this->get('HTTP_X_FORWARDED_SSL')) === true) { + return true; + } + + if ($this->detectHttpsProtocol($this->get('HTTP_X_FORWARDED_PROTO')) === true) { + return true; + } + + return false; + } + + /** + * Detects the port of the reverse proxy from the + * `X-Forwarded-Host` or `X-Forwarded-Port` header + * + * @param bool $https Whether HTTPS was detected + */ + protected function detectForwardedPort(bool $https): int|null + { + // based on forwarded port + $port = $this->get('HTTP_X_FORWARDED_PORT'); + + if (is_int($port) === true) { + return $port; + } + + // based on forwarded host + if (is_int($this->portInHost) === true) { + return $this->portInHost; + } + + // based on the detected https state + if ($https === true) { + return 443; + } + + return null; + } + + /** + * Detects the host name from various headers + * + * @param bool $insecure Include the `Host` header in the search + */ + protected function detectHost(bool $insecure = false): string|null + { + $hosts = []; + + if ($insecure === true) { + $hosts[] = $this->get('HTTP_HOST'); + } + + $hosts[] = $this->get('SERVER_NAME'); + $hosts[] = $this->get('SERVER_ADDR'); + + // use the first header that is not empty + $hosts = array_filter($hosts); + $host = A::first($hosts); + + $parts = $this->detectPortInHost($host); + + $this->portInHost = $parts['port']; + + return $parts['host']; + } + + /** + * Detects the HTTPS status + */ + protected function detectHttps(): bool + { + if ($this->detectHttpsOn($this->get('HTTPS')) === true) { + return true; + } + + return false; + } + + /** + * Normalizes the HTTPS status into a boolean + */ + protected function detectHttpsOn(string|int|bool|null $value): bool + { + // off can mean many things :) + $off = ['off', null, '', 0, '0', false, 'false', -1, '-1']; + + return in_array($value, $off, true) === false; + } + + /** + * Detects the HTTPS status from a `X-Forwarded-Proto` string + */ + protected function detectHttpsProtocol(string|null $protocol = null): bool + { + if ($protocol === null) { + return false; + } + + $protocols = ['https', 'https, http']; + + return in_array(strtolower($protocol), $protocols, true) === true; + } + + /** + * Detects the server's IP address + */ + protected function detectIp(): string|null + { + return $this->get('SERVER_ADDR'); + } + + /** + * Detects the URI path unless in CLI mode + */ + protected function detectPath(string|null $path = null): string + { + if ($this->cli === true) { + return ''; + } + + return $path ?? ''; + } + + /** + * Detects the port from various sources + */ + protected function detectPort(): int|null + { + // based on server port + $port = $this->get('SERVER_PORT'); + + if (is_int($port) === true) { + return $port; + } + + // based on the detected host + if (is_int($this->portInHost) === true) { + return $this->portInHost; + } + + // based on the detected https state + if ($this->https === true) { + return 443; + } + + return null; + } + + /** + * Splits a hostname:port string into its components + */ + protected function detectPortInHost(string|null $host = null): array + { + if (empty($host) === true) { + return [ + 'host' => null, + 'port' => null + ]; + } + + $parts = Str::split($host, ':'); + + return [ + 'host' => $parts[0] ?? null, + 'port' => static::sanitizePort($parts[1] ?? null), + ]; + } + + /** + * Splits any URI into path and query + */ + protected function detectRequestUri(string|null $requestUri = null): Uri + { + $uri = new Uri($requestUri ?? ''); + + // create the URI object as a combination of base uri parts + // and the parts from REQUEST_URI + $this->requestUri = $this->baseUri()->clone([ + 'fragment' => $uri->fragment(), + 'params' => $uri->params(), + 'path' => $uri->path(), + 'query' => $uri->query() + ]); + + // build the full request URL + $this->requestUrl = $this->requestUri->toString(); + + return $this->requestUri; + } + + /** + * Returns the sanitized script path unless in CLI mode + */ + protected function detectScriptPath(string|null $scriptPath = null): string + { + if ($this->cli === true) { + return ''; + } + + return $this->sanitizeScriptPath($scriptPath); + } + + /** + * Gets a value from the server environment array + * + * ```php + * // sample output: /var/www/kirby + * $server->get('document_root'); + * + * // 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. + * @param mixed $default Optional default value, which should be + * returned if no element has been found + */ + public function get(string|false|null $key = null, $default = null) + { + if (is_string($key) === false) { + return $this->info; + } + + if (isset($this->info[$key]) === false) { + $key = strtoupper($key); + } + + return $this->info[$key] ?? static::sanitize($key, $default); + } + + /** + * Gets a value from the global server environment array + * of the current app instance; falls back to `$_SERVER` if + * no app instance is running + * + * @param string|false|null $key The key to look for. Pass `false` or `null` + * to return the entire server array. + * @param mixed $default Optional default value, which should be + * returned if no element has been found + */ + public static function getGlobally( + string|false|null $key = null, + $default = null + ) { + // first try the global `Environment` object if the CMS is running + if ($app = App::instance(null, true)) { + return $app->environment()->get($key, $default); + } + + if (is_string($key) === false) { + return static::sanitize($_SERVER); + } + + if (isset($_SERVER[$key]) === false) { + $key = strtoupper($key); + } + + return static::sanitize($key, $_SERVER[$key] ?? $default); + } + + /** + * Returns the current host name + */ + public function host(): string|null + { + return $this->host; + } + + /** + * Returns whether the HTTPS protocol is used + */ + public function https(): bool + { + return $this->https; + } + + /** + * Returns the sanitized `$_SERVER` array + */ + public function info(): array + { + return $this->info; + } + + /** + * Returns the server's IP address + */ + public function ip(): string|null + { + return $this->ip; + } + + /** + * Returns if the server is behind a + * reverse proxy server + */ + public function isBehindProxy(): bool|null + { + return $this->isBehindProxy; + } + + /** + * Checks if this is a local installation; + * returns `false` if in doubt + */ + public function isLocal(): bool + { + // check host + $host = $this->host(); + + if ($host === 'localhost') { + return true; + } + + if (Str::endsWith($host, '.local') === true) { + return true; + } + + if (Str::endsWith($host, '.test') === true) { + return true; + } + + if (Str::endsWith($host, '.ddev.site') === true) { + return true; + } + + // collect all possible visitor ips + $ips = [ + $this->get('REMOTE_ADDR'), + $this->get('HTTP_X_FORWARDED_FOR'), + $this->get('HTTP_CLIENT_IP') + ]; + + if ($this->get('HTTP_FORWARDED')) { + $ips[] = $this->detectForwarded()['for']; + } + + // remove duplicates and empty ips + $ips = array_unique(array_filter($ips)); + + // no known ip? Better not assume it's local + 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'], true) === false) { + return false; + } + } + + return true; + } + + /** + * Loads and returns options from environment-specific + * PHP files (by host name and server IP address or CLI) + * + * @param string $root Root directory to load configs from + */ + public function options(string $root): array + { + $configCli = []; + $configHost = []; + $configAddr = []; + + $host = $this->host(); + $addr = $this->ip(); + + // load the config for the cli + if ($this->cli() === true) { + $configCli = F::load( + file: $root . '/config.cli.php', + fallback: [], + allowOutput: false + ); + } + + // load the config for the host + if ( + empty($host) === false && + F::exists($path = $root . '/config.' . $host . '.php', $root) === true + ) { + $configHost = F::load( + file: $path, + fallback: [], + allowOutput: false + ); + } + + // load the config for the server IP + if ( + empty($addr) === false && + F::exists($path = $root . '/config.' . $addr . '.php', $root) === true + ) { + $configAddr = F::load( + file: $path, + fallback: [], + allowOutput: false + ); + } + + return array_replace_recursive($configCli, $configHost, $configAddr); + } + + /** + * Returns the detected path + */ + public function path(): string|null + { + return $this->path; + } + + /** + * Returns the correct port number + */ + public function port(): int|null + { + return $this->port; + } + + /** + * Returns an URI object for the requested URL + */ + public function requestUri(): Uri + { + return $this->requestUri; + } + + /** + * Returns the current URL, including the request path + * and query + */ + public function requestUrl(): string + { + return $this->requestUrl; + } + + /** + * Sanitizes some `$_SERVER` keys + */ + public static function sanitize( + string|array $key, + $value = null + ) { + if (is_array($key) === true) { + foreach ($key as $k => $v) { + $key[$k] = static::sanitize($k, $v); + } + + return $key; + } + + return match ($key) { + 'SERVER_ADDR', + 'SERVER_NAME', + 'HTTP_HOST', + 'HTTP_X_FORWARDED_HOST' => static::sanitizeHost($value), + + 'SERVER_PORT', + 'HTTP_X_FORWARDED_PORT' => static::sanitizePort($value), + + default => $value + }; + } + + /** + * Sanitizes the given host name + */ + protected static function sanitizeHost( + string|null $host = null + ): string|null { + if (empty($host) === true) { + return null; + } + + $host = Str::lower($host); + $host = strip_tags($host); + $host = basename($host); + $host = preg_replace('![^\w.:-]+!iu', '', $host); + $host = htmlspecialchars($host, ENT_COMPAT); + $host = trim($host, '-'); + $host = trim($host, '.'); + $host = trim($host); + + if ($host === '') { + return null; + } + + return $host; + } + + /** + * Sanitizes the given port number + */ + protected static function sanitizePort( + string|int|false|null $port = null + ): int|null { + // already fine + if (is_int($port) === true) { + return $port; + } + + // no port given + if ($port === null || $port === false || $port === '') { + return null; + } + + // remove any character that is not an integer + $port = preg_replace('![^0-9]+!', '', $port); + + // no port + if ($port === '') { + return null; + } + + // convert to integer + return (int)$port; + } + + /** + * Sanitizes the given script path + */ + protected function sanitizeScriptPath(string|null $scriptPath = null): string + { + $scriptPath ??= ''; + $scriptPath = trim($scriptPath); + + // skip all the sanitizing steps if the path is empty + if ($scriptPath === '') { + return $scriptPath; + } + + // replace Windows backslashes + $scriptPath = str_replace('\\', '/', $scriptPath); + // remove the script + $scriptPath = dirname($scriptPath); + // replace those fucking backslashes again + $scriptPath = str_replace('\\', '/', $scriptPath); + // remove the leading and trailing slashes + $scriptPath = trim($scriptPath, '/'); + + // top-level scripts don't have a path + // and dirname() will return '.' + if ($scriptPath === '.') { + return ''; + } + + return $scriptPath; + } + + /** + * Returns the path to the php script + * within the document root without the + * filename of the script. + * + * i.e. /subfolder/index.php -> subfolder + * + * This can be used to build the base baseUrl + * for subfolder installations + */ + public function scriptPath(): string + { + return $this->scriptPath; + } + + /** + * Returns all environment data as array + */ + public function toArray(): array + { + return [ + 'baseUrl' => $this->baseUrl, + 'host' => $this->host, + 'https' => $this->https, + 'info' => $this->info, + 'ip' => $this->ip, + 'isBehindProxy' => $this->isBehindProxy, + 'path' => $this->path, + 'port' => $this->port, + 'requestUrl' => $this->requestUrl, + 'scriptPath' => $this->scriptPath, + ]; + } +} diff --git a/public/kirby/src/Http/Exceptions/NextRouteException.php b/public/kirby/src/Http/Exceptions/NextRouteException.php new file mode 100644 index 0000000..d6bd3f9 --- /dev/null +++ b/public/kirby/src/Http/Exceptions/NextRouteException.php @@ -0,0 +1,16 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class NextRouteException extends \Exception +{ +} diff --git a/public/kirby/src/Http/Header.php b/public/kirby/src/Http/Header.php new file mode 100644 index 0000000..bc9d6f6 --- /dev/null +++ b/public/kirby/src/Http/Header.php @@ -0,0 +1,312 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Header +{ + // configuration + public static array $codes = [ + // successful + '_200' => 'OK', + '_201' => 'Created', + '_202' => 'Accepted', + + // redirection + '_300' => 'Multiple Choices', + '_301' => 'Moved Permanently', + '_302' => 'Found', + '_303' => 'See Other', + '_304' => 'Not Modified', + '_307' => 'Temporary Redirect', + '_308' => 'Permanent Redirect', + + // client error + '_400' => 'Bad Request', + '_401' => 'Unauthorized', + '_402' => 'Payment Required', + '_403' => 'Forbidden', + '_404' => 'Not Found', + '_405' => 'Method Not Allowed', + '_406' => 'Not Acceptable', + '_410' => 'Gone', + '_418' => 'I\'m a teapot', + '_451' => 'Unavailable For Legal Reasons', + + // server error + '_500' => 'Internal Server Error', + '_501' => 'Not Implemented', + '_502' => 'Bad Gateway', + '_503' => 'Service Unavailable', + '_504' => 'Gateway Time-out' + ]; + + /** + * Sends a content type header + * + * @return string|void + */ + public static function contentType( + string $mime, + string $charset = 'UTF-8', + bool $send = true + ) { + if ($found = F::extensionToMime($mime)) { + $mime = $found; + } + + $header = 'Content-type: ' . $mime; + + if ($charset !== '') { + $header .= '; charset=' . $charset; + } + + if ($send === false) { + return $header; + } + + header($header); + } + + /** + * Creates headers by key and value + */ + public static function create( + string|array $key, + string|null $value = null + ): string { + if (is_array($key) === true) { + $headers = []; + + foreach ($key as $k => $v) { + $headers[] = static::create($k, $v); + } + + return implode("\r\n", $headers); + } + + // prevent header injection by stripping + // any newline characters from single headers + return str_replace(["\r", "\n"], '', $key . ': ' . $value); + } + + /** + * Shortcut for static::contentType() + * + * @return string|void + */ + public static function type( + string $mime, + string $charset = 'UTF-8', + bool $send = true + ) { + return static::contentType($mime, $charset, $send); + } + + /** + * Sends a status header + * + * Checks $code against a list of known status codes. To bypass this check + * and send a custom status code and message, use a $code string formatted + * as 3 digits followed by a space and a message, e.g. '999 Custom Status'. + * + * @param int|string|null $code The HTTP status code + * @param bool $send If set to false the header will be returned instead + * @return string|void + * @psalm-return ($send is false ? string : void) + */ + public static function status( + int|string|null $code = null, + bool $send = true + ) { + $codes = static::$codes; + $protocol = Environment::getGlobally('SERVER_PROTOCOL', 'HTTP/1.1'); + + // allow full control over code and message + if ( + is_string($code) === true && + preg_match('/^\d{3} \w.+$/', $code) === 1 + ) { + $message = substr(rtrim($code), 4); + $code = substr($code, 0, 3); + } else { + if (array_key_exists('_' . $code, $codes) === false) { + $code = 500; + } + + $message = $codes['_' . $code] ?? 'Something went wrong'; + } + + $header = $protocol . ' ' . $code . ' ' . $message; + + if ($send === false) { + return $header; + } + + // try to send the header + header($header); + } + + /** + * Sends a 200 header + * + * @return string|void + */ + public static function success(bool $send = true) + { + return static::status(200, $send); + } + + /** + * Sends a 201 header + * + * @return string|void + */ + public static function created(bool $send = true) + { + return static::status(201, $send); + } + + /** + * Sends a 202 header + * + * @return string|void + */ + public static function accepted(bool $send = true) + { + return static::status(202, $send); + } + + /** + * Sends a 400 header + * + * @return string|void + */ + public static function error(bool $send = true) + { + return static::status(400, $send); + } + + /** + * Sends a 403 header + * + * @return string|void + */ + public static function forbidden(bool $send = true) + { + return static::status(403, $send); + } + + /** + * Sends a 404 header + * + * @return string|void + */ + public static function notfound(bool $send = true) + { + return static::status(404, $send); + } + + /** + * Sends a 404 header + * + * @return string|void + */ + public static function missing(bool $send = true) + { + return static::status(404, $send); + } + + /** + * Sends a 410 header + * + * @return string|void + */ + public static function gone(bool $send = true) + { + return static::status(410, $send); + } + + /** + * Sends a 500 header + * + * @return string|void + */ + public static function panic(bool $send = true) + { + return static::status(500, $send); + } + + /** + * Sends a 503 header + * + * @return string|void + */ + public static function unavailable(bool $send = true) + { + return static::status(503, $send); + } + + /** + * Sends a redirect header + * + * @return string|void + */ + public static function redirect( + string $url, + int $code = 302, + bool $send = true + ) { + $status = static::status($code, false); + $location = 'Location:' . Url::unIdn($url); + + if ($send !== true) { + return $status . "\r\n" . $location; + } + + header($status); + header($location); + exit(); + } + + /** + * Sends download headers for anything that is downloadable + * + * @param array $params Check out the defaults array for available parameters + */ + public static function download(array $params = []): void + { + $options = [ + 'name' => 'download', + 'size' => false, + 'mime' => 'application/force-download', + 'modified' => time(), + ...$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'); + header('Content-Disposition: attachment; filename="' . $options['name'] . '"'); + header('Content-Transfer-Encoding: binary'); + + static::contentType($options['mime']); + + if ($options['size']) { + header('Content-Length: ' . $options['size']); + } + + header('Connection: close'); + } +} diff --git a/public/kirby/src/Http/Idn.php b/public/kirby/src/Http/Idn.php new file mode 100644 index 0000000..2ede8b3 --- /dev/null +++ b/public/kirby/src/Http/Idn.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Idn +{ + /** + * Convert domain name from IDNA ASCII to Unicode + */ + public static function decode(string $domain): string|false + { + return idn_to_utf8($domain); + } + + /** + * Convert domain name to IDNA ASCII form + */ + public static function encode(string $domain): string|false + { + return idn_to_ascii($domain); + } + + /** + * Decodes a email address to the Unicode format + */ + public static function decodeEmail(string $email): string + { + if (Str::contains($email, 'xn--') === true) { + $parts = Str::split($email, '@'); + $address = $parts[0]; + $domain = Idn::decode($parts[1] ?? ''); + $email = $address . '@' . $domain; + } + + return $email; + } + + /** + * Encodes a email address to the Punycode format + */ + public static function encodeEmail(string $email): string + { + if (mb_detect_encoding($email, 'ASCII', true) === false) { + $parts = Str::split($email, '@'); + $address = $parts[0]; + $domain = Idn::encode($parts[1] ?? ''); + $email = $address . '@' . $domain; + } + + return $email; + } +} diff --git a/public/kirby/src/Http/Params.php b/public/kirby/src/Http/Params.php new file mode 100644 index 0000000..c762f16 --- /dev/null +++ b/public/kirby/src/Http/Params.php @@ -0,0 +1,172 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Params extends Obj implements Stringable +{ + public static string|null $separator = null; + + /** + * Creates a new params object + */ + public function __construct(array|string|null $params) + { + if (is_string($params) === true) { + $params = static::extract($params)['params']; + } + + parent::__construct($params ?? []); + } + + /** + * Extract the params from a string or array + */ + public static function extract(string|array|null $path = null): array + { + if ($path === null || $path === '' || $path === []) { + return [ + 'path' => null, + 'params' => null, + 'slash' => false + ]; + } + + $slash = false; + + if (is_string($path) === true) { + $slash = str_ends_with($path, '/') === true; + $path = Str::split($path, '/'); + } + + if (is_array($path) === true) { + $params = []; + $separator = static::separator(); + + foreach ($path as $index => $p) { + if (str_contains($p, $separator) === false) { + continue; + } + + $parts = Str::split($p, $separator); + + if ($key = $parts[0] ?? null) { + $key = rawurldecode($key); + + if ($value = $parts[1] ?? null) { + $value = rawurldecode($value); + } + + $params[$key] = $value; + } + + unset($path[$index]); + } + + return [ + 'path' => $path, + 'params' => $params, + 'slash' => $slash + ]; + } + + return [ + 'path' => null, + 'params' => null, + 'slash' => false + ]; + } + + public function isEmpty(): bool + { + return (array)$this === []; + } + + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Merges the current params with the given params + * @since 5.1.0 + * + * @return $this + */ + public function merge(array|string|null $params): static + { + $params = new static($params); + + foreach ($params as $key => $value) { + $this->$key = $value; + } + + return $this; + } + + /** + * Returns the param separator according + * to the operating system. + * + * Unix = ':' + * Windows = ';' + */ + public static function separator(): string + { + return static::$separator ??= DIRECTORY_SEPARATOR === '/' ? ':' : ';'; + } + + /** + * Converts the params object to a params string + * which can then be used in the URL builder again + */ + public function toString( + bool $leadingSlash = false, + bool $trailingSlash = false + ): string { + if ($this->isEmpty() === true) { + return ''; + } + + $params = []; + $separator = static::separator(); + + foreach ($this as $key => $value) { + if ($value !== null && $value !== '') { + $key = rawurlencode($key); + $value = rawurlencode($value); + $params[] = $key . $separator . $value; + } + } + + if ($params === []) { + return ''; + } + + $params = implode('/', $params); + + $leadingSlash = $leadingSlash === true ? '/' : null; + $trailingSlash = $trailingSlash === true ? '/' : null; + + return $leadingSlash . $params . $trailingSlash; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/public/kirby/src/Http/Path.php b/public/kirby/src/Http/Path.php new file mode 100644 index 0000000..bd12cad --- /dev/null +++ b/public/kirby/src/Http/Path.php @@ -0,0 +1,51 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @extends \Kirby\Toolkit\Collection + */ +class Path extends Collection +{ + public function __construct(string|array|null $items) + { + if (is_string($items) === true) { + $items = Str::split($items, '/'); + } + + parent::__construct($items ?? []); + } + + public function __toString(): string + { + return $this->toString(); + } + + public function toString( + bool $leadingSlash = false, + bool $trailingSlash = false + ): string { + if ($this->data === []) { + return ''; + } + + $path = implode('/', $this->data); + + $leadingSlash = $leadingSlash === true ? '/' : null; + $trailingSlash = $trailingSlash === true ? '/' : null; + + return $leadingSlash . $path . $trailingSlash; + } +} diff --git a/public/kirby/src/Http/Query.php b/public/kirby/src/Http/Query.php new file mode 100644 index 0000000..16300b8 --- /dev/null +++ b/public/kirby/src/Http/Query.php @@ -0,0 +1,77 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query extends Obj implements Stringable +{ + public function __construct(string|array|null $query) + { + if (is_string($query) === true) { + parse_str(ltrim($query, '?'), $query); + } + + parent::__construct($query ?? []); + } + + public function isEmpty(): bool + { + return (array)$this === []; + } + + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Merges the current query with the given query + * @since 5.1.0 + * + * @return $this + */ + public function merge(string|array|null $query): static + { + $query = new static($query); + + foreach ($query as $key => $value) { + $this->$key = $value; + } + + return $this; + } + + public function toString(bool $questionMark = false): string + { + $query = http_build_query($this, '', '&', PHP_QUERY_RFC3986); + + if ($query === '') { + return ''; + } + + if ($questionMark === true) { + $query = '?' . $query; + } + + return $query; + } + + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/public/kirby/src/Http/Remote.php b/public/kirby/src/Http/Remote.php new file mode 100644 index 0000000..b780d7e --- /dev/null +++ b/public/kirby/src/Http/Remote.php @@ -0,0 +1,364 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Remote +{ + public const CA_INTERNAL = 1; + public const CA_SYSTEM = 2; + + public static array $defaults = [ + 'agent' => null, + 'basicAuth' => null, + 'body' => true, + 'ca' => self::CA_INTERNAL, + 'data' => [], + 'encoding' => 'utf-8', + 'file' => null, + 'headers' => [], + 'method' => 'GET', + 'progress' => null, + 'test' => false, + 'timeout' => 10, + ]; + + public string|null $content = null; + public CurlHandle|false $curl; + public array $curlopt = []; + public int $errorCode; + public string $errorMessage; + public array $headers = []; + public array $info = []; + + /** + * @throws \Exception when the curl request failed + */ + public function __construct( + string $url, + public array $options = [] + ) { + $defaults = static::$defaults; + + // use the system CA store by default if + // one has been configured in php.ini + $cainfo = ini_get('curl.cainfo'); + + // Suppress warnings e.g. if system CA is outside of open_basedir (See: issue #6236) + if (empty($cainfo) === false && @is_file($cainfo) === true) { + $defaults['ca'] = self::CA_SYSTEM; + } + + // update the defaults with App config if set; + // request the App instance lazily + if ($app = App::instance(null, true)) { + $defaults = [...$defaults, ...$app->option('remote', [])]; + } + + // set all options, incl. url + $this->options = [...$defaults, ...$options, 'url' => $url]; + + // send the request + $this->fetch(); + } + + /** + * Magic getter for request info data + */ + public function __call(string $method, array $arguments = []) + { + $method = str_replace('-', '_', Str::kebab($method)); + return $this->info[$method] ?? null; + } + + public static function __callStatic( + string $method, + array $arguments = [] + ): static { + return new static( + url: $arguments[0], + options: [ + 'method' => strtoupper($method), + ...$arguments[1] ?? [] + ] + ); + } + + /** + * Returns the http status code + */ + public function code(): int|null + { + return $this->info['http_code'] ?? null; + } + + /** + * Returns the response content + */ + public function content(): string|null + { + return $this->content; + } + + /** + * Sets up all curl options and sends the request + * + * @return $this + * @throws \Exception when the curl request failed + */ + public function fetch(): static + { + // curl options + $this->curlopt = [ + CURLOPT_URL => $this->options['url'], + CURLOPT_ENCODING => $this->options['encoding'], + CURLOPT_CONNECTTIMEOUT => $this->options['timeout'], + CURLOPT_TIMEOUT => $this->options['timeout'], + CURLOPT_AUTOREFERER => true, + CURLOPT_RETURNTRANSFER => $this->options['body'], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 10, + CURLOPT_HEADER => false, + CURLOPT_HEADERFUNCTION => function ($curl, $header): int { + $parts = Str::split($header, ':'); + + if (empty($parts[0]) === false && empty($parts[1]) === false) { + $key = array_shift($parts); + $this->headers[$key] = implode(':', $parts); + } + + return strlen($header); + } + ]; + + // determine the TLS CA to use + if ($this->options['ca'] === self::CA_INTERNAL) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + $this->curlopt[CURLOPT_CAINFO] = dirname(__DIR__, 2) . '/cacert.pem'; + } elseif ($this->options['ca'] === self::CA_SYSTEM) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + } elseif ($this->options['ca'] === false) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = false; + } elseif ( + is_string($this->options['ca']) === true && + is_file($this->options['ca']) === true + ) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + $this->curlopt[CURLOPT_CAINFO] = $this->options['ca']; + } elseif ( + is_string($this->options['ca']) === true && + is_dir($this->options['ca']) === true + ) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + $this->curlopt[CURLOPT_CAPATH] = $this->options['ca']; + } else { + throw new InvalidArgumentException( + message: 'Invalid "ca" option for the Remote class' + ); + } + + // add the progress + if (is_callable($this->options['progress']) === true) { + $this->curlopt[CURLOPT_NOPROGRESS] = false; + $this->curlopt[CURLOPT_PROGRESSFUNCTION] = $this->options['progress']; + } + + // add all headers + if (empty($this->options['headers']) === false) { + // convert associative arrays to strings + $headers = []; + foreach ($this->options['headers'] as $key => $value) { + if (is_string($key) === true) { + $value = $key . ': ' . $value; + } + + $headers[] = $value; + } + + $this->curlopt[CURLOPT_HTTPHEADER] = $headers; + } + + // add HTTP Basic authentication + if (empty($this->options['basicAuth']) === false) { + $this->curlopt[CURLOPT_USERPWD] = $this->options['basicAuth']; + } + + // add the user agent + if (empty($this->options['agent']) === false) { + $this->curlopt[CURLOPT_USERAGENT] = $this->options['agent']; + } + + // do some request specific stuff + switch (strtoupper($this->options['method'])) { + case 'POST': + $this->curlopt[CURLOPT_POST] = true; + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'POST'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + break; + case 'PUT': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'PUT'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + + // put a file + if ($this->options['file']) { + $this->curlopt[CURLOPT_INFILE] = fopen($this->options['file'], 'r'); + $this->curlopt[CURLOPT_INFILESIZE] = F::size($this->options['file']); + } + break; + case 'PATCH': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'PATCH'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + break; + case 'DELETE': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'DELETE'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + break; + case 'HEAD': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'HEAD'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + $this->curlopt[CURLOPT_NOBODY] = true; + break; + } + + if ($this->options['test'] === true) { + return $this; + } + + // start a curl request + $this->curl = curl_init(); + + curl_setopt_array($this->curl, $this->curlopt); + + $this->content = curl_exec($this->curl); + $this->info = curl_getinfo($this->curl); + $this->errorCode = curl_errno($this->curl); + $this->errorMessage = curl_error($this->curl); + + if ($this->errorCode) { + throw new Exception($this->errorMessage, $this->errorCode); + } + + curl_close($this->curl); + + return $this; + } + + /** + * Static method to send a GET request + * + * @throws \Exception when the curl request failed + */ + public static function get(string $url, array $params = []): static + { + $options = [ + 'method' => 'GET', + 'data' => [], + ...$params + ]; + + $query = http_build_query($options['data']); + + if ($query !== '') { + $url = match (Url::hasQuery($url)) { + true => $url . '&' . $query, + default => $url . '?' . $query + }; + } + + // remove the data array from the options + unset($options['data']); + + return new static($url, $options); + } + + /** + * Returns all received headers + */ + public function headers(): array + { + return $this->headers; + } + + /** + * Returns the request info + */ + public function info(): array + { + return $this->info; + } + + /** + * Decode the response content + * + * @param bool $array decode as array or object + * @psalm-return ($array is true ? array|null : stdClass|null) + */ + public function json(bool $array = true): array|stdClass|null + { + return json_decode($this->content(), $array); + } + + /** + * Returns the request method + */ + public function method(): string + { + return $this->options['method']; + } + + /** + * Returns all options which have been + * set for the current request + */ + public function options(): array + { + return $this->options; + } + + /** + * Internal method to handle post field data + */ + protected function postfields($data) + { + if (is_object($data) === true || is_array($data) === true) { + return http_build_query($data); + } + + return $data; + } + + /** + * Static method to init this class and send a request + * + * @throws \Exception when the curl request failed + */ + public static function request(string $url, array $params = []): static + { + return new static($url, $params); + } + + /** + * Returns the request Url + */ + public function url(): string + { + return $this->options['url']; + } +} diff --git a/public/kirby/src/Http/Request.php b/public/kirby/src/Http/Request.php new file mode 100644 index 0000000..6f2d2f3 --- /dev/null +++ b/public/kirby/src/Http/Request.php @@ -0,0 +1,434 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Request +{ + public static array $authTypes = [ + 'basic' => BasicAuth::class, + 'bearer' => BearerAuth::class, + 'session' => SessionAuth::class, + ]; + + /** + * The auth object if available + */ + protected Auth|false|null $auth = null; + + /** + * The Body object is a wrapper around + * the request body, which parses the contents + * of the body and provides an API to fetch + * particular parts of the body + * + * Examples: + * + * `$request->body()->get('foo')` + */ + protected Body|null $body = null; + + /** + * The Files object is a wrapper around + * the $_FILES global. It sanitizes the + * $_FILES array and provides an API to fetch + * individual files by key + * + * Examples: + * + * `$request->files()->get('upload')['size']` + * `$request->file('upload')['size']` + */ + protected Files|null $files = null; + + /** + * The Method type + */ + protected string $method; + + /** + * The Query object is a wrapper around + * the URL query string, which parses the + * string and provides a clean API to fetch + * particular parts of the query + * + * Examples: + * + * `$request->query()->get('foo')` + */ + protected Query $query; + + /** + * Request URL object + */ + protected Uri $url; + + /** + * Creates a new Request object + * You can either pass your own request + * data via the $options array or use + * the data from the incoming request. + */ + public function __construct( + protected array $options = [] + ) { + $this->method = $this->detectRequestMethod($options['method'] ?? null); + + if (isset($options['body']) === true) { + $this->body = + $options['body'] instanceof Body + ? $options['body'] + : new Body($options['body']); + } + + if (isset($options['files']) === true) { + $this->files = + $options['files'] instanceof Files + ? $options['files'] + : new Files($options['files']); + } + + if (isset($options['query']) === true) { + $this->query = + $options['query'] instanceof Query + ? $options['query'] + : new Query($options['query']); + } + + if (isset($options['url']) === true) { + $this->url = + $options['url'] instanceof Uri + ? $options['url'] + : new Uri($options['url']); + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + 'body' => $this->body(), + 'files' => $this->files(), + 'method' => $this->method(), + 'query' => $this->query(), + 'url' => $this->url()->toString() + ]; + } + + /** + * Returns the Auth object if authentication is set + */ + public function auth(): Auth|false|null + { + if ($this->auth !== null) { + return $this->auth; + } + + // lazily request the instance for non-CMS use cases + $kirby = App::instance(lazy: true); + + // tell the CMS responder that the response relies on + // the `Authorization` header and its value (even if + // the header isn't set in the current request); + // this ensures that the response is only cached for + // unauthenticated visitors; + // https://github.com/getkirby/kirby/issues/4423#issuecomment-1166300526 + $kirby?->response()->usesAuth(true); + + if ($auth = $this->authString()) { + $type = Str::lower(Str::before($auth, ' ')); + $data = Str::after($auth, ' '); + + $class = static::$authTypes[$type] ?? null; + if (!$class || class_exists($class) === false) { + return $this->auth = false; + } + + $object = new $class($data); + + return $this->auth = $object; + } + + return $this->auth = false; + } + + /** + * Returns the Body object + */ + public function body(): Body + { + return $this->body ??= new Body(); + } + + /** + * Checks if the request has been made from the command line + */ + public function cli(): bool + { + return $this->options['cli'] ?? (new Environment())->cli(); + } + + /** + * Returns a CSRF token if stored in a header or the query + */ + public function csrf(): string|null + { + return $this->header('x-csrf') ?? $this->query()->get('csrf'); + } + + /** + * Returns the request input as array + */ + public function data(): array + { + return array_replace( + $this->body()->toArray(), + $this->query()->toArray() + ); + } + + /** + * Detect the request method from various + * options: given method, query string, server vars + */ + public function detectRequestMethod(string|null $method = null): string + { + // all possible methods + $methods = [ + 'CONNECT', + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', + 'TRACE', + ]; + + // the request method can be overwritten with a header + if ($method === null) { + $override = Environment::getGlobally('HTTP_X_HTTP_METHOD_OVERRIDE', ''); + $override = strtoupper($override); + + if (in_array($override, $methods, true) === true) { + $method = $override; + } + } + + // final chain of options to detect the method + $method ??= Environment::getGlobally('REQUEST_METHOD', 'GET'); + + // uppercase the shit out of it + $method = strtoupper($method); + + // sanitize the method + if (in_array($method, $methods, true) === false) { + $method = 'GET'; + } + + return $method; + } + + /** + * Returns the domain + */ + public function domain(): string + { + return $this->url()->domain(); + } + + /** + * Fetches a single file array + * from the Files object by key + */ + public function file(string $key): array|null + { + return $this->files()->get($key); + } + + /** + * Returns the Files object + */ + public function files(): Files + { + return $this->files ??= new Files(); + } + + /** + * Returns any data field from the request + * if it exists + */ + public function get(string|array|null $key = null, $fallback = null) + { + return A::get($this->data(), $key, $fallback); + } + + /** + * Returns whether the request contains + * the `Authorization` header + * @since 3.7.0 + */ + public function hasAuth(): bool + { + return $this->authString() !== null; + } + + /** + * Returns a header by key if it exists + */ + public function header(string $key, $fallback = null) + { + $headers = array_change_key_case($this->headers()); + return $headers[strtolower($key)] ?? $fallback; + } + + /** + * Return all headers with polyfill for + * missing getallheaders function + */ + public function headers(): array + { + $headers = []; + + foreach (Environment::getGlobally() as $key => $value) { + if ( + str_starts_with($key, 'HTTP_') === false && + str_starts_with($key, 'REDIRECT_HTTP_') === false + ) { + continue; + } + + // remove HTTP_ + $key = str_replace(['REDIRECT_HTTP_', 'HTTP_'], '', $key); + + // convert to lowercase + $key = strtolower($key); + + // replace _ with spaces + $key = str_replace('_', ' ', $key); + + // uppercase first char in each word + $key = ucwords($key); + + // convert spaces to dashes + $key = str_replace(' ', '-', $key); + + $headers[$key] = $value; + } + + return $headers; + } + + /** + * Checks if the given method name + * matches the name of the request method. + */ + public function is(string $method): bool + { + return strtoupper($this->method) === strtoupper($method); + } + + /** + * Returns the request method + */ + public function method(): string + { + return $this->method; + } + + /** + * Shortcut to the Params object + */ + public function params(): Params + { + return $this->url()->params(); + } + + /** + * Shortcut to the Path object + */ + public function path(): Path + { + return $this->url()->path(); + } + + /** + * Returns the Query object + */ + public function query(): Query + { + return $this->query ??= new Query(); + } + + /** + * Checks for a valid SSL connection + */ + public function ssl(): bool + { + return $this->url()->scheme() === 'https'; + } + + /** + * Returns the current Uri object. + * If you pass props you can safely modify + * the Url with new parameters without destroying + * the original object. + */ + public function url(array|null $props = null): Uri + { + if ($props !== null) { + return $this->url()->clone($props); + } + + return $this->url ??= Uri::current(); + } + + /** + * Returns the raw auth string from the `auth` option + * or `Authorization` header unless both are empty + */ + protected function authString(): string|null + { + // both variants need to be checked separately + // because empty strings are treated as invalid + // but the `??` operator wouldn't do the fallback + $option = $this->options['auth'] ?? null; + + if (is_string($option) === true && $option !== '') { + return $option; + } + + $header = $this->header('authorization'); + + if (is_string($header) === true && $header !== '') { + return $header; + } + + return null; + } +} diff --git a/public/kirby/src/Http/Request/Auth.php b/public/kirby/src/Http/Request/Auth.php new file mode 100644 index 0000000..5b8175d --- /dev/null +++ b/public/kirby/src/Http/Request/Auth.php @@ -0,0 +1,49 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Auth implements Stringable +{ + /** + * @param string $data Raw authentication data after the first space in the `Authorization` header + */ + public function __construct( + #[SensitiveParameter] + protected string $data + ) { + } + + /** + * Converts the object to a string + */ + public function __toString(): string + { + return ucfirst($this->type()) . ' ' . $this->data(); + } + + /** + * Returns the raw authentication data after the + * first space in the `Authorization` header + */ + public function data(): string + { + return $this->data; + } + + /** + * Returns the name of the auth type (lowercase) + */ + abstract public function type(): string; +} diff --git a/public/kirby/src/Http/Request/Auth/BasicAuth.php b/public/kirby/src/Http/Request/Auth/BasicAuth.php new file mode 100644 index 0000000..f0e80ce --- /dev/null +++ b/public/kirby/src/Http/Request/Auth/BasicAuth.php @@ -0,0 +1,66 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class BasicAuth extends Auth +{ + protected string $credentials; + protected string|null $password; + protected string|null $username; + + public function __construct( + #[SensitiveParameter] + string $data + ) { + parent::__construct($data); + + $this->credentials = base64_decode($data); + $this->username = Str::before($this->credentials, ':'); + $this->password = Str::after($this->credentials, ':'); + } + + /** + * Returns the entire unencoded credentials string + */ + public function credentials(): string + { + return $this->credentials; + } + + /** + * Returns the password + */ + public function password(): string|null + { + return $this->password; + } + + /** + * Returns the authentication type + */ + public function type(): string + { + return 'basic'; + } + + /** + * Returns the username + */ + public function username(): string|null + { + return $this->username; + } +} diff --git a/public/kirby/src/Http/Request/Auth/BearerAuth.php b/public/kirby/src/Http/Request/Auth/BearerAuth.php new file mode 100644 index 0000000..81dc9a9 --- /dev/null +++ b/public/kirby/src/Http/Request/Auth/BearerAuth.php @@ -0,0 +1,33 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class BearerAuth extends Auth +{ + /** + * Returns the authentication token + */ + public function token(): string + { + return $this->data; + } + + /** + * Returns the auth type + */ + public function type(): string + { + return 'bearer'; + } +} diff --git a/public/kirby/src/Http/Request/Auth/SessionAuth.php b/public/kirby/src/Http/Request/Auth/SessionAuth.php new file mode 100644 index 0000000..ca10830 --- /dev/null +++ b/public/kirby/src/Http/Request/Auth/SessionAuth.php @@ -0,0 +1,43 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class SessionAuth extends Auth +{ + /** + * Tries to return the session object + */ + public function session(): Session + { + return App::instance()->sessionHandler()->getManually($this->data); + } + + /** + * Returns the session token + */ + public function token(): string + { + return $this->data; + } + + /** + * Returns the authentication type + */ + public function type(): string + { + return 'session'; + } +} diff --git a/public/kirby/src/Http/Request/Body.php b/public/kirby/src/Http/Request/Body.php new file mode 100644 index 0000000..e9ac19a --- /dev/null +++ b/public/kirby/src/Http/Request/Body.php @@ -0,0 +1,114 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Body implements Stringable +{ + use Data; + + /** + * The parsed content as array + */ + protected array|null $data = null; + + /** + * Creates a new request body object. + * You can pass your own array or string. + * If null is being passed, the class will + * fetch the body either from the $_POST global + * or from php://input. + * + * @param array|string|null $contents The raw body content + */ + public function __construct( + protected array|string|null $contents = null + ) { + } + + /** + * Fetches the raw contents for the body + * or uses the passed contents. + */ + public function contents(): string|array + { + if ($this->contents !== null) { + return $this->contents; + } + + if ($_POST !== []) { + return $this->contents = $_POST; + } + + return $this->contents = file_get_contents('php://input'); + } + + /** + * Parses the raw contents once and caches + * the result. The parser will try to convert + * the body with the json decoder first and + * then run parse_str to get some results + * if the json decoder failed. + */ + public function data(): array + { + if (is_array($this->data) === true) { + return $this->data; + } + + $contents = $this->contents(); + + // return content which is already in array form + if (is_array($contents) === true) { + return $this->data = $contents; + } + + // try to convert the body from json + $json = json_decode($contents, true); + + if (is_array($json) === true) { + return $this->data = $json; + } + + if (str_contains($contents, '=') === true) { + // try to parse the body as query string + parse_str($contents, $parsed); + + if (is_array($parsed) === true) { + return $this->data = $parsed; + } + } + + return $this->data = []; + } + + /** + * Converts the data array back + * to a http query string + */ + public function toString(): string + { + return http_build_query($this->data()); + } + + /** + * Magic string converter + */ + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/public/kirby/src/Http/Request/Data.php b/public/kirby/src/Http/Request/Data.php new file mode 100644 index 0000000..0a435e1 --- /dev/null +++ b/public/kirby/src/Http/Request/Data.php @@ -0,0 +1,75 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Data +{ + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * The data provider method has to be + * implemented by each class using this Trait + * and has to return an associative array + * for the get method + */ + abstract public function data(): array; + + /** + * The get method is the heart and soul of this + * Trait. You can use it to fetch a single value + * of the data array by key or multiple values by + * passing an array of keys. + */ + public function get(string|array $key, $default = null) + { + if (is_array($key) === true) { + $result = []; + + foreach ($key as $k) { + $result[$k] = $this->get($k); + } + + return $result; + } + + return $this->data()[$key] ?? $default; + } + + /** + * Returns the data array. + * This is basically an alias for Data::data() + */ + public function toArray(): array + { + return $this->data(); + } + + /** + * Converts the data array to json + */ + public function toJson(): string + { + return json_encode($this->data()); + } +} diff --git a/public/kirby/src/Http/Request/Files.php b/public/kirby/src/Http/Request/Files.php new file mode 100644 index 0000000..4cb3dd2 --- /dev/null +++ b/public/kirby/src/Http/Request/Files.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Files +{ + use Data; + + /** + * Sanitized array of all received files + */ + protected array $files = []; + + /** + * Creates a new Files object + * Pass your own array to mock + * uploads. + */ + public function __construct(array|null $files = null) + { + $files ??= $_FILES; + + foreach ($files as $key => $file) { + if (is_array($file['name']) === true) { + foreach ($file['name'] as $i => $name) { + $this->files[$key][] = [ + 'name' => $file['name'][$i] ?? null, + 'type' => $file['type'][$i] ?? null, + 'tmp_name' => $file['tmp_name'][$i] ?? null, + 'error' => $file['error'][$i] ?? null, + 'size' => $file['size'][$i] ?? null, + ]; + } + } else { + $this->files[$key] = $file; + } + } + } + + /** + * The data method returns the files + * array. This is only needed to make + * the Data trait work for the Files::get($key) + * method. + */ + public function data(): array + { + return $this->files; + } +} diff --git a/public/kirby/src/Http/Request/Query.php b/public/kirby/src/Http/Request/Query.php new file mode 100644 index 0000000..4b7a189 --- /dev/null +++ b/public/kirby/src/Http/Request/Query.php @@ -0,0 +1,86 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query implements Stringable +{ + use Data; + + /** + * The Query data array + */ + protected array $data; + + /** + * Creates a new Query object. + * The passed data can be an array + * or a parsable query string. If + * null is passed, the current Query + * will be taken from $_GET + */ + public function __construct(array|string|null $data = null) + { + if ($data === null) { + $this->data = $_GET; + } elseif (is_array($data) === true) { + $this->data = $data; + } else { + parse_str($data, $parsed); + $this->data = $parsed; + } + } + + /** + * Returns the Query data as array + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns `true` if the request doesn't contain query variables + */ + public function isEmpty(): bool + { + return $this->data === []; + } + + /** + * Returns `true` if the request contains query variables + */ + public function isNotEmpty(): bool + { + return $this->data !== []; + } + + /** + * Converts the query data array + * back to a query string + */ + public function toString(): string + { + return http_build_query($this->data()); + } + + /** + * Magic string converter + */ + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/public/kirby/src/Http/Response.php b/public/kirby/src/Http/Response.php new file mode 100644 index 0000000..3b6cdb5 --- /dev/null +++ b/public/kirby/src/Http/Response.php @@ -0,0 +1,354 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Response implements Stringable +{ + /** + * Store for all registered headers, + * which will be sent with the response + */ + protected array $headers = []; + + /** + * The response body + */ + protected string $body; + + /** + * The HTTP response code + */ + protected int $code; + + /** + * The content type for the response + */ + protected string $type; + + /** + * The content type charset + */ + protected string $charset = 'UTF-8'; + + /** + * Creates a new response object + */ + public function __construct( + string|array $body = '', + string|null $type = null, + int|null $code = null, + array|null $headers = null, + string|null $charset = null + ) { + // array construction + if (is_array($body) === true) { + $params = $body; + $body = $params['body'] ?? ''; + $type = $params['type'] ?? $type; + $code = $params['code'] ?? $code; + $headers = $params['headers'] ?? $headers; + $charset = $params['charset'] ?? $charset; + } + + // regular construction + $this->body = $body; + $this->type = $type ?? 'text/html'; + $this->code = $code ?? 200; + $this->headers = $headers ?? []; + $this->charset = $charset ?? 'UTF-8'; + + // automatic mime type detection + if (str_contains($this->type, '/') === false) { + $this->type = F::extensionToMime($this->type) ?? 'text/html'; + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Makes it possible to convert the + * entire response object to a string + * to send the headers and print the body + */ + public function __toString(): string + { + return $this->send(); + } + + /** + * Getter for the body + */ + public function body(): string + { + return $this->body; + } + + /** + * Getter for the content type charset + */ + public function charset(): string + { + return $this->charset; + } + + /** + * Getter for the HTTP status code + */ + public function code(): int + { + return $this->code; + } + + /** + * Creates a response that triggers + * a file download for the given file + * + * @param array $props Custom overrides for response props (e.g. headers) + */ + public static function download( + string $file, + string|null $filename = null, + array $props = [] + ): static { + if (file_exists($file) === false) { + throw new Exception(message: 'The file could not be found'); + } + + $filename ??= basename($file); + $modified = filemtime($file); + $body = file_get_contents($file); + $size = strlen($body); + + $props = array_replace_recursive([ + 'body' => $body, + 'type' => F::mime($file), + 'headers' => [ + 'Pragma' => 'public', + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Last-Modified' => gmdate('D, d M Y H:i:s', $modified) . ' GMT', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Transfer-Encoding' => 'binary', + 'Content-Length' => $size, + 'Connection' => 'close' + ] + ], $props); + + return new static($props); + } + + /** + * Creates a response for a file and + * sends the file content to the browser + * + * @param array $props Custom overrides for response props (e.g. headers) + */ + public static function file(string $file, array $props = []): static + { + $props = [ + 'body' => F::read($file), + '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 + // harden against attacks from malicious file uploads + if ($props['type'] === null) { + if (isset($props['headers']) !== true) { + $props['headers'] = []; + } + + $props['type'] = 'text/plain'; + $props['headers']['X-Content-Type-Options'] = 'nosniff'; + } + + return new static($props); + } + + + /** + * Redirects to the given Urls + * Urls can be relative or absolute. + * @since 3.7.0 + * + * @codeCoverageIgnore + */ + public static function go(string $url = '/', int $code = 302): never + { + die(static::redirect($url, $code)); + } + + /** + * Ensures that the callback does not produce the first body output + * (used to show when loading a file creates side effects) + */ + public static function guardAgainstOutput(Closure $callback, ...$args): mixed + { + $before = headers_sent(); + $result = $callback(...$args); + $after = headers_sent($file, $line); + + if ($before === false && $after === true) { + throw new LogicException("Disallowed output from file $file:$line, possible accidental whitespace?"); + } + + return $result; + } + + /** + * Getter for single headers + * + * @param string $key Name of the header + */ + public function header(string $key): string|null + { + return $this->headers[$key] ?? null; + } + + /** + * Getter for all headers + */ + public function headers(): array + { + return $this->headers; + } + + /** + * Creates a json response with appropriate + * header and automatic conversion of arrays. + */ + public static function json( + string|array $body = '', + int|null $code = null, + bool|null $pretty = null, + array $headers = [] + ): static { + if (is_array($body) === true) { + $body = json_encode($body, $pretty === true ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : 0); + } + + return new static([ + 'body' => $body, + 'code' => $code, + 'type' => 'application/json', + 'headers' => $headers + ]); + } + + /** + * Creates a redirect response, + * which will send the visitor to the + * given location. + */ + public static function redirect(string $location = '/', int $code = 302): static + { + return new static([ + 'code' => $code, + 'headers' => [ + 'Location' => Url::unIdn($location) + ] + ]); + } + + /** + * 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 + */ + public function send(): string + { + // send the status response code + http_response_code($this->code()); + + // send all custom headers + foreach ($this->headers() as $key => $value) { + header($key . ': ' . $value); + } + + // send the content type header + header('Content-Type: ' . $this->type() . '; charset=' . $this->charset()); + + // print the response body + return $this->body(); + } + + /** + * Sets the provided headers in case they are not already set + * @internal + * @return $this + */ + public function setHeaderFallbacks(array $headers): static + { + // the case-insensitive nature of headers will be + // handled by PHP's `header()` functions + $this->headers = [...$headers, ...$this->headers]; + return $this; + } + + /** + * Converts all relevant response attributes + * to an associative array for debugging, + * testing or whatever. + */ + public function toArray(): array + { + return [ + 'type' => $this->type(), + 'charset' => $this->charset(), + 'code' => $this->code(), + 'headers' => $this->headers(), + 'body' => $this->body() + ]; + } + + /** + * Getter for the content type + */ + public function type(): string + { + return $this->type; + } +} diff --git a/public/kirby/src/Http/Route.php b/public/kirby/src/Http/Route.php new file mode 100644 index 0000000..97b6da0 --- /dev/null +++ b/public/kirby/src/Http/Route.php @@ -0,0 +1,174 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Route +{ + /** + * Listed of parsed arguments + */ + protected array $arguments = []; + + /** + * The registered pattern + */ + protected string $pattern; + + /** + * Wildcards, which can be used in + * Route patterns to make regular expressions + * a little more human + */ + protected array $wildcards = [ + 'required' => [ + '(:num)' => '(-?[0-9]+)', + '(:alpha)' => '([a-zA-Z]+)', + '(:alphanum)' => '([a-zA-Z0-9]+)', + '(:any)' => '([a-zA-Z0-9\.\-_%= \+\@\(\)]+)', + '(:all)' => '(.*)', + ], + 'optional' => [ + '/(:num?)' => '(?:/(-?[0-9]+)', + '/(:alpha?)' => '(?:/([a-zA-Z]+)', + '/(:alphanum?)' => '(?:/([a-zA-Z0-9]+)', + '/(:any?)' => '(?:/([a-zA-Z0-9\.\-_%= \+\@\(\)]+)', + '/(:all?)' => '(?:/(.*)', + ], + ]; + + /** + * Magic getter for route attributes + */ + public function __call(string $key, array|null $args = null): mixed + { + return $this->attributes[$key] ?? null; + } + + /** + * Creates a new Route object for the given + * pattern(s), method(s) and the callback action + */ + public function __construct( + string $pattern, + protected string $method, + protected Closure $action, + protected array $attributes = [] + ) { + $this->pattern = $this->regex(ltrim($pattern, '/')); + } + + /** + * Getter for the action callback + */ + public function action(): Closure + { + return $this->action; + } + + /** + * Returns all parsed arguments + */ + public function arguments(): array + { + return $this->arguments; + } + + /** + * Getter for additional attributes + */ + public function attributes(): array + { + return $this->attributes; + } + + /** + * Getter for the method + */ + public function method(): string + { + return $this->method; + } + + /** + * Returns the route name if set + */ + public function name(): string|null + { + return $this->attributes['name'] ?? null; + } + + /** + * Throws a specific exception to tell + * the router to jump to the next route + * @since 3.0.3 + */ + public static function next(): void + { + throw new Exceptions\NextRouteException(message: 'next'); + } + + /** + * Getter for the pattern + */ + public function pattern(): string + { + return $this->pattern; + } + + /** + * Converts the pattern into a full regular + * expression by replacing all the wildcards + */ + public function regex(string $pattern): string + { + $search = array_keys($this->wildcards['optional']); + $replace = array_values($this->wildcards['optional']); + + // For optional parameters, first translate the wildcards to their + // regex equivalent, sans the ")?" ending. We'll add the endings + // back on when we know the replacement count. + $pattern = str_replace($search, $replace, $pattern, $count); + + if ($count > 0) { + $pattern .= str_repeat(')?', $count); + } + + return strtr($pattern, $this->wildcards['required']); + } + + /** + * Tries to match the path with the regular expression and + * extracts all arguments for the Route action + */ + public function parse(string $pattern, string $path): array|false + { + // check for direct matches + if ($pattern === $path) { + return $this->arguments = []; + } + + // 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 (str_contains($pattern, '(') === false) { + return false; + } + + // If we have a match we'll return all results + // from the preg without the full first match. + if (preg_match('#^' . $this->regex($pattern) . '$#u', $path, $parameters)) { + return $this->arguments = array_slice($parameters, 1); + } + + return false; + } +} diff --git a/public/kirby/src/Http/Router.php b/public/kirby/src/Http/Router.php new file mode 100644 index 0000000..1798705 --- /dev/null +++ b/public/kirby/src/Http/Router.php @@ -0,0 +1,207 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Router +{ + /** + * Hook that is called after each route + */ + protected Closure|null $afterEach; + + /** + * Hook that is called before each route + */ + protected Closure|null $beforeEach; + + /** + * Store for the current route, + * if one can be found + */ + protected Route|null $route = null; + + /** + * All registered routes, sorted by + * their request method. This makes + * it faster to find the right route + * later. + */ + protected array $routes = [ + 'GET' => [], + 'HEAD' => [], + 'POST' => [], + 'PUT' => [], + 'DELETE' => [], + 'CONNECT' => [], + 'OPTIONS' => [], + 'TRACE' => [], + 'PATCH' => [], + ]; + + /** + * Creates a new router object and + * registers all the given routes + * + * @param array $hooks Optional `beforeEach` and `afterEach` hooks + */ + public function __construct(array $routes = [], array $hooks = []) + { + $this->beforeEach = $hooks['beforeEach'] ?? null; + $this->afterEach = $hooks['afterEach'] ?? null; + + foreach ($routes as $props) { + if (isset($props['pattern'], $props['action']) === false) { + throw new InvalidArgumentException( + message: 'Invalid route parameters' + ); + } + + $patterns = A::wrap($props['pattern']); + $methods = A::map( + explode('|', strtoupper($props['method'] ?? 'GET')), + 'trim' + ); + + if ($methods === ['ALL']) { + $methods = array_keys($this->routes); + } + + foreach ($methods as $method) { + foreach ($patterns as $pattern) { + $this->routes[$method][] = new Route( + $pattern, + $method, + $props['action'], + $props + ); + } + } + } + } + + /** + * Calls the Router by path and method. + * This will try to find a Route object + * and then call the Route action with + * the appropriate arguments and a Result + * object. + */ + public function call( + string|null $path = null, + string $method = 'GET', + Closure|null $callback = null + ) { + $path ??= ''; + $ignore = []; + $result = null; + $loop = true; + + while ($loop === true) { + $route = $this->find($path, $method, $ignore); + + if ($this->beforeEach instanceof Closure) { + ($this->beforeEach)($route, $path, $method); + } + + try { + if ($callback) { + $result = $callback($route); + } else { + $result = $route->action()->call( + $route, + ...$route->arguments() + ); + } + + $loop = false; + } catch (Exceptions\NextRouteException) { + $ignore[] = $route; + } + + if ($this->afterEach instanceof Closure) { + $final = $loop === false; + $result = ($this->afterEach)($route, $path, $method, $result, $final); + } + } + + return $result; + } + + /** + * Creates a micro-router and executes + * the routing action immediately + * @since 3.7.0 + */ + public static function execute( + string|null $path = null, + string $method = 'GET', + array $routes = [], + Closure|null $callback = null + ) { + return (new static($routes))->call($path, $method, $callback); + } + + /** + * Finds a Route object by path and method + * The Route's arguments method is used to + * find matches and return all the found + * arguments in the path. + * + * @param array|null $ignore (Passing null has been deprecated) + * @todo Remove support for `$ignore = null` in v6 + */ + public function find( + string $path, + string $method, + array|null $ignore = null + ): Route { + if (isset($this->routes[$method]) === false) { + throw new InvalidArgumentException( + message: 'Invalid routing method: ' . $method, + code: 400 + ); + } + + // remove leading and trailing slashes + $path = trim($path, '/'); + $ignore ??= []; + + foreach ($this->routes[$method] as $route) { + $arguments = $route->parse($route->pattern(), $path); + + if ($arguments !== false) { + if (in_array($route, $ignore, true) === false) { + return $this->route = $route; + } + } + } + + throw new Exception( + code: 404, + message: 'No route found for path: "' . $path . '" and request method: "' . $method . '"', + ); + } + + /** + * Returns the current route. + * This will only return something, + * once Router::find() has been called + * and only if a route was found. + */ + public function route(): Route|null + { + return $this->route; + } +} diff --git a/public/kirby/src/Http/Uri.php b/public/kirby/src/Http/Uri.php new file mode 100644 index 0000000..aa41a96 --- /dev/null +++ b/public/kirby/src/Http/Uri.php @@ -0,0 +1,541 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Uri implements Stringable +{ + /** + * Cache for the current Uri object + */ + public static Uri|null $current = null; + + /** + * The fragment after the hash + */ + protected string|false|null $fragment; + + /** + * The host address + */ + protected string|null $host; + + /** + * The optional password for basic authentication + */ + protected string|false|null $password; + + /** + * The optional list of params + */ + protected Params $params; + + /** + * The optional path + */ + protected Path $path; + + /** + * The optional port number + */ + protected int|false|null $port; + + /** + * All original properties + */ + protected array $props; + + /** + * The optional query string without leading ? + */ + protected Query $query; + + /** + * https or http + */ + protected string|null $scheme; + + /** + * Supported schemes + */ + protected static array $schemes = ['http', 'https', 'ftp']; + + protected bool $slash; + + /** + * The optional username for basic authentication + */ + protected string|false|null $username = null; + + /** + * Creates a new URI object + * + * @param array $inject Additional props to inject if a URL string is passed + */ + public function __construct(array|string $props = [], array $inject = []) + { + if (is_string($props) === true) { + // make sure the URL parser works properly when there's a + // colon in the string but the string is a relative URL + if (Url::isAbsolute($props) === false) { + $props = 'https://getkirby.com/' . $props; + $props = parse_url($props); + unset($props['scheme'], $props['host']); + } else { + $props = parse_url($props); + } + + $props['username'] = $props['user'] ?? null; + $props['password'] = $props['pass'] ?? null; + $props = [...$props, ...$inject]; + } + + // parse the path and extract params + if (empty($props['path']) === false) { + $props = static::parsePath($props); + } + + $this->props = $props; + $this->setFragment($props['fragment'] ?? null); + $this->setHost($props['host'] ?? null); + $this->setParams($props['params'] ?? null); + $this->setPassword($props['password'] ?? null); + $this->setPath($props['path'] ?? null); + $this->setPort($props['port'] ?? null); + $this->setQuery($props['query'] ?? null); + $this->setScheme($props['scheme'] ?? 'http'); + $this->setSlash($props['slash'] ?? false); + $this->setUsername($props['username'] ?? null); + } + + /** + * Magic caller to access all properties + */ + public function __call(string $property, array $arguments = []) + { + return $this->$property ?? null; + } + + /** + * Make sure that cloning also clones + * the path and query objects + */ + public function __clone() + { + $this->path = clone $this->path; + $this->query = clone $this->query; + $this->params = clone $this->params; + } + + /** + * Magic getter + */ + public function __get(string $property) + { + return $this->$property ?? null; + } + + /** + * Magic setter + */ + public function __set(string $property, $value): void + { + if (method_exists($this, 'set' . $property) === true) { + $this->{'set' . $property}($value); + } + } + + /** + * Converts the URL object to string + */ + public function __toString(): string + { + try { + return $this->toString(); + } catch (Throwable) { + return ''; + } + } + + /** + * Returns the auth details (username:password) + */ + public function auth(): string|null + { + $auth = trim($this->username . ':' . $this->password); + return $auth !== ':' ? $auth : null; + } + + /** + * Returns the base url (scheme + host) + * without trailing slash + */ + public function base(): string|null + { + if ($domain = $this->domain()) { + return $this->scheme ? $this->scheme . '://' . $domain : $domain; + } + + return null; + } + + /** + * Clones the Uri object and applies optional + * new props. + */ + public function clone(array $props = []): static + { + $clone = clone $this; + + foreach ($props as $key => $value) { + $clone->__set($key, $value); + } + + return $clone; + } + + public static function current(array $props = []): static + { + if (static::$current !== null) { + return static::$current; + } + + if ($app = App::instance(null, true)) { + $environment = $app->environment(); + } + + $environment ??= new Environment(); + + return new static($environment->requestUrl(), $props); + } + + /** + * Returns the domain without scheme, path or query. + * Includes auth part when not empty. + * Includes port number when different from 80 or 443. + */ + public function domain(): string|null + { + if ($this->host === null || $this->host === '' || $this->host === '/') { + return null; + } + + $auth = $this->auth(); + $domain = ''; + + if ($auth !== null) { + $domain .= $auth . '@'; + } + + $domain .= $this->host; + + if ( + $this->port !== null && + in_array($this->port, [80, 443], true) === false + ) { + $domain .= ':' . $this->port; + } + + return $domain; + } + + public function hasFragment(): bool + { + return $this->fragment !== null && $this->fragment !== ''; + } + + public function hasPath(): bool + { + return $this->path()->isNotEmpty(); + } + + public function hasQuery(): bool + { + return $this->query()->isNotEmpty(); + } + + public function https(): bool + { + return $this->scheme() === 'https'; + } + + /** + * Tries to convert the internationalized host + * name to the human-readable UTF8 representation + * + * @return $this + */ + public function idn(): static + { + if ($this->isAbsolute() === true) { + $host = Idn::decode($this->host); + $this->setHost($host); + } + return $this; + } + + /** + * Creates an Uri object for the URL to the index.php + * or any other executed script. + */ + public static function index(array $props = []): static + { + if ($app = App::instance(null, true)) { + $url = $app->url('index'); + } + + $url ??= (new Environment())->baseUrl(); + + return new static($url, $props); + } + + /** + * Checks if the host exists + */ + public function isAbsolute(): bool + { + return $this->host !== null && $this->host !== ''; + } + + /** + * Returns the fragment after the hash + * @since 5.1.0 + */ + public function fragment(): string|null + { + return $this->fragment; + } + + /** + * @return $this + */ + public function setFragment(string|null $fragment = null): static + { + $this->fragment = $fragment ? ltrim($fragment, '#') : null; + return $this; + } + + /** + * @return $this + */ + public function setHost(string|null $host = null): static + { + $this->host = $host; + return $this; + } + + /** + * @return $this + */ + public function setParams(Params|string|array|false|null $params = null): static + { + // ensure that the special constructor value of `false` + // is never passed through as it's not supported by `Params` + if ($params === false) { + $params = []; + } + + $this->params = $params instanceof Params ? $params : new Params($params); + return $this; + } + + /** + * @return $this + */ + public function setPassword( + #[SensitiveParameter] + string|null $password = null + ): static { + $this->password = $password; + return $this; + } + + /** + * @return $this + */ + public function setPath(Path|string|array|null $path = null): static + { + $this->path = $path instanceof Path ? $path : new Path($path); + return $this; + } + + /** + * @return $this + */ + public function setPort(int|null $port = null): static + { + if ($port === 0) { + $port = null; + } + + if ($port !== null) { + if ($port < 1 || $port > 65535) { + throw new InvalidArgumentException( + message: 'Invalid port format: ' . $port + ); + } + } + + $this->port = $port; + return $this; + } + + /** + * @return $this + */ + public function setQuery(Query|string|array|null $query = null): static + { + $this->query = $query instanceof Query ? $query : new Query($query); + return $this; + } + + /** + * @return $this + */ + public function setScheme(string|null $scheme = null): static + { + if ( + $scheme !== null && + in_array($scheme, static::$schemes, true) === false + ) { + throw new InvalidArgumentException( + message: 'Invalid URL scheme: ' . $scheme + ); + } + + $this->scheme = $scheme; + return $this; + } + + /** + * Set if a trailing slash should be added to + * the path when the URI is being built + * + * @return $this + */ + public function setSlash(bool $slash = false): static + { + $this->slash = $slash; + return $this; + } + + /** + * @return $this + */ + public function setUsername(string|null $username = null): static + { + $this->username = $username; + return $this; + } + + /** + * Converts the Url object to an array + */ + public function toArray(): array + { + $array = []; + + foreach ($this->props as $key => $value) { + $value = $this->$key; + + if (is_object($value) === true) { + $value = $value->toArray(); + } + + $array[$key] = $value; + } + + return $array; + } + + public function toJson(...$arguments): string + { + return json_encode($this->toArray(), ...$arguments); + } + + /** + * Returns the full URL as string + */ + public function toString(): string + { + $url = $this->base(); + $slash = true; + + if ($url === null || $url === '') { + $url = '/'; + $slash = false; + } + + $path = $this->path->toString($slash) . $this->params->toString(true); + + if ($this->slash && ($path !== '' || $slash === true)) { + $path .= '/'; + } + + $url .= $path; + $url .= $this->query->toString(true); + + if ($this->hasFragment() === true) { + $url .= '#' . $this->fragment(); + } + + return $url; + } + + /** + * Tries to convert a URL with an internationalized host + * name to the machine-readable Punycode representation + * + * @return $this + */ + public function unIdn(): static + { + if ($this->isAbsolute() === true) { + $host = Idn::encode($this->host); + $this->setHost($host); + } + return $this; + } + + /** + * Parses the path inside the props and extracts + * the params unless disabled + * + * @return array Modified props array + */ + protected static function parsePath(array $props): array + { + // extract params, the rest is the path; + // only do this if not explicitly disabled (set to `false`) + if (isset($props['params']) === false || $props['params'] !== false) { + $extract = Params::extract($props['path']); + $props['params'] ??= $extract['params']; + $props['path'] = $extract['path']; + $props['slash'] ??= $extract['slash']; + + return $props; + } + + // use the full path; + // automatically detect the trailing slash from it if possible + if (is_string($props['path']) === true) { + $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 new file mode 100644 index 0000000..0f8d697 --- /dev/null +++ b/public/kirby/src/Http/Url.php @@ -0,0 +1,258 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Url +{ + /** + * The base Url to build absolute Urls from + */ + public static string|null $home = '/'; + + /** + * The current Uri object as string + */ + public static string|null $current = null; + + /** + * Facade for all Uri object methods + */ + public static function __callStatic(string $method, array $arguments) + { + $uri = new Uri($arguments[0] ?? static::current()); + return $uri->$method(...array_slice($arguments, 1)); + } + + /** + * Url Builder + * Actually just a factory for `new Uri($parts)` + */ + public static function build( + array $parts = [], + string|null $url = null + ): string { + $url ??= static::current(); + $uri = new Uri($url); + return $uri->clone($parts)->toString(); + } + + /** + * Returns the current url with all bells and whistles + */ + public static function current(): string + { + return static::$current ??= static::toObject()->toString(); + } + + /** + * Returns the url for the current directory + */ + public static function currentDir(): string + { + return dirname(static::current()); + } + + /** + * Tries to fix a broken url without protocol + * @psalm-return ($url is null ? string|null : string) + */ + public static function fix(string|null $url = null): string|null + { + // make sure to not touch absolute urls + if (!preg_match('!^(https|http|ftp)\:\/\/!i', $url ?? '')) { + return 'http://' . $url; + } + + return $url; + } + + /** + * Returns the home url if defined + */ + public static function home(): string + { + return static::$home; + } + + /** + * Returns the url to the executed script + */ + public static function index(array $props = []): string + { + return Uri::index($props)->toString(); + } + + /** + * Checks if an URL is absolute + */ + public static function isAbsolute(string|null $url = null): bool + { + // matches the following groups of URLs: + // //example.com/uri + // http://example.com/uri, https://example.com/uri, ftp://example.com/uri + // mailto:example@example.com, geo:49.0158,8.3239?z=11 + return + $url !== null && + preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1; + } + + /** + * Convert a relative path into an absolute URL + */ + public static function makeAbsolute( + string|null $path = null, + string|null $home = null + ): string { + if ($path === '' || $path === '/' || $path === null) { + return $home ?? static::home(); + } + + if (str_starts_with($path, '#') === true) { + return $path; + } + + if (static::isAbsolute($path) === true) { + return $path; + } + + // build the full url + $path = ltrim($path, '/'); + $home ??= static::home(); + + if ($path === '') { + return $home; + } + + if ($home === '/') { + return '/' . $path; + } + + return $home . '/' . $path; + } + + /** + * Returns the path for the given url + */ + public static function path( + string|array|null $url = null, + bool $leadingSlash = false, + bool $trailingSlash = false + ): string { + return Url::toObject($url) + ->path() + ->toString($leadingSlash, $trailingSlash); + } + + /** + * Returns the query for the given url + */ + public static function query(string|array|null $url = null): string + { + return Url::toObject($url)->query()->toString(); + } + + /** + * Return the last url the user has been on if detectable + */ + public static function last(): string + { + return Environment::getGlobally('HTTP_REFERER', ''); + } + + /** + * Shortens the Url by removing all unnecessary parts + */ + public static function short( + string|null $url = null, + int $length = 0, + bool $base = false, + string $rep = '…' + ): string { + $uri = static::toObject($url); + + $uri->fragment = null; + $uri->query = null; + $uri->password = null; + $uri->port = null; + $uri->scheme = null; + $uri->username = null; + + // remove the trailing slash from the path + $uri->slash = false; + + $url = $base ? $uri->base() : $uri->toString(); + $url = str_replace('www.', '', $url ?? ''); + + return Str::short($url, $length, $rep); + } + + /** + * Removes the path from the Url + */ + public static function stripPath(string|null $url = null): string + { + return static::toObject($url)->setPath(null)->toString(); + } + + /** + * Removes the query string from the Url + */ + public static function stripQuery(string|null $url = null): string + { + return static::toObject($url)->setQuery(null)->toString(); + } + + /** + * Removes the fragment (hash) from the Url + */ + public static function stripFragment(string|null $url = null): string + { + return static::toObject($url)->setFragment(null)->toString(); + } + + /** + * Smart resolver for internal and external urls + */ + public static function to( + string|null $path = null, + array|null $options = null + ): string { + // make sure $path is string + $path ??= ''; + + // keep relative urls + if ( + str_starts_with($path, './') === true || + str_starts_with($path, '../') === true + ) { + return $path; + } + + $url = static::makeAbsolute($path); + + if ($options === null) { + return $url; + } + + return (new Uri($url, $options))->toString(); + } + + /** + * Converts the Url to a Uri object + */ + public static function toObject(string|null $url = null): Uri + { + return $url === null ? Uri::current() : new Uri($url); + } +} diff --git a/public/kirby/src/Http/Visitor.php b/public/kirby/src/Http/Visitor.php new file mode 100644 index 0000000..f4e57fb --- /dev/null +++ b/public/kirby/src/Http/Visitor.php @@ -0,0 +1,231 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Visitor +{ + protected string|null $ip = null; + protected string|null $userAgent = null; + protected string|null $acceptedLanguage = null; + protected string|null $acceptedMimeType = null; + + /** + * Creates a new visitor object. + * Optional arguments can be passed to + * modify the information about the visitor. + * + * By default everything is pulled from $_SERVER + */ + public function __construct(array $arguments = []) + { + $ip = $arguments['ip'] ?? null; + $ip ??= Environment::getGlobally('REMOTE_ADDR', ''); + $agent = $arguments['userAgent'] ?? null; + $agent ??= Environment::getGlobally('HTTP_USER_AGENT', ''); + $language = $arguments['acceptedLanguage'] ?? null; + $language ??= Environment::getGlobally('HTTP_ACCEPT_LANGUAGE', ''); + $mime = $arguments['acceptedMimeType'] ?? null; + $mime ??= Environment::getGlobally('HTTP_ACCEPT', ''); + + $this->ip($ip); + $this->userAgent($agent); + $this->acceptedLanguage($language); + $this->acceptedMimeType($mime); + } + + /** + * Sets the accepted language if + * provided or returns the user's + * accepted language otherwise + * + * @return $this|\Kirby\Toolkit\Obj|null + */ + public function acceptedLanguage( + string|null $acceptedLanguage = null + ): static|Obj|null { + if ($acceptedLanguage === null) { + return $this->acceptedLanguages()->first(); + } + + $this->acceptedLanguage = $acceptedLanguage; + return $this; + } + + /** + * Returns an array of all accepted languages + * including their quality and locale + * + * @return \Kirby\Toolkit\Collection<\Kirby\Toolkit\Obj> + */ + public function acceptedLanguages(): Collection + { + $accepted = Str::accepted($this->acceptedLanguage); + $languages = []; + + foreach ($accepted as $language) { + $value = $language['value']; + $parts = Str::split($value, '-'); + $code = isset($parts[0]) ? Str::lower($parts[0]) : null; + $region = isset($parts[1]) ? Str::upper($parts[1]) : null; + $locale = $region ? $code . '_' . $region : $code; + + $languages[$locale] = new Obj([ + 'code' => $code, + 'locale' => $locale, + 'original' => $value, + 'quality' => $language['quality'], + 'region' => $region, + ]); + } + + return new Collection($languages); + } + + /** + * Checks if the user accepts the given language + */ + public function acceptsLanguage(string $code): bool + { + $mode = Str::contains($code, '_') === true ? 'locale' : 'code'; + + foreach ($this->acceptedLanguages() as $language) { + if ($language->$mode() === $code) { + return true; + } + } + + return false; + } + + /** + * Sets the accepted mime type if + * provided or returns the user's + * accepted mime type otherwise + * + * @return $this|\Kirby\Toolkit\Obj|null + */ + public function acceptedMimeType( + string|null $acceptedMimeType = null + ): static|Obj|null { + if ($acceptedMimeType === null) { + return $this->acceptedMimeTypes()->first(); + } + + $this->acceptedMimeType = $acceptedMimeType; + return $this; + } + + /** + * Returns a collection of all accepted mime types + */ + public function acceptedMimeTypes(): Collection + { + $accepted = Str::accepted($this->acceptedMimeType); + $mimes = []; + + foreach ($accepted as $mime) { + $mimes[$mime['value']] = new Obj([ + 'type' => $mime['value'], + 'quality' => $mime['quality'], + ]); + } + + return new Collection($mimes); + } + + /** + * Checks if the user accepts the given mime type + */ + public function acceptsMimeType(string $mimeType): bool + { + return Mime::isAccepted($mimeType, $this->acceptedMimeType); + } + + /** + * Returns the MIME type from the provided list that + * is most accepted (= preferred) by the visitor + * @since 3.3.0 + * + * @param string ...$mimeTypes MIME types to query for + * @return string|null Preferred MIME type + */ + public function preferredMimeType(string ...$mimeTypes): string|null + { + foreach ($this->acceptedMimeTypes() as $accepted) { + // look for direct matches + if (in_array($accepted->type(), $mimeTypes, true) === true) { + return $accepted->type(); + } + + // test each option against wildcard `Accept` values + foreach ($mimeTypes as $expected) { + if (Mime::matches($expected, $accepted->type()) === true) { + return $expected; + } + } + } + + return null; + } + + /** + * Returns true if the visitor prefers a JSON response over + * an HTML response based on the `Accept` request header + * @since 3.3.0 + */ + public function prefersJson(): bool + { + $preferred = $this->preferredMimeType('application/json', 'text/html'); + return $preferred === 'application/json'; + } + + /** + * Sets the ip address if provided + * or returns the ip of the current + * visitor otherwise + * + * @return $this|string|null + */ + public function ip(string|null $ip = null): static|string|null + { + if ($ip === null) { + return $this->ip; + } + + $this->ip = $ip; + return $this; + } + + /** + * Sets the user agent if provided + * or returns the user agent string of + * the current visitor otherwise + * + * @return $this|string|null + */ + public function userAgent(string|null $userAgent = null): static|string|null + { + if ($userAgent === null) { + return $this->userAgent; + } + + $this->userAgent = $userAgent; + return $this; + } +} diff --git a/public/kirby/src/Image/Camera.php b/public/kirby/src/Image/Camera.php new file mode 100644 index 0000000..dd5e044 --- /dev/null +++ b/public/kirby/src/Image/Camera.php @@ -0,0 +1,70 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Camera implements Stringable +{ + protected string|null $make; + protected string|null $model; + + public function __construct(array $exif) + { + $this->make = $exif['Make'] ?? null; + $this->model = $exif['Model'] ?? null; + } + + /** + * Returns the make of the camera + */ + public function make(): string|null + { + return $this->make; + } + + /** + * Returns the camera model + */ + public function model(): string|null + { + return $this->model; + } + + /** + * Converts the object into a nicely readable array + */ + public function toArray(): array + { + return [ + 'make' => $this->make, + 'model' => $this->model + ]; + } + + /** + * Returns the full make + model name + */ + public function __toString(): string + { + return trim($this->make . ' ' . $this->model); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/public/kirby/src/Image/Darkroom.php b/public/kirby/src/Image/Darkroom.php new file mode 100644 index 0000000..2769c28 --- /dev/null +++ b/public/kirby/src/Image/Darkroom.php @@ -0,0 +1,150 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Darkroom +{ + public static array $types = [ + 'gd' => GdLib::class, + 'imagick' => Imagick::class, + 'im' => ImageMagick::class + ]; + + public function __construct( + protected array $settings = [] + ) { + $this->settings = [...$this->defaults(), ...$settings]; + } + + /** + * Creates a new Darkroom instance + * for the given type/driver + * + * @throws \Exception + */ + public static function factory(string $type, array $settings = []): static + { + if (isset(static::$types[$type]) === false) { + throw new Exception(message: 'Invalid Darkroom type'); + } + + return new static::$types[$type]($settings); + } + + /** + * Returns the default thumb settings + */ + protected function defaults(): array + { + return [ + 'blur' => false, + 'crop' => false, + 'format' => null, + 'grayscale' => false, + 'height' => null, + 'quality' => 90, + 'scaleHeight' => null, + 'scaleWidth' => null, + 'sharpen' => null, + 'width' => null, + ]; + } + + /** + * Normalizes all thumb options + */ + protected function options(array $options = []): array + { + $options = [ + ...$this->settings, + ...$options, + // ensure quality isn't unset by provided options + 'quality' => $options['quality'] ?? $this->settings['quality'] + ]; + + // normalize the crop option + if ($options['crop'] === true) { + $options['crop'] = 'center'; + } + + // normalize the blur option + if ($options['blur'] === true) { + $options['blur'] = 10; + } + + // normalize the grayscale option + if (isset($options['greyscale']) === true) { + $options['grayscale'] = $options['greyscale']; + unset($options['greyscale']); + } + + // normalize the bw option + if (isset($options['bw']) === true) { + $options['grayscale'] = $options['bw']; + unset($options['bw']); + } + + // normalize the sharpen option + if ($options['sharpen'] === true) { + $options['sharpen'] = 50; + } + + return $options; + } + + /** + * Calculates the dimensions of the final thumb based + * on the given options and returns a full array with + * all the final options to be used for the image generator + */ + public function preprocess(string $file, array $options = []): array + { + $options = $this->options($options); + $image = new Image($file); + + $options['sourceWidth'] = $image->width(); + $options['sourceHeight'] = $image->height(); + + $dimensions = $image->dimensions(); + $thumbDimensions = $dimensions->thumb($options); + + $options['width'] = $thumbDimensions->width(); + $options['height'] = $thumbDimensions->height(); + + // scale ratio compared to the source dimensions + $options['scaleWidth'] = Focus::ratio( + $options['width'], + $options['sourceWidth'] + ); + $options['scaleHeight'] = Focus::ratio( + $options['height'], + $options['sourceHeight'] + ); + + return $options; + } + + /** + * This method must be replaced by the driver to run the + * actual image processing job. + */ + public function process(string $file, array $options = []): array + { + return $this->preprocess($file, $options); + } +} diff --git a/public/kirby/src/Image/Darkroom/GdLib.php b/public/kirby/src/Image/Darkroom/GdLib.php new file mode 100644 index 0000000..049d097 --- /dev/null +++ b/public/kirby/src/Image/Darkroom/GdLib.php @@ -0,0 +1,130 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class GdLib extends Darkroom +{ + /** + * Processes the image with the SimpleImage library + */ + public function process(string $file, array $options = []): array + { + $options = $this->preprocess($file, $options); + $mime = $this->mime($options); + + $image = new SimpleImage(); + $image->fromFile($file); + $image->autoOrient(); + + $image = $this->resize($image, $options); + $image = $this->blur($image, $options); + $image = $this->grayscale($image, $options); + $image = $this->sharpen($image, $options); + + $image->toFile($file, $mime, $options); + + return $options; + } + + /** + * Wrapper around SimpleImage's resize and crop methods + */ + protected function resize(SimpleImage $image, array $options): SimpleImage + { + // just resize, no crop + if ($options['crop'] === false) { + return $image->resize($options['width'], $options['height']); + } + + // crop based on focus point + if (Focus::isFocalPoint($options['crop']) === true) { + // get crop coords for focal point: + // if image needs to be cropped, crop before resizing + if ($focus = Focus::coords( + $options['crop'], + $options['sourceWidth'], + $options['sourceHeight'], + $options['width'], + $options['height'] + )) { + $image->crop( + $focus['x1'], + $focus['y1'], + $focus['x2'], + $focus['y2'] + ); + } + + return $image->thumbnail($options['width'], $options['height']); + } + + // normal crop with crop anchor + return $image->thumbnail( + $options['width'], + $options['height'] ?? $options['width'], + $options['crop'] + ); + } + + /** + * Applies the correct blur settings for SimpleImage + */ + protected function blur(SimpleImage $image, array $options): SimpleImage + { + if ($options['blur'] === false) { + return $image; + } + + return $image->blur('gaussian', (int)$options['blur']); + } + + /** + * Applies grayscale conversion if activated in the options. + */ + protected function grayscale(SimpleImage $image, array $options): SimpleImage + { + if ($options['grayscale'] === false) { + return $image; + } + + return $image->desaturate(); + } + + /** + * Applies sharpening if activated in the options. + */ + protected function sharpen(SimpleImage $image, array $options): SimpleImage + { + if (is_int($options['sharpen']) === false) { + return $image; + } + + return $image->sharpen($options['sharpen']); + } + + /** + * Returns mime type based on `format` option + */ + protected function mime(array $options): string|null + { + if ($options['format'] === null) { + return null; + } + + return Mime::fromExtension($options['format']); + } +} diff --git a/public/kirby/src/Image/Darkroom/ImageMagick.php b/public/kirby/src/Image/Darkroom/ImageMagick.php new file mode 100644 index 0000000..bc3f5d9 --- /dev/null +++ b/public/kirby/src/Image/Darkroom/ImageMagick.php @@ -0,0 +1,236 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @deprecated 5.1.0 Use `imagick` in the `thumbs.driver` config option instead + * @todo Remove in 7.0.0 + */ +class ImageMagick extends Darkroom +{ + /** + * Applies the blur settings + */ + protected function blur(string $file, array $options): string|null + { + if ($options['blur'] !== false) { + return '-blur ' . escapeshellarg('0x' . $options['blur']); + } + + return null; + } + + /** + * Keep animated gifs + */ + protected function coalesce(string $file, array $options): string|null + { + if (F::extension($file) === 'gif') { + return '-coalesce'; + } + + return null; + } + + /** + * Creates the convert command with the right path to the binary file + */ + protected function convert(string $file, array $options): string + { + $command = escapeshellarg($options['bin']); + + // default is limiting to single-threading to keep CPU usage sane + $command .= ' -limit thread ' . escapeshellarg($options['threads']); + + // append input file + return $command . ' ' . escapeshellarg($file); + } + + /** + * Returns additional default parameters for imagemagick + */ + protected function defaults(): array + { + return parent::defaults() + [ + 'bin' => 'convert', + 'interlace' => false, + 'threads' => 1, + ]; + } + + /** + * Applies the correct settings for grayscale images + */ + protected function grayscale(string $file, array $options): string|null + { + if ($options['grayscale'] === true) { + return '-colorspace gray'; + } + + return null; + } + + /** + * Applies sharpening if activated in the options. + */ + protected function sharpen(string $file, array $options): string|null + { + if (is_int($options['sharpen']) === false) { + return null; + } + + $amount = max(1, min(100, $options['sharpen'])) / 100; + return '-sharpen ' . escapeshellarg('0x' . $amount); + } + + /** + * Applies the correct settings for interlaced JPEGs if + * activated via options + */ + protected function interlace(string $file, array $options): string|null + { + if ($options['interlace'] === true) { + return '-interlace line'; + } + + return null; + } + + /** + * Creates and runs the full imagemagick command + * to process the image + * + * @throws \Exception + */ + public function process(string $file, array $options = []): array + { + $options = $this->preprocess($file, $options); + $command = []; + + $command[] = $this->convert($file, $options); + $command[] = $this->strip($file, $options); + $command[] = $this->interlace($file, $options); + $command[] = $this->coalesce($file, $options); + $command[] = $this->grayscale($file, $options); + $command[] = '-auto-orient'; + $command[] = $this->resize($file, $options); + $command[] = $this->quality($file, $options); + $command[] = $this->blur($file, $options); + $command[] = $this->sharpen($file, $options); + $command[] = $this->save($file, $options); + + // remove all null values and join the parts + $command = implode(' ', array_filter($command)); + + // try to execute the command + exec($command, $output, $return); + + // log broken commands + if ($return !== 0) { + throw new Exception(message: 'The imagemagick convert command could not be executed: ' . $command); + } + + return $options; + } + + /** + * Applies the correct JPEG compression quality settings + */ + protected function quality(string $file, array $options): string + { + return '-quality ' . escapeshellarg($options['quality']); + } + + /** + * Creates the correct options to crop or resize the image + * and translates the crop positions for imagemagick + */ + protected function resize(string $file, array $options): string + { + // simple resize + if ($options['crop'] === false) { + return '-thumbnail ' . escapeshellarg(sprintf('%sx%s!', $options['width'], $options['height'])); + } + + // crop based on focus point + if (Focus::isFocalPoint($options['crop']) === true) { + if ($focus = Focus::coords( + $options['crop'], + $options['sourceWidth'], + $options['sourceHeight'], + $options['width'], + $options['height'] + )) { + return sprintf( + '-crop %sx%s+%s+%s -resize %sx%s^', + $focus['width'], + $focus['height'], + $focus['x1'], + $focus['y1'], + $options['width'], + $options['height'] + ); + } + } + + // translate the gravity option into something imagemagick understands + $gravity = match ($options['crop'] ?? null) { + 'top left' => 'NorthWest', + 'top' => 'North', + 'top right' => 'NorthEast', + 'left' => 'West', + 'right' => 'East', + 'bottom left' => 'SouthWest', + 'bottom' => 'South', + 'bottom right' => 'SouthEast', + default => 'Center' + }; + + $command = '-thumbnail ' . escapeshellarg(sprintf('%sx%s^', $options['width'], $options['height'])); + $command .= ' -gravity ' . escapeshellarg($gravity); + $command .= ' -crop ' . escapeshellarg(sprintf('%sx%s+0+0', $options['width'], $options['height'])); + + return $command; + } + + /** + * Creates the option for the output file + */ + protected function save(string $file, array $options): string + { + if ($options['format'] !== null) { + $file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format']; + } + + return escapeshellarg($file); + } + + /** + * Removes all metadata from the image + */ + protected function strip(string $file, array $options): string + { + if (F::extension($file) === 'png') { + // ImageMagick does not support keeping ICC profiles while + // stripping other privacy- and security-related information, + // such as GPS data; so discard all color profiles for PNG files + // (tested with ImageMagick 7.0.11-14 Q16 x86_64 2021-05-31) + return '-strip'; + } + + return ''; + } +} diff --git a/public/kirby/src/Image/Darkroom/Imagick.php b/public/kirby/src/Image/Darkroom/Imagick.php new file mode 100644 index 0000000..a301754 --- /dev/null +++ b/public/kirby/src/Image/Darkroom/Imagick.php @@ -0,0 +1,292 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + */ +class Imagick extends Darkroom +{ + protected function autoOrient(Image $image): Image + { + switch ($image->getImageOrientation()) { + case Image::ORIENTATION_TOPLEFT: + break; + case Image::ORIENTATION_TOPRIGHT: + $image->flopImage(); + break; + case Image::ORIENTATION_BOTTOMRIGHT: + $image->rotateImage('#000', 180); + break; + case Image::ORIENTATION_BOTTOMLEFT: + $image->flopImage(); + $image->rotateImage('#000', 180); + break; + case Image::ORIENTATION_LEFTTOP: + $image->flopImage(); + $image->rotateImage('#000', -90); + break; + case Image::ORIENTATION_RIGHTTOP: + $image->rotateImage('#000', 90); + break; + case Image::ORIENTATION_RIGHTBOTTOM: + $image->flopImage(); + $image->rotateImage('#000', 90); + break; + case Image::ORIENTATION_LEFTBOTTOM: + $image->rotateImage('#000', -90); + break; + default: // Invalid orientation + break; + } + + $image->setImageOrientation(Image::ORIENTATION_TOPLEFT); + return $image; + } + + /** + * Applies the blur settings + */ + protected function blur(Image $image, array $options): Image + { + if ($options['blur'] !== false) { + $image->blurImage(0.0, $options['blur']); + } + + return $image; + } + + /** + * Keep animated gifs + */ + protected function coalesce(Image $image): Image + { + if ($image->getImageMimeType() === 'image/gif') { + return $image->coalesceImages(); + } + + return $image; + } + + /** + * Returns additional default parameters for imagemagick + */ + protected function defaults(): array + { + return parent::defaults() + [ + 'interlace' => false, + 'profiles' => ['icc', 'icm'], + 'threads' => 1, + ]; + } + + /** + * Applies the correct settings for grayscale images + */ + protected function grayscale(Image $image, array $options): Image + { + if ($options['grayscale'] === true) { + $image->setImageColorspace(Image::COLORSPACE_GRAY); + } + + return $image; + } + + /** + * Applies the correct settings for interlaced JPEGs if + * activated via options + */ + protected function interlace(Image $image, array $options): Image + { + if ($options['interlace'] === true) { + $image->setInterlaceScheme(Image::INTERLACE_LINE); + } + + return $image; + } + + /** + * Creates and runs the full imagemagick command + * to process the image + * + * @throws \Exception + */ + public function process(string $file, array $options = []): array + { + $options = $this->preprocess($file, $options); + + $image = new Image($file); + $image = $this->threads($image, $options); + $image = $this->interlace($image, $options); + $image = $this->coalesce($image); + $image = $this->grayscale($image, $options); + $image = $this->autoOrient($image); + $image = $this->resize($image, $options); + $image = $this->quality($image, $options); + $image = $this->blur($image, $options); + $image = $this->sharpen($image, $options); + $image = $this->strip($image, $options); + + if ($this->save($image, $file, $options) === false) { + // @codeCoverageIgnoreStart + throw new Exception(message: 'The imagemagick result could not be generated'); + // @codeCoverageIgnoreEnd + } + + return $options; + } + + /** + * Applies the correct JPEG compression quality settings + */ + protected function quality(Image $image, array $options): Image + { + $image->setImageCompressionQuality($options['quality']); + return $image; + } + + /** + * Creates the correct options to crop or resize the image + * and translates the crop positions for imagemagick + */ + protected function resize(Image $image, array $options): Image + { + // simple resize + if ($options['crop'] === false) { + $image->thumbnailImage( + $options['width'], + $options['height'], + true + ); + + return $image; + } + + // crop based on focus point + if (Focus::isFocalPoint($options['crop']) === true) { + if ($focus = Focus::coords( + $options['crop'], + $options['sourceWidth'], + $options['sourceHeight'], + $options['width'], + $options['height'] + )) { + $image->cropImage( + $focus['width'], + $focus['height'], + $focus['x1'], + $focus['y1'] + ); + + $image->thumbnailImage( + $options['width'], + $options['height'], + true + ); + + return $image; + } + } + + // translate the gravity option into something imagemagick understands + $gravity = match ($options['crop'] ?? null) { + 'top left' => Image::GRAVITY_NORTHWEST, + 'top' => Image::GRAVITY_NORTH, + 'top right' => Image::GRAVITY_NORTHEAST, + 'left' => Image::GRAVITY_WEST, + 'right' => Image::GRAVITY_EAST, + 'bottom left' => Image::GRAVITY_SOUTHWEST, + 'bottom' => Image::GRAVITY_SOUTH, + 'bottom right' => Image::GRAVITY_SOUTHEAST, + default => Image::GRAVITY_CENTER + }; + + $landscape = $options['width'] >= $options['height']; + + $image->thumbnailImage( + $landscape ? $options['width'] : $image->getImageWidth(), + $landscape ? $image->getImageHeight() : $options['height'], + true + ); + + $image->setGravity($gravity); + $image->cropImage($options['width'], $options['height'], 0, 0); + + return $image; + } + + /** + * Creates the option for the output file + */ + protected function save(Image $image, string $file, array $options): bool + { + if ($options['format'] !== null) { + $file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format']; + } + + return $image->writeImages($file, true); + } + + /** + * Applies sharpening if activated in the options. + */ + protected function sharpen(Image $image, array $options): Image + { + if (is_int($options['sharpen']) === false) { + return $image; + } + + $amount = max(1, min(100, $options['sharpen'])) / 100; + $image->sharpenImage(0.0, $amount); + + return $image; + } + + /** + * Removes all metadata but ICC profiles from the image + */ + protected function strip(Image $image, array $options): Image + { + // strip all profiles but the ICC profile + $profiles = $image->getImageProfiles('*', false); + + foreach ($profiles as $profile) { + if (in_array($profile, $options['profiles'] ?? [], true) === false) { + $image->removeImageProfile($profile); + } + } + + // strip all properties + $properties = $image->getImageProperties('*', false); + + foreach ($properties as $property) { + $image->deleteImageProperty($property); + } + + return $image; + } + + /** + * Sets thread limit + */ + protected function threads(Image $image, array $options): Image + { + $image->setResourceLimit( + Image::RESOURCETYPE_THREAD, + $options['threads'] + ); + return $image; + } +} diff --git a/public/kirby/src/Image/Dimensions.php b/public/kirby/src/Image/Dimensions.php new file mode 100644 index 0000000..a8188b6 --- /dev/null +++ b/public/kirby/src/Image/Dimensions.php @@ -0,0 +1,409 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Dimensions implements Stringable +{ + public function __construct( + public int $width, + public int $height + ) { + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Echos the dimensions as width × height + */ + public function __toString(): string + { + return $this->width . ' × ' . $this->height; + } + + /** + * Crops the dimensions by width and height + * + * @return $this + */ + public function crop(int $width, int|null $height = null): static + { + $this->width = $width; + $this->height = $width; + + if ($height !== 0 && $height !== null) { + $this->height = $height; + } + + return $this; + } + + /** + * Returns the height + */ + public function height(): int + { + return $this->height; + } + + /** + * Recalculates the width and height to fit into the given box. + * + * ```php + * $dimensions = new Dimensions(1200, 768); + * $dimensions->fit(500); + * + * echo $dimensions->width(); + * // output: 500 + * + * echo $dimensions->height(); + * // output: 320 + * ``` + * + * @param int $box the max width and/or height + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + public function fit(int $box, bool $force = false): static + { + if ($this->width === 0 || $this->height === 0) { + $this->width = $box; + $this->height = $box; + return $this; + } + + $ratio = $this->ratio(); + + if ($this->width > $this->height) { + // wider than tall + if ($this->width > $box || $force === true) { + $this->width = $box; + } + $this->height = (int)round($this->width / $ratio); + } elseif ($this->height > $this->width) { + // taller than wide + if ($this->height > $box || $force === true) { + $this->height = $box; + } + $this->width = (int)round($this->height * $ratio); + } elseif ($this->width > $box) { + // width = height but bigger than box + $this->width = $box; + $this->height = $box; + } + + return $this; + } + + /** + * Recalculates the width and height to fit the given height + * + * ```php + * $dimensions = new Dimensions(1200, 768); + * $dimensions->fitHeight(500); + * + * echo $dimensions->width(); + * // output: 781 + * + * echo $dimensions->height(); + * // output: 500 + * ``` + * + * @param int|null $fit the max height + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + public function fitHeight( + int|null $fit = null, + bool $force = false + ): static { + return $this->fitSize('height', $fit, $force); + } + + /** + * Helper for fitWidth and fitHeight methods + * + * @param string $ref reference (width or height) + * @param int|null $fit the max width + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + protected function fitSize( + string $ref, + int|null $fit = null, + bool $force = false + ): static { + if ($fit === 0 || $fit === null) { + return $this; + } + + if ($this->$ref <= $fit && !$force) { + return $this; + } + + $ratio = $this->ratio(); + $mode = $ref === 'width'; + $this->width = $mode ? $fit : (int)round($fit * $ratio); + $this->height = !$mode ? $fit : (int)round($fit / $ratio); + + return $this; + } + + /** + * Recalculates the width and height to fit the given width + * + * ```php + * $dimensions = new Dimensions(1200, 768); + * $dimensions->fitWidth(500); + * + * echo $dimensions->width(); + * // output: 500 + * + * echo $dimensions->height(); + * // output: 320 + * ``` + * + * @param int|null $fit the max width + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + public function fitWidth( + int|null $fit = null, + bool $force = false + ): static { + return $this->fitSize('width', $fit, $force); + } + + /** + * Recalculates the dimensions by the width and height + * + * @param int|null $width the max height + * @param int|null $height the max width + * @return $this + */ + public function fitWidthAndHeight( + int|null $width = null, + int|null $height = null, + bool $force = false + ): static { + if ($this->width > $this->height) { + $this->fitWidth($width, $force); + + // do another check for the max height + if ($this->height > $height) { + $this->fitHeight($height); + } + } else { + $this->fitHeight($height, $force); + + // do another check for the max width + if ($this->width > $width) { + $this->fitWidth($width); + } + } + + return $this; + } + + /** + * Detect the dimensions for an image file + */ + public static function forImage(Image $image): static + { + if ($image->exists() === false) { + return new static(0, 0); + } + + $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) + }; + } + + /** + * Detect the dimensions for a svg file + */ + public static function forSvg(string $root): static + { + // avoid xml errors + libxml_use_internal_errors(true); + + $content = file_get_contents($root); + $height = 0; + $width = 0; + $xml = simplexml_load_string($content); + + if ($xml !== false) { + $attr = $xml->attributes(); + $rawWidth = $attr->width; + $width = (int)$rawWidth; + $rawHeight = $attr->height; + $height = (int)$rawHeight; + + // use viewbox values if direct attributes are 0 + // or based on percentages + if (empty($attr->viewBox) === false) { + $box = explode(' ', $attr->viewBox); + + // when using viewbox values, make sure to subtract + // first two box values from last two box values + // to retrieve the absolute dimensions + + if (Str::endsWith($rawWidth, '%') === true || $width === 0) { + $width = (int)($box[2] ?? 0) - (int)($box[0] ?? 0); + } + + if (Str::endsWith($rawHeight, '%') === true || $height === 0) { + $height = (int)($box[3] ?? 0) - (int)($box[1] ?? 0); + } + } + } + + return new static($width, $height); + } + + /** + * Checks if the dimensions are landscape + */ + public function landscape(): bool + { + return $this->width > $this->height; + } + + /** + * Returns a string representation of the orientation + */ + public function orientation(): string|false + { + if (!$this->ratio()) { + return false; + } + + if ($this->portrait() === true) { + return 'portrait'; + } + + if ($this->landscape() === true) { + return 'landscape'; + } + + return 'square'; + } + + /** + * Checks if the dimensions are portrait + */ + public function portrait(): bool + { + return $this->height > $this->width; + } + + /** + * Calculates and returns the ratio + * + * ```php + * $dimensions = new Dimensions(1200, 768); + * echo $dimensions->ratio(); + * // output: 1.5625 + * ``` + */ + public function ratio(): float + { + if ($this->width !== 0 && $this->height !== 0) { + return $this->width / $this->height; + } + + return 0.0; + } + + /** + * Resizes image + * @return $this + */ + public function resize( + int|null $width = null, + int|null $height = null, + bool $force = false + ): static { + return $this->fitWidthAndHeight($width, $height, $force); + } + + /** + * Checks if the dimensions are square + */ + public function square(): bool + { + return $this->width === $this->height; + } + + /** + * Resize and crop + * + * @return $this + */ + public function thumb(array $options = []): static + { + $width = $options['width'] ?? null; + $height = $options['height'] ?? null; + $crop = $options['crop'] ?? false; + $method = $crop !== false ? 'crop' : 'resize'; + + if ($width === null && $height === null) { + return $this; + } + + return $this->$method($width, $height); + } + + /** + * Converts the dimensions object + * to a plain PHP array + */ + public function toArray(): array + { + return [ + 'width' => $this->width(), + 'height' => $this->height(), + 'ratio' => $this->ratio(), + 'orientation' => $this->orientation(), + ]; + } + + /** + * Returns the width + */ + public function width(): int + { + return $this->width; + } +} diff --git a/public/kirby/src/Image/Exif.php b/public/kirby/src/Image/Exif.php new file mode 100644 index 0000000..d5d0f0a --- /dev/null +++ b/public/kirby/src/Image/Exif.php @@ -0,0 +1,216 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Exif +{ + /** + * The raw exif array + */ + protected array $data = []; + + protected string|null $aperture = null; + protected Camera|null $camera = null; + protected string|null $exposure = null; + protected string|null $focalLength = null; + protected bool|null $isColor = null; + protected array|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($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(); + } + + /** + * Returns the raw data array from the parser + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns the Camera object + */ + public function camera(): Camera + { + return $this->camera ??= new Camera($this->data); + } + + /** + * Returns the location object + */ + public function location(): Location + { + return $this->location ??= new Location($this->data); + } + + /** + * Returns the timestamp + */ + public function timestamp(): string|null + { + return $this->timestamp; + } + + /** + * Returns the exposure + */ + public function exposure(): string|null + { + return $this->exposure; + } + + /** + * Returns the aperture + */ + public function aperture(): string|null + { + return $this->aperture; + } + + /** + * Returns the iso value + */ + public function iso(): string|null + { + if (is_array($this->iso) === true) { + return A::first($this->iso); + } + + return $this->iso; + } + + /** + * Checks if this is a color picture + */ + public function isColor(): bool|null + { + return $this->isColor; + } + + /** + * Checks if this is a bw picture + */ + public function isBW(): bool|null + { + return ($this->isColor !== null) ? $this->isColor === false : null; + } + + /** + * Returns the focal length + */ + public function focalLength(): string|null + { + return $this->focalLength; + } + + /** + * Read the exif data of the image object if possible + */ + public static function read(string $root): array + { + // @codeCoverageIgnoreStart + if (function_exists('exif_read_data') === false) { + return []; + } + // @codeCoverageIgnoreEnd + + $data = @exif_read_data($root); + return is_array($data) ? $data : []; + } + + /** + * Get all computed data + */ + protected function computed(): array + { + return $this->data['COMPUTED'] ?? []; + } + + /** + * Returns the exif orientation + */ + public function orientation(): int + { + return $this->orientation; + } + + /** + * Return the timestamp when the picture has been taken + */ + protected function parseTimestamp(): string + { + if (isset($this->data['DateTimeOriginal']) === true) { + if ($time = strtotime($this->data['DateTimeOriginal'])) { + return (string)$time; + } + } + + return $this->data['FileDateTime'] ?? $this->image->modified(); + } + + /** + * Return the focal length + */ + protected function parseFocalLength(): string|null + { + return + $this->data['FocalLength'] ?? + $this->data['FocalLengthIn35mmFilm'] ?? + null; + } + + /** + * Converts the object into a nicely readable array + */ + public function toArray(): array + { + return [ + 'camera' => $this->camera()->toArray(), + 'location' => $this->location()->toArray(), + 'timestamp' => $this->timestamp(), + 'exposure' => $this->exposure(), + 'aperture' => $this->aperture(), + 'iso' => $this->iso(), + 'focalLength' => $this->focalLength(), + 'isColor' => $this->isColor() + ]; + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + ...$this->toArray(), + 'camera' => $this->camera(), + 'location' => $this->location() + ]; + } +} diff --git a/public/kirby/src/Image/Focus.php b/public/kirby/src/Image/Focus.php new file mode 100644 index 0000000..da1dc73 --- /dev/null +++ b/public/kirby/src/Image/Focus.php @@ -0,0 +1,110 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Focus +{ + /** + * Generates crop coordinates based on focal point + */ + public static function coords( + string $crop, + int $sourceWidth, + int $sourceHeight, + int $width, + int $height + ): array|null { + [$x, $y] = static::parse($crop); + + // determine aspect ratios + $ratioSource = static::ratio($sourceWidth, $sourceHeight); + $ratioThumb = static::ratio($width, $height); + + // no cropping necessary + if ($ratioSource == $ratioThumb) { + return null; + } + + // defaults + $width = $sourceWidth; + $height = $sourceHeight; + + if ($ratioThumb > $ratioSource) { + $height = $sourceWidth / $ratioThumb; + } else { + $width = $sourceHeight * $ratioThumb; + } + + // calculate focus for original image + $x = $sourceWidth * $x; + $y = $sourceHeight * $y; + + $x1 = max(0, $x - $width / 2); + $y1 = max(0, $y - $height / 2); + + // off canvas? + if ($x1 + $width > $sourceWidth) { + $x1 = $sourceWidth - $width; + } + + if ($y1 + $height > $sourceHeight) { + $y1 = $sourceHeight - $height; + } + + return [ + 'x1' => (int)floor($x1), + 'y1' => (int)floor($y1), + 'x2' => (int)floor($x1 + $width), + 'y2' => (int)floor($y1 + $height), + 'width' => (int)floor($width), + 'height' => (int)floor($height), + ]; + } + + public static function isFocalPoint(string $value): bool + { + return Str::contains($value, '%') === true; + } + + /** + * Transforms the focal point's string value (from content field) + * to a [x, y] array (values 0.0-1.0) + */ + public static function parse(string $value): array + { + // support for former Focus plugin + if (Str::startsWith($value, '{') === true) { + $focus = json_decode($value); + return [$focus->x, $focus->y]; + } + + preg_match_all("/(\d{1,3}\.?\d*)[%|,|\s]*/", $value, $points); + + return A::map( + $points[1], + function ($point) { + $point = (float)$point; + $point = $point > 1 ? $point / 100 : $point; + return round($point, 3); + } + ); + } + + /** + * Calculates the image ratio + */ + public static function ratio(int $width, int $height): float + { + return $height !== 0 ? $width / $height : 0; + } +} diff --git a/public/kirby/src/Image/Image.php b/public/kirby/src/Image/Image.php new file mode 100644 index 0000000..6a24082 --- /dev/null +++ b/public/kirby/src/Image/Image.php @@ -0,0 +1,233 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Image extends File +{ + protected Exif|null $exif = null; + protected Dimensions|null $dimensions = null; + + public static array $resizableTypes = [ + 'avif', + 'jpg', + 'jpeg', + 'gif', + 'png', + 'webp' + ]; + + public static array $viewableTypes = [ + 'avif', + 'jpg', + 'jpeg', + 'gif', + 'png', + 'svg', + 'webp' + ]; + + /** + * Validation rules to be used for `::match()` + */ + public static array $validations = [ + 'maxsize' => ['size', 'max'], + 'minsize' => ['size', 'min'], + 'maxwidth' => ['width', 'max'], + 'minwidth' => ['width', 'min'], + 'maxheight' => ['height', 'max'], + 'minheight' => ['height', 'min'], + 'orientation' => ['orientation', 'same'] + ]; + + /** + * Returns the `` tag for the image object + */ + public function __toString(): string + { + return $this->html(); + } + + /** + * Returns the dimensions of the file if possible + */ + public function dimensions(): Dimensions + { + if ($this->dimensions !== null) { + return $this->dimensions; + } + + if (in_array($this->mime(), [ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/jp2', + 'image/png', + 'image/webp' + ], true)) { + return $this->dimensions = Dimensions::forImage($this); + } + + if ($this->extension() === 'svg') { + return $this->dimensions = Dimensions::forSvg($this->root); + } + + return $this->dimensions = new Dimensions(0, 0); + } + + /** + * Returns the exif object for this file (if image) + */ + public function exif(): Exif + { + return $this->exif ??= new Exif($this); + } + + /** + * Returns the height of the asset + */ + public function height(): int + { + return $this->dimensions()->height(); + } + + /** + * Converts the file to html + */ + public function html(array $attr = []): string + { + $model = match (true) { + $this->model instanceof FileVersion => $this->model->original(), + default => $this->model + }; + + // if no alt text explicitly provided, + // try to infer from model content file + if ( + $model !== null && + method_exists($model, 'content') === true && + $model->content() instanceof Content && + $model->content()->get('alt')->isNotEmpty() === true + ) { + $attr['alt'] ??= $model->content()->get('alt')->value(); + } + + if ($url = $this->url()) { + return Html::img($url, $attr); + } + + throw new LogicException( + message: 'Calling Image::html() requires that the URL property is not null' + ); + } + + /** + * Returns the PHP imagesize array + */ + public function imagesize(): array + { + return getimagesize($this->root); + } + + /** + * Checks if the dimensions of the asset are portrait + */ + public function isPortrait(): bool + { + return $this->dimensions()->portrait(); + } + + /** + * Checks if the dimensions of the asset are landscape + */ + public function isLandscape(): bool + { + return $this->dimensions()->landscape(); + } + + /** + * Checks if the dimensions of the asset are square + */ + public function isSquare(): bool + { + return $this->dimensions()->square(); + } + + /** + * Checks if the file is a resizable image + */ + public function isResizable(): bool + { + return in_array($this->extension(), static::$resizableTypes, true) === true; + } + + /** + * Checks if a preview can be displayed for the file + * in the Panel or in the frontend + */ + public function isViewable(): bool + { + return in_array($this->extension(), static::$viewableTypes, true) === true; + } + + /** + * Returns the ratio of the asset + */ + public function ratio(): float + { + return $this->dimensions()->ratio(); + } + + /** + * Returns the orientation as string + * `landscape` | `portrait` | `square` + */ + public function orientation(): string|false + { + return $this->dimensions()->orientation(); + } + + /** + * Converts the object to an array + */ + public function toArray(): array + { + $array = [ + ...parent::toArray(), + 'dimensions' => $this->dimensions()->toArray(), + 'exif' => $this->exif()->toArray(), + ]; + + ksort($array); + + return $array; + } + + /** + * Returns the width of the asset + */ + public function width(): int + { + return $this->dimensions()->width(); + } +} diff --git a/public/kirby/src/Image/Location.php b/public/kirby/src/Image/Location.php new file mode 100644 index 0000000..6894704 --- /dev/null +++ b/public/kirby/src/Image/Location.php @@ -0,0 +1,118 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Location implements Stringable +{ + protected float|null $lat = null; + protected float|null $lng = null; + + /** + * Constructor + * + * @param array $exif The entire exif array + */ + public function __construct(array $exif) + { + if ( + isset($exif['GPSLatitude']) === true && + isset($exif['GPSLatitudeRef']) === true && + isset($exif['GPSLongitude']) === true && + isset($exif['GPSLongitudeRef']) === true + ) { + $this->lat = $this->gps( + $exif['GPSLatitude'], + $exif['GPSLatitudeRef'] + ); + $this->lng = $this->gps( + $exif['GPSLongitude'], + $exif['GPSLongitudeRef'] + ); + } + } + + /** + * Returns the latitude + */ + public function lat(): float|null + { + return $this->lat; + } + + /** + * Returns the longitude + */ + public function lng(): float|null + { + return $this->lng; + } + + /** + * Converts the gps coordinates + */ + protected function gps(array $coord, string $hemi): float + { + $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; + + $hemi = strtoupper($hemi); + $flip = ($hemi === 'W' || $hemi === 'S') ? -1 : 1; + + return $flip * ($degrees + $minutes / 60 + $seconds / 3600); + } + + /** + * Converts coordinates to floats + */ + protected function num(string $part): float + { + $parts = explode('/', $part); + + if (count($parts) === 1) { + return (float)$parts[0]; + } + + return (float)($parts[0]) / (float)($parts[1]); + } + + /** + * Converts the object into a nicely readable array + */ + public function toArray(): array + { + return [ + 'lat' => $this->lat(), + 'lng' => $this->lng() + ]; + } + + /** + * Echos the entire location as lat, lng + */ + public function __toString(): string + { + return trim($this->lat() . ', ' . $this->lng(), ','); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/public/kirby/src/Image/QrCode.php b/public/kirby/src/Image/QrCode.php new file mode 100644 index 0000000..5619ad7 --- /dev/null +++ b/public/kirby/src/Image/QrCode.php @@ -0,0 +1,1614 @@ +, + * Lukas Bestle + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * QR Code® is a registered trademark of DENSO WAVE INCORPORATED. + * + * The code of this class is based on: + * https://github.com/psyon/php-qrcode + * + * qrcode.php - Generate QR Codes. MIT license. + * + * Copyright for portions of this project are held by Kreative Software, 2016-2018. + * All other copyright for the project are held by Donald Becker, 2019 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +class QrCode implements Stringable +{ + public function __construct(public string $data) + { + } + + /** + * Returns the QR code as a PNG data URI + * + * @param int|null $size Image width/height in pixels, defaults to a size per module of 4x4 + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + * @param int $border Border size in number of modules + */ + public function toDataUri( + int|null $size = null, + string $color = '#000000', + string $back = '#ffffff', + int $border = 4 + ): string { + $image = $this->toImage($size, $color, $back, $border); + + ob_start(); + imagepng($image); + imagedestroy($image); + $data = ob_get_contents(); + ob_end_clean(); + + return 'data:image/png;base64,' . base64_encode($data); + } + + /** + * Returns the QR code as a GdImage object + * + * @param int|null $size Image width/height in pixels, defaults to a size per module of 4x4 + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + * @param int $border Border size in number of modules + */ + public function toImage( + int|null $size = null, + string $color = '#000000', + string $back = '#ffffff', + int $border = 4 + ): GdImage { + // get code and size measurements + $code = $this->encode($border); + [$width, $height] = $this->measure($code); + $size ??= ceil($width * 4); + $ws = $size / $width; + $hs = $size / $height; + + // create image baseplate + $image = imagecreatetruecolor($size, $size); + + $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)); + $b = hexdec(substr($hex, 4, 2)); + return imagecolorallocate($image, $r, $g, $b); + }; + + $back = $allocateColor($back); + $color = $allocateColor($color); + imagefill($image, 0, 0, $back); + + // paint square for each module + $this->eachModuleGroup( + $code, + fn ($x, $y, $width, $height) => imagefilledrectangle( + $image, + floor($x * $ws), + floor($y * $hs), + floor($x * $ws + $ws * $width) - 1, + floor($y * $hs + $hs * $height) - 1, + $color + ) + ); + + return $image; + } + + /** + * Returns the QR code as `` element + * + * @param int|string|null $size Optional CSS width of the `` element + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + * @param int $border Border size in number of modules + */ + public function toSvg( + int|string|null $size = null, + string $color = '#000000', + string $back = '#ffffff', + int $border = 4 + ): string { + $code = $this->encode($border); + [$vbw, $vbh] = $this->measure($code); + + $modules = $this->eachModuleGroup( + $code, + fn ($x, $y, $width, $height) => 'M' . $x . ',' . $y . 'h' . $width . 'v' . $height . 'h-' . $width . 'z' + ); + + $size = $size ? ' style="width: ' . $size . '"' : ''; + + return '' . + '' . + '' . + ''; + } + + public function __toString(): string + { + return $this->toSvg(); + } + + /** + * Saves the QR code to a file. + * Supported formats: gif, jpg, jpeg, png, svg, webp + * + * @param string $file Path to the output file with one of the supported file extensions + * @param int|string|null $size Optional image width/height in pixels (defaults to a size per module of 4x4) or CSS width of the `` element + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + * @param int $border Border size in number of modules + */ + public function write( + string $file, + int|string|null $size = null, + string $color = '#000000', + string $back = '#ffffff', + int $border = 4 + ): void { + $format = F::extension($file); + $args = [$size, $color, $back, $border]; + + match ($format) { + 'gif' => imagegif($this->toImage(...$args), $file), + 'jpg', + 'jpeg' => imagejpeg($this->toImage(...$args), $file), + 'png' => imagepng($this->toImage(...$args), $file), + 'svg' => F::write($file, $this->toSvg(...$args)), + 'webp' => imagewebp($this->toImage(...$args), $file), + default => throw new InvalidArgumentException( + message: 'Cannot write QR code as ' . $format + ) + }; + } + + protected function applyMask(array $matrix, int $size, int $mask): array + { + for ($i = 0; $i < $size; $i++) { + for ($j = 0; $j < $size; $j++) { + if ($matrix[$i][$j] >= 4 && $this->mask($mask, $i, $j)) { + $matrix[$i][$j] ^= 1; + } + } + } + + return $matrix; + } + + protected function applyBestMask(array $matrix, int $size): array + { + $mask = 0; + $mmatrix = $this->applyMask($matrix, $size, $mask); + $penalty = $this->penalty($mmatrix, $size); + + for ($tmask = 1; $tmask < 8; $tmask++) { + $tmatrix = $this->applyMask($matrix, $size, $tmask); + $tpenalty = $this->penalty($tmatrix, $size); + + if ($tpenalty < $penalty) { + $mask = $tmask; + $mmatrix = $tmatrix; + $penalty = $tpenalty; + } + } + + return [$mask, $mmatrix]; + } + + protected function createMatrix(int $version, array $data): array + { + $size = $version * 4 + 17; + $matrix = []; + $row = array_fill(0, $size, 0); + + for ($i = 0; $i < $size; $i++) { + $matrix[] = $row; + } + + // finder patterns + for ($i = 0; $i < 8; $i++) { + for ($j = 0; $j < 8; $j++) { + $m = (($i == 7 || $j == 7) ? 2 : + (($i == 0 || $j == 0 || $i == 6 || $j == 6) ? 3 : + (($i == 1 || $j == 1 || $i == 5 || $j == 5) ? 2 : 3))); + $matrix[$i][$j] = $m; + $matrix[$size - $i - 1][$j] = $m; + $matrix[$i][$size - $j - 1] = $m; + } + } + + // alignment patterns + if ($version >= 2) { + $alignment = static::ALIGNMENT_PATTERNS[$version - 2]; + + foreach ($alignment as $i) { + foreach ($alignment as $j) { + if (!$matrix[$i][$j]) { + for ($ii = -2; $ii <= 2; $ii++) { + for ($jj = -2; $jj <= 2; $jj++) { + $m = (max(abs($ii), abs($jj)) & 1) ^ 3; + $matrix[$i + $ii][$j + $jj] = $m; + } + } + } + } + } + } + + // timing patterns + for ($i = $size - 9; $i >= 8; $i--) { + $matrix[$i][6] = ($i & 1) ^ 3; + $matrix[6][$i] = ($i & 1) ^ 3; + } + + // dark module – such an ominous name for such an innocuous thing + $matrix[$size - 8][8] = 3; + + // format information area + for ($i = 0; $i <= 8; $i++) { + if (!$matrix[$i][8]) { + $matrix[$i][8] = 1; + } + if (!$matrix[8][$i]) { + $matrix[8][$i] = 1; + } + if ($i && !$matrix[$size - $i][8]) { + $matrix[$size - $i][8] = 1; + } + if ($i && !$matrix[8][$size - $i]) { + $matrix[8][$size - $i] = 1; + } + } + + // version information area + if ($version >= 7) { + for ($i = 9; $i < 12; $i++) { + for ($j = 0; $j < 6; $j++) { + $matrix[$size - $i][$j] = 1; + $matrix[$j][$size - $i] = 1; + } + } + } + + // data + $col = $size - 1; + $row = $size - 1; + $dir = -1; + $offset = 0; + $length = count($data); + + while ($col > 0 && $offset < $length) { + if (!$matrix[$row][$col]) { + $matrix[$row][$col] = $data[$offset] ? 5 : 4; + $offset++; + } + if (!$matrix[$row][$col - 1]) { + $matrix[$row][$col - 1] = $data[$offset] ? 5 : 4; + $offset++; + } + $row += $dir; + if ($row < 0 || $row >= $size) { + $dir = -$dir; + $row += $dir; + $col -= 2; + + if ($col == 6) { + $col--; + } + } + } + + return [$size, $matrix]; + } + + /** + * Loops over every row and column, finds all modules that can + * be grouped as rectangle (starting at the top left corner) + * and applies the given action to each active module group + */ + protected function eachModuleGroup(array $code, Closure $action): array + { + $result = []; + $xStart = $code['q'][3]; + $yStart = $code['q'][0]; + + // generate empty matrix to track what modules have been covered + $covered = array_fill(0, count($code['bits']), array_fill(0, count($code['bits'][0]), 0)); + + foreach ($code['bits'] as $by => $row) { + foreach ($row as $bx => $module) { + // skip if module is inactive or already covered + if ($module === 0 || $covered[$by][$bx] === 1) { + continue; + } + + $width = 0; + $height = 0; + + $rowLength = count($row); + $colLength = count($code['bits']); + + // extend to the right as long as the modules are active + // and use this to determine the width of the group + for ($x = $bx; $x < $rowLength; $x++) { + if ($row[$x] === 0) { + break; + } + $width++; + $covered[$by][$x] = 1; + } + + // extend downwards as long as all the modules + // at the same width range are active; + // use this to determine the height of the group + for ($y = $by; $y < $colLength; $y++) { + $below = array_slice($code['bits'][$y], $bx, $width); + + // if the sum is less than the width, + // there is at least one inactive module + if (array_sum($below) < $width) { + break; + } + + $height++; + + for ($x = $bx; $x < $bx + $width; $x++) { + $covered[$y][$x] = 1; + } + } + + $result[] = $action( + $xStart + $bx, + $yStart + $by, + $width, + $height + ); + } + } + + return $result; + } + + protected function encode(int $q = 4): array + { + [$data, $version, $ecl, $ec] = $this->encodeData(); + $data = $this->encodeErrorCorrection($data, $ec, $version); + [$size, $mtx] = $this->createMatrix($version, $data); + [$mask, $mtx] = $this->applyBestMask($mtx, $size); + $mtx = $this->finalizeMatrix($mtx, $size, $ecl, $mask, $version); + + return [ + 'q' => [$q, $q, $q, $q], + 'size' => [$size, $size], + 'bits' => $mtx + ]; + } + + protected function encodeData(): array + { + $mode = $this->mode(); + [$version, $ecl] = $this->version($mode); + + $group = match (true) { + $version >= 27 => 2, + $version >= 10 => 1, + default => 0 + }; + + $ec = static::EC_PARAMS[($version - 1) * 4 + $ecl]; + + // don't cut off mid-character if exceeding capacity + $max_chars = static::CAPACITY[$version - 1][$ecl][$mode]; + + if ($mode == 3) { + $max_chars <<= 1; + } + + $data = substr($this->data, 0, $max_chars); + + // convert from character level to bit level + $code = match ($mode) { + 0 => $this->encodeNumeric($data, $group), + 1 => $this->encodeAlphanum($data, $group), + 2 => $this->encodeBinary($data, $group), + default => throw new LogicException(message: 'Invalid QR mode') // @codeCoverageIgnore + }; + + $code = [...$code, ...array_fill(0, 4, 0)]; + + if ($remainder = count($code) % 8) { + $code = [...$code, ...array_fill(0, 8 - $remainder, 0)]; + } + + // convert from bit level to byte level + $data = []; + + for ($i = 0, $n = count($code); $i < $n; $i += 8) { + $byte = 0; + + if ($code[$i + 0]) { + $byte |= 0x80; + } + if ($code[$i + 1]) { + $byte |= 0x40; + } + if ($code[$i + 2]) { + $byte |= 0x20; + } + if ($code[$i + 3]) { + $byte |= 0x10; + } + if ($code[$i + 4]) { + $byte |= 0x08; + } + if ($code[$i + 5]) { + $byte |= 0x04; + } + if ($code[$i + 6]) { + $byte |= 0x02; + } + if ($code[$i + 7]) { + $byte |= 0x01; + } + + $data[] = $byte; + } + + for ( + $i = count($data), + $a = 1, + $n = $ec[0]; + $i < $n; + $i++, + $a ^= 1 + ) { + $data[] = $a ? 236 : 17; + } + + return [ + $data, + $version, + $ecl, + $ec + ]; + } + + protected function encodeNumeric($data, $version_group): array + { + $code = [0, 0, 0, 1]; + $length = strlen($data); + + switch ($version_group) { + case 2: // 27 - 40 + $code[] = $length & 0x2000; + $code[] = $length & 0x1000; + // no break + case 1: // 10 - 26 + $code[] = $length & 0x0800; + $code[] = $length & 0x0400; + // no break + case 0: // 1 - 9 + $code[] = $length & 0x0200; + $code[] = $length & 0x0100; + $code[] = $length & 0x0080; + $code[] = $length & 0x0040; + $code[] = $length & 0x0020; + $code[] = $length & 0x0010; + $code[] = $length & 0x0008; + $code[] = $length & 0x0004; + $code[] = $length & 0x0002; + $code[] = $length & 0x0001; + } + for ($i = 0; $i < $length; $i += 3) { + $group = substr($data, $i, 3); + switch (strlen($group)) { + case 3: + $code[] = $group & 0x200; + $code[] = $group & 0x100; + $code[] = $group & 0x080; + // no break + case 2: + $code[] = $group & 0x040; + $code[] = $group & 0x020; + $code[] = $group & 0x010; + // no break + case 1: + $code[] = $group & 0x008; + $code[] = $group & 0x004; + $code[] = $group & 0x002; + $code[] = $group & 0x001; + } + } + return $code; + } + + protected function encodeAlphanum($data, $version_group): array + { + $alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'; + $code = [0, 0, 1, 0]; + $length = strlen($data); + switch ($version_group) { + case 2: // 27 - 40 + $code[] = $length & 0x1000; + $code[] = $length & 0x0800; + // no break + case 1: // 10 - 26 + $code[] = $length & 0x0400; + $code[] = $length & 0x0200; + // no break + case 0: // 1 - 9 + $code[] = $length & 0x0100; + $code[] = $length & 0x0080; + $code[] = $length & 0x0040; + $code[] = $length & 0x0020; + $code[] = $length & 0x0010; + $code[] = $length & 0x0008; + $code[] = $length & 0x0004; + $code[] = $length & 0x0002; + $code[] = $length & 0x0001; + } + for ($i = 0; $i < $length; $i += 2) { + $group = substr($data, $i, 2); + if (strlen($group) > 1) { + $c1 = strpos($alphabet, substr($group, 0, 1)); + $c2 = strpos($alphabet, substr($group, 1, 1)); + $ch = $c1 * 45 + $c2; + $code[] = $ch & 0x400; + $code[] = $ch & 0x200; + $code[] = $ch & 0x100; + $code[] = $ch & 0x080; + $code[] = $ch & 0x040; + $code[] = $ch & 0x020; + $code[] = $ch & 0x010; + $code[] = $ch & 0x008; + $code[] = $ch & 0x004; + $code[] = $ch & 0x002; + $code[] = $ch & 0x001; + } else { + $ch = strpos($alphabet, $group); + $code[] = $ch & 0x020; + $code[] = $ch & 0x010; + $code[] = $ch & 0x008; + $code[] = $ch & 0x004; + $code[] = $ch & 0x002; + $code[] = $ch & 0x001; + } + } + return $code; + } + + protected function encodeBinary(string $data, int $version_group): array + { + $code = [0, 1, 0, 0]; + $length = strlen($data); + + switch ($version_group) { + case 2: // 27 - 40 + case 1: // 10 - 26 + $code[] = $length & 0x8000; + $code[] = $length & 0x4000; + $code[] = $length & 0x2000; + $code[] = $length & 0x1000; + $code[] = $length & 0x0800; + $code[] = $length & 0x0400; + $code[] = $length & 0x0200; + $code[] = $length & 0x0100; + // no break + case 0: // 1 - 9 + $code[] = $length & 0x0080; + $code[] = $length & 0x0040; + $code[] = $length & 0x0020; + $code[] = $length & 0x0010; + $code[] = $length & 0x0008; + $code[] = $length & 0x0004; + $code[] = $length & 0x0002; + $code[] = $length & 0x0001; + } + + for ($i = 0; $i < $length; $i++) { + $ch = ord(substr($data, $i, 1)); + $code[] = $ch & 0x80; + $code[] = $ch & 0x40; + $code[] = $ch & 0x20; + $code[] = $ch & 0x10; + $code[] = $ch & 0x08; + $code[] = $ch & 0x04; + $code[] = $ch & 0x02; + $code[] = $ch & 0x01; + } + + return $code; + } + + protected function encodeErrorCorrection( + array $data, + array $ec_params, + int $version + ): array { + $blocks = $this->errorCorrectionSplit($data, $ec_params); + $ec_blocks = []; + + for ($i = 0, $n = count($blocks); $i < $n; $i++) { + $ec_blocks[] = $this->errorCorrectionDivide($blocks[$i], $ec_params); + } + + $data = $this->errorCorrectionInterleave($blocks); + $ec_data = $this->errorCorrectionInterleave($ec_blocks); + $code = []; + + foreach ($data as $ch) { + $code[] = $ch & 0x80; + $code[] = $ch & 0x40; + $code[] = $ch & 0x20; + $code[] = $ch & 0x10; + $code[] = $ch & 0x08; + $code[] = $ch & 0x04; + $code[] = $ch & 0x02; + $code[] = $ch & 0x01; + } + foreach ($ec_data as $ch) { + $code[] = $ch & 0x80; + $code[] = $ch & 0x40; + $code[] = $ch & 0x20; + $code[] = $ch & 0x10; + $code[] = $ch & 0x08; + $code[] = $ch & 0x04; + $code[] = $ch & 0x02; + $code[] = $ch & 0x01; + } + for ($n = static::REMAINER_BITS[$version - 1]; $n > 0; $n--) { + $code[] = 0; + } + + return $code; + } + + protected function errorCorrectionSplit(array $data, array $ec): array + { + $blocks = []; + $offset = 0; + + for ($i = $ec[2], $length = $ec[3]; $i > 0; $i--) { + $blocks[] = array_slice($data, $offset, $length); + $offset += $length; + } + for ($i = $ec[4], $length = $ec[5]; $i > 0; $i--) { + $blocks[] = array_slice($data, $offset, $length); + $offset += $length; + } + + return $blocks; + } + + protected function errorCorrectionDivide(array $data, array $ec): array + { + $num_data = count($data); + $num_error = $ec[1]; + $generator = static::EC_POLYNOMIALS[$num_error]; + $message = $data; + + for ($i = 0; $i < $num_error; $i++) { + $message[] = 0; + } + + for ($i = 0; $i < $num_data; $i++) { + if ($message[$i]) { + $leadterm = static::LOG[$message[$i]]; + + for ($j = 0; $j <= $num_error; $j++) { + $term = ($generator[$j] + $leadterm) % 255; + $message[$i + $j] ^= static::EXP[$term]; + } + } + } + + return array_slice($message, $num_data, $num_error); + } + + protected function errorCorrectionInterleave(array $blocks): array + { + $data = []; + $num_blocks = count($blocks); + + for ($offset = 0; true; $offset++) { + $break = true; + + for ($i = 0; $i < $num_blocks; $i++) { + if (isset($blocks[$i][$offset]) === true) { + $data[] = $blocks[$i][$offset]; + $break = false; + } + } + + if ($break) { + break; + } + } + + return $data; + } + + protected function finalizeMatrix( + array $matrix, + int $size, + int $ecl, + int $mask, + int $version + ): array { + // Format info + $format = static::FORMAT_INFO[$ecl * 8 + $mask]; + $matrix[8][0] = $format[0]; + $matrix[8][1] = $format[1]; + $matrix[8][2] = $format[2]; + $matrix[8][3] = $format[3]; + $matrix[8][4] = $format[4]; + $matrix[8][5] = $format[5]; + $matrix[8][7] = $format[6]; + $matrix[8][8] = $format[7]; + $matrix[7][8] = $format[8]; + $matrix[5][8] = $format[9]; + $matrix[4][8] = $format[10]; + $matrix[3][8] = $format[11]; + $matrix[2][8] = $format[12]; + $matrix[1][8] = $format[13]; + $matrix[0][8] = $format[14]; + $matrix[$size - 1][8] = $format[0]; + $matrix[$size - 2][8] = $format[1]; + $matrix[$size - 3][8] = $format[2]; + $matrix[$size - 4][8] = $format[3]; + $matrix[$size - 5][8] = $format[4]; + $matrix[$size - 6][8] = $format[5]; + $matrix[$size - 7][8] = $format[6]; + $matrix[8][$size - 8] = $format[7]; + $matrix[8][$size - 7] = $format[8]; + $matrix[8][$size - 6] = $format[9]; + $matrix[8][$size - 5] = $format[10]; + $matrix[8][$size - 4] = $format[11]; + $matrix[8][$size - 3] = $format[12]; + $matrix[8][$size - 2] = $format[13]; + $matrix[8][$size - 1] = $format[14]; + + // version info + if ($version >= 7) { + $version = static::VERSION_INFO[$version - 7]; + + for ($i = 0; $i < 18; $i++) { + $r = $size - 9 - ($i % 3); + $c = 5 - floor($i / 3); + $matrix[$r][$c] = $version[$i]; + $matrix[$c][$r] = $version[$i]; + } + } + + // patterns and data + for ($i = 0; $i < $size; $i++) { + for ($j = 0; $j < $size; $j++) { + $matrix[$i][$j] &= 1; + } + } + + return $matrix; + } + + protected function mask(int $mask, int $row, int $column): int + { + return match ($mask) { + 0 => !(($row + $column) % 2), + 1 => !($row % 2), + 2 => !($column % 3), + 3 => !(($row + $column) % 3), + 4 => !((floor($row / 2) + floor($column / 3)) % 2), + 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(message: 'Invalid QR mask') // @codeCoverageIgnore + }; + } + + /** + * Returns width and height based on the + * generated modules and quiet zone + */ + protected function measure($code): array + { + return [ + $code['q'][3] + $code['size'][0] + $code['q'][1], + $code['q'][0] + $code['size'][1] + $code['q'][2] + ]; + } + + /** + * Detect what encoding mode (numeric, alphanumeric, binary) + * can be used + */ + protected function mode(): int + { + // numeric + if (preg_match('/^[0-9]*$/', $this->data)) { + return 0; + } + + // alphanumeric + if (preg_match('/^[0-9A-Z .\/:$%*+-]*$/', $this->data)) { + return 1; + } + + return 2; + } + + protected function penalty(array &$matrix, int $size): int + { + $score = $this->penalty1($matrix, $size); + $score += $this->penalty2($matrix, $size); + $score += $this->penalty3($matrix, $size); + $score += $this->penalty4($matrix, $size); + return $score; + } + + protected function penalty1(array &$matrix, int $size): int + { + $score = 0; + + for ($i = 0; $i < $size; $i++) { + $rowvalue = 0; + $rowcount = 0; + $colvalue = 0; + $colcount = 0; + + for ($j = 0; $j < $size; $j++) { + $rv = ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) ? 1 : 0; + $cv = ($matrix[$j][$i] == 5 || $matrix[$j][$i] == 3) ? 1 : 0; + + if ($rv == $rowvalue) { + $rowcount++; + } else { + if ($rowcount >= 5) { + $score += $rowcount - 2; + } + $rowvalue = $rv; + $rowcount = 1; + } + + if ($cv == $colvalue) { + $colcount++; + } else { + if ($colcount >= 5) { + $score += $colcount - 2; + } + $colvalue = $cv; + $colcount = 1; + } + } + + if ($rowcount >= 5) { + $score += $rowcount - 2; + } + if ($colcount >= 5) { + $score += $colcount - 2; + } + } + + return $score; + } + + protected function penalty2(array &$matrix, int $size): int + { + $score = 0; + + for ($i = 1; $i < $size; $i++) { + for ($j = 1; $j < $size; $j++) { + $v1 = $matrix[$i - 1][$j - 1]; + $v2 = $matrix[$i - 1][$j ]; + $v3 = $matrix[$i ][$j - 1]; + $v4 = $matrix[$i ][$j ]; + $v1 = ($v1 == 5 || $v1 == 3) ? 1 : 0; + $v2 = ($v2 == 5 || $v2 == 3) ? 1 : 0; + $v3 = ($v3 == 5 || $v3 == 3) ? 1 : 0; + $v4 = ($v4 == 5 || $v4 == 3) ? 1 : 0; + + if ($v1 == $v2 && $v2 == $v3 && $v3 == $v4) { + $score += 3; + } + } + } + + return $score; + } + + protected function penalty3(array &$matrix, int $size): int + { + $score = 0; + + for ($i = 0; $i < $size; $i++) { + $rowvalue = 0; + $colvalue = 0; + + for ($j = 0; $j < 11; $j++) { + $rv = ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) ? 1 : 0; + $cv = ($matrix[$j][$i] == 5 || $matrix[$j][$i] == 3) ? 1 : 0; + $rowvalue = (($rowvalue << 1) & 0x7FF) | $rv; + $colvalue = (($colvalue << 1) & 0x7FF) | $cv; + } + + if ($rowvalue == 0x5D0 || $rowvalue == 0x5D) { + $score += 40; + } + if ($colvalue == 0x5D0 || $colvalue == 0x5D) { + $score += 40; + } + + for ($j = 11; $j < $size; $j++) { + $rv = ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) ? 1 : 0; + $cv = ($matrix[$j][$i] == 5 || $matrix[$j][$i] == 3) ? 1 : 0; + $rowvalue = (($rowvalue << 1) & 0x7FF) | $rv; + $colvalue = (($colvalue << 1) & 0x7FF) | $cv; + + if ($rowvalue == 0x5D0 || $rowvalue == 0x5D) { + $score += 40; + } + + if ($colvalue == 0x5D0 || $colvalue == 0x5D) { + $score += 40; + } + } + } + + return $score; + } + + protected function penalty4(array &$matrix, int $size): int + { + $dark = 0; + + for ($i = 0; $i < $size; $i++) { + for ($j = 0; $j < $size; $j++) { + if ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) { + $dark++; + } + } + } + + $dark *= 20; + $dark /= $size * $size; + $a = abs(floor($dark) - 10); + $b = abs(ceil($dark) - 10); + return min($a, $b) * 10; + } + + /** + * Detect what version needs to be used by + * trying to maximize the error correction level + */ + protected function version(int $mode): array + { + $length = strlen($this->data); + + if ($mode == 3) { + $length >>= 1; + } + + $ecl = 0; + + // first try to find the minimum version + // that can contain the data + for ($version = 1; $version <= 40; $version++) { + if ($length <= static::CAPACITY[$version - 1][$ecl][$mode]) { + break; + } + } + + // with the version in place, try to raise + // the error correction level as long as + // the data still fits + for ($newEcl = 1; $newEcl <= 3; $newEcl++) { + if ($length <= static::CAPACITY[$version - 1][$newEcl][$mode]) { + $ecl = $newEcl; + } + } + + return [$version, $ecl]; + } + + /** + * maximum encodable characters = $qr_capacity [ (version - 1) ] + * [ (0 for L, 1 for M, 2 for Q, 3 for H) ] + * [ (0 for numeric, 1 for alpha, 2 for binary) ] + */ + protected const CAPACITY = [ + [ + [41, 25, 17], + [34, 20, 14], + [27, 16, 11], + [17, 10, 7] + ], + [ + [77, 47, 32], + [63, 38, 26], + [48, 29, 20], + [34, 20, 14] + ], + [ + [127, 77, 53], + [101, 61, 42], + [77, 47, 32], + [58, 35, 24] + ], + [ + [187, 114, 78], + [149, 90, 62], + [111, 67, 46], + [82, 50, 34] + ], + [ + [255, 154, 106], + [202, 122, 84], + [144, 87, 60], + [106, 64, 44] + ], + [ + [322, 195, 134], + [255, 154, 106], + [178, 108, 74], + [139, 84, 58] + ], + [ + [370, 224, 154], + [293, 178, 122], + [207, 125, 86], + [154, 93, 64] + ], + [ + [461, 279, 192], + [365, 221, 152], + [259, 157, 108], + [202, 122, 84] + ], + [ + [552, 335, 230], + [432, 262, 180], + [312, 189, 130], + [235, 143, 98]], + [ + [652, 395, 271], + [513, 311, 213], + [364, 221, 151], + [288, 174, 119] + ], + [ + [772, 468, 321], + [604, 366, 251], + [427, 259, 177], + [331, 200, 137] + ], + [ + [883, 535, 367], + [691, 419, 287], + [489, 296, 203], + [374, 227, 155] + ], + [ + [1022, 619, 425], + [796, 483, 331], + [580, 352, 241], + [427, 259, 177] + ], + [ + [1101, 667, 458], + [871, 528, 362], + [621, 376, 258], + [468, 283, 194] + ], + [ + [1250, 758, 520], + [991, 600, 412], + [703, 426, 292], + [530, 321, 220] + ], + [ + [1408, 854, 586], + [1082, 656, 450], + [775, 470, 322], + [602, 365, 250] + ], + [ + [1548, 938, 644], + [1212, 734, 504], + [876, 531, 364], + [674, 408, 280] + ], + [ + [1725, 1046, 718], + [1346, 816, 560], + [948, 574, 394], + [746, 452, 310] + ], + [ + [1903, 1153, 792], + [1500, 909, 624], + [1063, 644, 442], + [813, 493, 338] + ], + [ + [2061, 1249, 858], + [1600, 970, 666], + [1159, 702, 482], + [919, 557, 382] + ], + [ + [2232, 1352, 929], + [1708, 1035, 711], + [1224, 742, 509], + [969, 587, 403] + ], + [ + [2409, 1460, 1003], + [1872, 1134, 779], + [1358, 823, 565], + [1056, 640, 439] + ], + [ + [2620, 1588, 1091], + [2059, 1248, 857], + [1468, 890, 611], + [1108, 672, 461] + ], + [ + [2812, 1704, 1171], + [2188, 1326, 911], + [1588, 963, 661], + [1228, 744, 511] + ], + [ + [3057, 1853, 1273], + [2395, 1451, 997], + [1718, 1041, 715], + [1286, 779, 535] + ], + [ + [3283, 1990, 1367], + [2544, 1542, 1059], + [1804, 1094, 751], + [1425, 864, 593] + ], + [ + [3517, 2132, 1465], + [2701, 1637, 1125], + [1933, 1172, 805], + [1501, 910, 625] + ], + [ + [3669, 2223, 1528], + [2857, 1732, 1190], + [2085, 1263, 868], + [1581, 958, 658] + ], + [ + [3909, 2369, 1628], + [3035, 1839, 1264], + [2181, 1322, 908], + [1677, 1016, 698] + ], + [ + [4158, 2520, 1732], + [3289, 1994, 1370], + [2358, 1429, 982], + [1782, 1080, 742] + ], + [ + [4417, 2677, 1840], + [3486, 2113, 1452], + [2473, 1499, 1030], + [1897, 1150, 790] + ], + [ + [4686, 2840, 1952], + [3693, 2238, 1538], + [2670, 1618, 1112], + [2022, 1226, 842] + ], + [ + [4965, 3009, 2068], + [3909, 2369, 1628], + [2805, 1700, 1168], + [2157, 1307, 898] + ], + [ + [5253, 3183, 2188], + [4134, 2506, 1722], + [2949, 1787, 1228], + [2301, 1394, 958] + ], + [ + [5529, 3351, 2303], + [4343, 2632, 1809], + [3081, 1867, 1283], + [2361, 1431, 983] + ], + [ + [5836, 3537, 2431], + [4588, 2780, 1911], + [3244, 1966, 1351], + [2524, 1530, 1051] + ], + [ + [6153, 3729, 2563], + [4775, 2894, 1989], + [3417, 2071, 1423], + [2625, 1591, 1093] + ], + [ + [6479, 3927, 2699], + [5039, 3054, 2099], + [3599, 2181, 1499], + [2735, 1658, 1139] + ], + [ + [6743, 4087, 2809], + [5313, 3220, 2213], + [3791, 2298, 1579], + [2927, 1774, 1219] + ], + [ + [7089, 4296, 2953], + [5596, 3391, 2331], + [3993, 2420, 1663], + [3057, 1852, 1273] + ], + ]; + + /** + * $qr_ec_params[ + * 4 * (version - 1) + (0 for L, 1 for M, 2 for Q, 3 for H) + * ] = [ + * total number of data codewords, + * number of error correction codewords per block, + * number of blocks in first group, + * number of data codewords per block in first group, + * number of blocks in second group, + * number of data codewords per block in second group + * ); + */ + 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], + ]; + + protected const EC_POLYNOMIALS = [ + 7 => [0, 87, 229, 146, 149, 238, 102, 21], + 10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45], + 13 => [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78], + 15 => [0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105], + 16 => [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120], + 17 => [0, 43, 139, 206, 78, 43, 239, 123, 206, 214, 147, 24, 99, 150, 39, 243, 163, 136], + 18 => [0, 215, 234, 158, 94, 184, 97, 118, 170, 79, 187, 152, 148, 252, 179, 5, 98, 96, 153], + 20 => [0, 17, 60, 79, 50, 61, 163, 26, 187, 202, 180, 221, 225, 83, 239, 156, 164, 212, 212, 188, 190], + 22 => [0, 210, 171, 247, 242, 93, 230, 14, 109, 221, 53, 200, 74, 8, 172, 98, 80, 219, 134, 160, 105, 165, 231], + 24 => [0, 229, 121, 135, 48, 211, 117, 251, 126, 159, 180, 169, 152, 192, 226, 228, 218, 111, 0, 117, 232, 87, 96, 227, 21], + 26 => [0, 173, 125, 158, 2, 103, 182, 118, 17, 145, 201, 111, 28, 165, 53, 161, 21, 245, 142, 13, 102, 48, 227, 153, 145, 218, 70], + 28 => [0, 168, 223, 200, 104, 224, 234, 108, 180, 110, 190, 195, 147, 205, 27, 232, 201, 21, 43, 245, 87, 42, 195, 212, 119, 242, 37, 9, 123], + 30 => [0, 41, 173, 145, 152, 216, 31, 179, 182, 50, 48, 110, 86, 239, 96, 222, 125, 42, 173, 226, 193, 224, 130, 156, 37, 251, 216, 238, 40, 192, 180], + ]; + + protected const LOG = [0, 0, 1, 25, 2, 50, 26, 198, 3, 223, 51, 238, 27, 104, 199, 75, 4, 100, 224, 14, 52, 141, 239, 129, 28, 193, 105, 248, 200, 8, 76, 113, 5, 138, 101, 47, 225, 36, 15, 33, 53, 147, 142, 218, 240, 18, 130, 69, 29, 181, 194, 125, 106, 39, 249, 185, 201, 154, 9, 120, 77, 228, 114, 166, 6, 191, 139, 98, 102, 221, 48, 253, 226, 152, 37, 179, 16, 145, 34, 136, 54, 208, 148, 206, 143, 150, 219, 189, 241, 210, 19, 92, 131, 56, 70, 64, 30, 66, 182, 163, 195, 72, 126, 110, 107, 58, 40, 84, 250, 133, 186, 61, 202, 94, 155, 159, 10, 21, 121, 43, 78, 212, 229, 172, 115, 243, 167, 87, 7, 112, 192, 247, 140, 128, 99, 13, 103, 74, 222, 237, 49, 197, 254, 24, 227, 165, 153, 119, 38, 184, 180, 124, 17, 68, 146, 217, 35, 32, 137, 46, 55, 63, 209, 91, 149, 188, 207, 205, 144, 135, 151, 178, 220, 252, 190, 97, 242, 86, 211, 171, 20, 42, 93, 158, 132, 60, 57, 83, 71, 109, 65, 162, 31, 45, 67, 216, 183, 123, 164, 118, 196, 23, 73, 236, 127, 12, 111, 246, 108, 161, 59, 82, 41, 157, 85, 170, 251, 96, 134, 177, 187, 204, 62, 90, 203, 89, 95, 176, 156, 169, 160, 81, 11, 245, 22, 235, 122, 117, 44, 215, 79, 174, 213, 233, 230, 231, 173, 232, 116, 214, 244, 234, 168, 80, 88, 175]; + + protected const EXP = [1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152, 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78, 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5, 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97, 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211, 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175, 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31, 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23, 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154, 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191, 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219, 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25, 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89, 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72, 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176, 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142, 1]; + + protected const REMAINER_BITS = [0, 7, 7, 7, 7, 7, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0]; + + protected const ALIGNMENT_PATTERNS = [ + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170], + ]; + + /** + * format info string = $qr_format_info[ + * (0 for L, 8 for M, 16 for Q, 24 for H) + mask + *]; + */ + protected const FORMAT_INFO = [ + [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0], + [1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1], + [1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0], + [1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1], + [1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0], + [1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1], + [1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0], + [1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0], + [1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1], + [1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0], + [1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1], + [1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0], + [1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1], + [1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0], + [0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1], + [0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0], + [0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0], + [0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1], + [0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0], + [0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1], + [0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1], + [0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0], + [0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1], + [0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1], + [0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1] + ]; + + /** + * version info string = $qr_version_info[ (version - 7) ] + */ + protected const VERSION_INFO = [ + [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0], + [0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0], + [0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1], + [0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1], + [0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 0], + [0, 0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], + [0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1], + [0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0], + [0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1], + [0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1], + [0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], + [0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0], + [0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1], + [0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1], + [0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0], + [0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0], + [0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1], + [0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1], + [0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0], + [0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0], + [0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1], + [0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1], + [1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0], + [1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1, 0], + [1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1], + [1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1], + [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0], + [1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0], + [1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1], + [1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1] + ]; +} diff --git a/public/kirby/src/Option/Option.php b/public/kirby/src/Option/Option.php new file mode 100644 index 0000000..bc5f281 --- /dev/null +++ b/public/kirby/src/Option/Option.php @@ -0,0 +1,81 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Option +{ + public string|array $text; + + public function __construct( + public string|int|float|null $value, + public bool $disabled = false, + public string|null $icon = null, + public string|array|null $info = null, + string|array|null $text = null + ) { + $this->text = $text ?? ['en' => $this->value]; + } + + public static function factory(string|int|float|array|null $props): static + { + if (is_array($props) === false) { + $props = ['value' => $props]; + } + + // 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); + } + + public function id(): string|int|float + { + return $this->value ?? ''; + } + + /** + * Renders all data for the 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, + '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 new file mode 100644 index 0000000..871a1cc --- /dev/null +++ b/public/kirby/src/Option/Options.php @@ -0,0 +1,76 @@ + + * @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 function __construct(array $objects = []) + { + foreach ($objects as $object) { + $this->__set($object->value, $object); + } + } + + /** + * 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(); + + foreach ($items as $key => $option) { + // convert an associative value => text array into props; + // skip if option is already an array of option props + if ( + is_array($option) === false || + array_key_exists('value', $option) === false + ) { + $option = match (true) { + is_string($key) => ['value' => $key, 'text' => $option], + default => ['value' => $option] + }; + } + + $option = Option::factory($option); + $collection->__set($option->id(), $option); + } + + return $collection; + } + + public function render(ModelWithContent $model): array + { + $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 new file mode 100644 index 0000000..2470e02 --- /dev/null +++ b/public/kirby/src/Option/OptionsApi.php @@ -0,0 +1,157 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class OptionsApi extends OptionsProvider +{ + public function __construct( + public string $url, + public string|null $query = null, + public string|null $text = null, + public string|null $value = null, + public string|null $icon = null, + public string|null $info = null + ) { + } + + public function defaults(): static + { + $this->text ??= '{{ item.value }}'; + $this->value ??= '{{ item.key }}'; + return $this; + } + + public static function factory(string|array $props): static + { + if (is_string($props) === true) { + return new static(url: $props); + } + + return new static( + url : $props['url'], + query: $props['query'] ?? $props['fetch'] ?? null, + text : $props['text'] ?? null, + value: $props['value'] ?? null, + icon : $props['icon'] ?? null, + info : $props['info'] ?? null + ); + } + + /** + * Loads the API content from a remote URL + * or local file (or from cache) + */ + public function load(ModelWithContent $model): array|null + { + // resolve query templates in $this->url string + $url = $model->toSafeString($this->url); + + // URL, request via cURL + if (Url::isAbsolute($url) === true) { + return Remote::get($url)->json(); + } + + // local file + return Json::read($url); + } + + public static function polyfill(array|string $props = []): array + { + if (is_string($props) === true) { + return ['url' => $props]; + } + + if ($query = $props['fetch'] ?? null) { + $props['query'] ??= $query; + unset($props['fetch']); + } + + return $props; + } + + /** + * Creates the actual options by loading + * data from the API and resolving it to + * the correct text-value entries + * + * @param bool $safeMode Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + public function resolve(ModelWithContent $model, bool $safeMode = true): Options + { + // use cached options if present + // @codeCoverageIgnoreStart + if ($this->options !== null) { + return $this->options; + } + // @codeCoverageIgnoreEnd + + // apply property defaults + $this->defaults(); + + // load data from URL and convert from JSON to array + $data = $this->load($model); + + // @codeCoverageIgnoreStart + if ($data === null) { + throw new NotFoundException( + message: 'Options could not be loaded from API: ' . $model->toSafeString($this->url) + ); + } + // @codeCoverageIgnoreEnd + + // turn data into Nest so that it can be queried + // or field methods applied to the data + $data = Nest::create($data); + + // optionally query a substructure inside the data array + $data = Query::factory($this->query)->resolve($data); + $options = []; + + // create options by resolving text and value query strings + // for each item from the data + foreach ($data as $key => $item) { + // convert simple `key: value` API data + if (is_string($item) === true) { + $item = new Field(null, $key, $item); + } + + $safeMethod = $safeMode === true ? 'toSafeString' : 'toString'; + + $options[] = [ + // value is always a raw string + 'value' => $model->toString($this->value, ['item' => $item]), + // text is only a raw string when using {< >} + // or when the safe mode is explicitly disabled (select field) + 'text' => $model->$safeMethod($this->text, ['item' => $item]), + // additional data + 'icon' => $this->icon !== null ? $model->toString($this->icon, ['item' => $item]) : null, + 'info' => $this->info !== null ? $model->$safeMethod($this->info, ['item' => $item]) : null + ]; + } + + // create Options object and render this subsequently + return $this->options = Options::factory($options); + } +} diff --git a/public/kirby/src/Option/OptionsProvider.php b/public/kirby/src/Option/OptionsProvider.php new file mode 100644 index 0000000..433b2ab --- /dev/null +++ b/public/kirby/src/Option/OptionsProvider.php @@ -0,0 +1,38 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class OptionsProvider +{ + public Options|null $options = null; + + /** + * Returns options as array + */ + public function render(ModelWithContent $model) + { + return $this->resolve($model)->render($model); + } + + /** + * Dynamically determines the actual options and resolves + * them to the correct text-value entries + * + * @param bool $safeMode Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + abstract public function resolve(ModelWithContent $model, bool $safeMode = true): Options; +} diff --git a/public/kirby/src/Option/OptionsQuery.php b/public/kirby/src/Option/OptionsQuery.php new file mode 100644 index 0000000..85257aa --- /dev/null +++ b/public/kirby/src/Option/OptionsQuery.php @@ -0,0 +1,196 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class OptionsQuery extends OptionsProvider +{ + public function __construct( + public string $query, + public string|null $text = null, + public string|null $value = null, + public string|null $icon = null, + public string|null $info = null + ) { + } + + protected function collection(array $array): Collection + { + foreach ($array as $key => $value) { + if (is_scalar($value) === true) { + $array[$key] = new Obj([ + 'key' => new Field(null, 'key', $key), + 'value' => new Field(null, 'value', $value), + ]); + } + } + + return new Collection($array); + } + + public static function factory(string|array $props): static + { + if (is_string($props) === true) { + return new static(query: $props); + } + + return new static( + query: $props['query'] ?? $props['fetch'], + text : $props['text'] ?? null, + value: $props['value'] ?? null, + icon : $props['icon'] ?? null, + info : $props['info'] ?? null + ); + } + + /** + * Returns defaults for the following based on item type: + * [query entry alias, default text query, default value query] + */ + protected function itemToDefaults(array|object $item): array + { + return match (true) { + is_array($item), + $item instanceof Obj => [ + 'arrayItem', + '{{ item.value }}', + '{{ item.value }}' + ], + + $item instanceof StructureObject => [ + 'structureItem', + '{{ item.title }}', + '{{ item.id }}' + ], + + $item instanceof Block => [ + 'block', + '{{ block.type }}: {{ block.id }}', + '{{ block.id }}' + ], + + $item instanceof Page => [ + 'page', + '{{ page.title }}', + '{{ page.id }}' + ], + + $item instanceof File => [ + 'file', + '{{ file.filename }}', + '{{ file.id }}' + ], + + $item instanceof User => [ + 'user', + '{{ user.username }}', + '{{ user.email }}' + ], + + default => [ + 'item', + '{{ item.value }}', + '{{ item.value }}' + ] + }; + } + + public static function polyfill(array|string $props = []): array + { + if (is_string($props) === true) { + return ['query' => $props]; + } + + if ($query = $props['fetch'] ?? null) { + $props['query'] ??= $query; + unset($props['fetch']); + } + + return $props; + } + + /** + * Creates the actual options by running + * the query on the model and resolving it to + * the correct text-value entries + * + * @param bool $safeMode Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + public function resolve(ModelWithContent $model, bool $safeMode = true): Options + { + // use cached options if present + // @codeCoverageIgnoreStart + if ($this->options !== null) { + return $this->options; + } + // @codeCoverageIgnoreEnd + + // run query + $result = $model->query($this->query); + + // the query already returned an options collection + if ($result instanceof Options) { + return $result; + } + + // convert result to a collection + if (is_array($result) === true) { + $result = $this->collection($result); + } + + if ($result instanceof Collection === false) { + $type = is_object($result) === true ? $result::class : gettype($result); + + throw new InvalidArgumentException( + message: 'Invalid query result data: ' . $type + ); + } + + // create options array + $options = $result->toArray(function ($item) use ($model, $safeMode) { + // get defaults based on item type + [$alias, $text, $value] = $this->itemToDefaults($item); + $data = ['item' => $item, $alias => $item]; + + // value is always a raw string + $value = $model->toString($this->value ?? $value, $data); + + // text is only a raw string when using {< >} + // or when the safe mode is explicitly disabled (select field) + $safeMethod = $safeMode === true ? 'toSafeString' : 'toString'; + $text = $model->$safeMethod($this->text ?? $text, $data); + + // additional data + $icon = $this->icon !== null ? $model->toString($this->icon, $data) : null; + $info = $this->info !== null ? $model->$safeMethod($this->info, $data) : null; + + return compact('text', 'value', 'icon', 'info'); + }); + + return $this->options = Options::factory($options); + } +} diff --git a/public/kirby/src/Panel/Assets.php b/public/kirby/src/Panel/Assets.php new file mode 100644 index 0000000..bb861cb --- /dev/null +++ b/public/kirby/src/Panel/Assets.php @@ -0,0 +1,355 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + */ +class Assets +{ + protected bool $isDev; + protected App $kirby; + protected string $nonce; + protected Plugins $plugins; + protected string $url; + protected bool $vite; + + public function __construct() + { + $this->kirby = App::instance(); + $this->nonce = $this->kirby->nonce(); + $this->plugins = new Plugins(); + + $vite = $this->kirby->roots()->panel() . '/.vite-running'; + $this->vite = is_file($vite) === true; + + // 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 + $this->isDev = + $this->kirby->option('panel.dev', false) !== false && + $this->vite === true; + + // Get the base URL + $this->url = $this->url(); + } + + /** + * Get all CSS files + */ + public function css(): array + { + $css = [ + 'index' => $this->url . '/css/style.min.css', + 'plugins' => $this->plugins->url('css'), + ...$this->custom('panel.css') + ]; + + // during dev mode we do not need to load + // the general stylesheet (as styling will be inlined) + if ($this->isDev === true) { + $css['index'] = null; + } + + return array_filter($css); + } + + /** + * Check for a custom asset file from the + * config (e.g. panel.css or panel.js) + */ + public function custom(string $option): array + { + $customs = []; + + if ($assets = $this->kirby->option($option)) { + $assets = A::wrap($assets); + + foreach ($assets as $index => $path) { + if (Url::isAbsolute($path) === true) { + $customs['custom-' . $index] = $path; + continue; + } + + $asset = new Asset($path); + + if ($asset->exists() === true) { + $customs['custom-' . $index] = $asset->url() . '?' . $asset->modified(); + } + } + } + + return $customs; + } + + /** + * Generates an array with all assets + * that need to be loaded for the panel (js, css, icons) + */ + public function external(): array + { + 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'), + ]; + } + + /** + * Returns array of favicon icons based on config option + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function favicons(): array + { + $icons = $this->kirby->option('panel.favicon', [ + [ + 'rel' => 'apple-touch-icon', + 'type' => 'image/png', + 'href' => $this->url . '/apple-touch-icon.png' + ], + [ + 'rel' => 'alternate icon', + 'type' => 'image/png', + 'href' => $this->url . '/favicon.png' + ], + [ + 'rel' => 'shortcut icon', + 'type' => 'image/svg+xml', + 'href' => $this->url . '/favicon.svg' + ], + [ + 'rel' => 'apple-touch-icon', + 'type' => 'image/png', + 'href' => $this->url . '/apple-touch-icon-dark.png', + 'media' => '(prefers-color-scheme: dark)' + ], + [ + 'rel' => 'alternate icon', + 'type' => 'image/png', + 'href' => $this->url . '/favicon-dark.png', + 'media' => '(prefers-color-scheme: dark)' + ] + ]); + + if (is_array($icons) === true) { + // normalize options + 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; + } + + $icon['href'] = Url::to($icon['href']); + $icon['nonce'] = $this->nonce; + } + + return array_values($icons); + } + + // make sure to convert favicon string to array + if (is_string($icons) === true) { + return [ + [ + 'rel' => 'shortcut icon', + 'type' => F::mime($icons), + 'href' => Url::to($icons), + 'nonce' => $this->nonce + ] + ]; + } + + throw new InvalidArgumentException( + message: 'Invalid panel.favicon option' + ); + } + + /** + * Load the SVG icon sprite + * This will be injected in the + * initial HTML document for the Panel + */ + public function icons(): string + { + $dir = $this->kirby->root('panel') . '/'; + $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 = [ + 'vendor' => [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/js/vendor.min.js', + 'type' => 'module' + ], + 'plugin-registry' => [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/js/plugins.js', + 'type' => 'module' + ], + 'plugins' => [ + 'nonce' => $this->nonce, + 'src' => $this->plugins->url('js'), + 'defer' => true + ], + ...A::map($this->custom('panel.js'), fn ($src) => [ + 'nonce' => $this->nonce, + 'src' => $src, + 'type' => 'module', + 'defer' => true + ]), + 'index' => [ + 'src' => $this->url . '/js/index.min.js', + 'type' => 'module' + ], + ]; + + + // During dev mode, add vite client and adapt + // path to `index.js` - vendor does not need + // to be loaded in dev mode + if ($this->isDev === true) { + // Load the non-minified index.js, remove vendor script + $js['index']['src'] = $this->url . '/src/index.js'; + $js['vendor'] = null; + + // Add vite dev client + $js['vite'] = [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/@vite/client', + 'type' => 'module' + ]; + } + + return array_filter($js); + } + + /** + * Links all dist files in the media folder + * and returns the link to the requested asset + * + * @throws \Kirby\Exception\Exception If Panel assets could not be moved to the public directory + */ + public function link(): bool + { + $mediaRoot = $this->kirby->root('media') . '/panel'; + $panelRoot = $this->kirby->root('panel') . '/dist'; + $versionHash = $this->kirby->versionHash(); + $versionRoot = $mediaRoot . '/' . $versionHash; + + // check if the version already exists + if (is_dir($versionRoot) === true) { + return false; + } + + // delete the panel folder and all previous versions + Dir::remove($mediaRoot); + + // recreate the panel folder + Dir::make($mediaRoot, true); + + // copy assets to the dist folder + if (Dir::copy($panelRoot, $versionRoot) !== true) { + throw new Exception( + message: 'Panel assets could not be linked' + ); + } + + return true; + } + + /** + * Get the base URL for all assets depending on dev mode + */ + public function url(): string + { + // vite is not running, use production assets + 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; + } + + // port 3000 of the current Kirby request + return rtrim($this->kirby->request()->url([ + 'port' => 3000, + 'path' => null, + 'params' => null, + 'query' => null + ])->toString(), '/'); + } + + /** + * Get the correct Vue script URL depending on dev mode + * and the enabled/disabled template compiler + */ + public function vue(): string + { + // During dev mode, load the dev version of Vue + if ($this->isDev === true) { + return $this->url . '/node_modules/vue/dist/vue.esm.browser.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 new file mode 100644 index 0000000..e3bb2f5 --- /dev/null +++ b/public/kirby/src/Panel/ChangesDialog.php @@ -0,0 +1,96 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class ChangesDialog +{ + 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 a single given model + */ + public function item(File|Page|User $model): array + { + $item = match (true) { + $model instanceof File => new FileItem(file: $model), + $model instanceof Page => new PageItem(page: $model), + $model instanceof User => new UserItem(user: $model), + }; + + return $item->props(); + } + + /** + * Helper method to return item props for the given models + */ + public function items(Collection $models): array + { + return $models->values($this->item(...)); + } + + /** + * Returns the backend full definition for dialog + */ + public function load(): array + { + if ($this->changes->cacheExists() === false) { + $this->changes->generateCache(); + } + + return [ + 'component' => 'k-changes-dialog', + 'props' => [ + 'files' => $this->files(), + 'pages' => $this->pages(), + 'users' => $this->users(), + ] + ]; + } + + /** + * Returns the item props for all changed pages + */ + public function pages(): array + { + 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/Collector/FilesCollector.php b/public/kirby/src/Panel/Collector/FilesCollector.php new file mode 100644 index 0000000..b9e8560 --- /dev/null +++ b/public/kirby/src/Panel/Collector/FilesCollector.php @@ -0,0 +1,73 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class FilesCollector extends ModelsCollector +{ + public function __construct( + protected bool $flip = false, + protected int|null $limit = null, + protected int $page = 1, + protected Site|Page|User|null $parent = null, + protected string|null $query = null, + protected string|null $search = null, + protected string|null $sortBy = null, + protected string|null $template = null, + ) { + } + + protected function collect(): Files + { + return $this->parent()->files(); + } + + protected function collectByQuery(): Files + { + return $this->parent()->query($this->query, Files::class) ?? new Files([]); + } + + protected function filter(Files|Pages|Users $models): Files + { + return $models->filter(function ($file) { + // remove all protected and hidden files + if ($file->isListable() === false) { + return false; + } + + // filter by template + if ($this->template !== null && $file->template() !== $this->template) { + return false; + } + + return true; + }); + } + + public function isSorting(): bool + { + return true; + } + + protected function sort(Files|Pages|Users $models): Files + { + if ($this->sortBy === null || $this->isSearching() === true) { + return $models->sorted(); + } + + return parent::sort($models); + } +} diff --git a/public/kirby/src/Panel/Collector/ModelsCollector.php b/public/kirby/src/Panel/Collector/ModelsCollector.php new file mode 100644 index 0000000..df5969f --- /dev/null +++ b/public/kirby/src/Panel/Collector/ModelsCollector.php @@ -0,0 +1,130 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class ModelsCollector +{ + protected Files|Pages|Users $models; + protected Files|Pages|Users $paginated; + + public function __construct( + protected int|null $limit = null, + protected int $page = 1, + protected Site|Page|User|null $parent = null, + protected string|null $query = null, + protected string|null $search = null, + protected string|null $sortBy = null, + protected bool $flip = false, + ) { + } + + abstract protected function collect(): Files|Pages|Users; + abstract protected function collectByQuery(): Files|Pages|Users; + abstract protected function filter(Files|Pages|Users $models): Files|Pages|Users; + + protected function flip(Files|Pages|Users $models): Files|Pages|Users + { + return $models->flip(); + } + + public function isFlipping(): bool + { + if ($this->isSearching() === true) { + return false; + } + + return $this->flip === true; + } + + public function isQuerying(): bool + { + return $this->query !== null; + } + + public function isSearching(): bool + { + return $this->search !== null && trim($this->search) !== ''; + } + + public function isSorting(): bool + { + if ($this->isSearching() === true) { + return false; + } + + return $this->sortBy !== null; + } + + public function models(bool $paginated = false): Files|Pages|Users + { + if ($paginated === true) { + return $this->paginated ??= $this->models()->paginate([ + 'limit' => $this->limit ?? 1000, + 'page' => $this->page, + 'method' => 'none' // the page is manually provided + ]); + } + + if (isset($this->models) === true) { + return $this->models; + } + + if ($this->isQuerying() === true) { + $models = $this->collectByQuery(); + } else { + $models = $this->collect(); + } + + $models = $this->filter($models); + + if ($this->isSearching() === true) { + $models = $this->search($models); + } + + if ($this->isSorting() === true) { + $models = $this->sort($models); + } + + if ($this->isFlipping() === true) { + $models = $this->flip($models); + } + + return $this->models ??= $models; + } + + public function pagination(): Pagination + { + return $this->models(paginated: true)->pagination(); + } + + protected function parent(): Site|Page|User + { + return $this->parent ?? App::instance()->site(); + } + + protected function search(Files|Pages|Users $models): Files|Pages|Users + { + return $models->search($this->search); + } + + protected function sort(Files|Pages|Users $models): Files|Pages|Users + { + return $models->sort(...$models::sortArgs($this->sortBy)); + } +} diff --git a/public/kirby/src/Panel/Collector/PagesCollector.php b/public/kirby/src/Panel/Collector/PagesCollector.php new file mode 100644 index 0000000..5213446 --- /dev/null +++ b/public/kirby/src/Panel/Collector/PagesCollector.php @@ -0,0 +1,85 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PagesCollector extends ModelsCollector +{ + public function __construct( + protected int|null $limit = null, + protected int $page = 1, + protected Site|Page|User|null $parent = null, + protected string|null $query = null, + protected string|null $status = null, + protected array $templates = [], + protected array $templatesIgnore = [], + protected string|null $search = null, + protected string|null $sortBy = null, + protected bool $flip = false, + ) { + } + + protected function collect(): Pages + { + return match ($this->status) { + 'draft' => $this->parent()->drafts(), + 'listed' => $this->parent()->children()->listed(), + 'published' => $this->parent()->children(), + 'unlisted' => $this->parent()->children()->unlisted(), + default => $this->parent()->childrenAndDrafts() + }; + } + + protected function collectByQuery(): Pages + { + return $this->parent()->query($this->query, Pages::class) ?? new Pages([]); + } + + protected function filter(Files|Pages|Users $models): Pages + { + // filters pages that are protected and not in the templates list + // internal `filter()` method used instead of foreach loop that previously included `unset()` + // because `unset()` is updating the original data, `filter()` is just filtering + // also it has been tested that there is no performance difference + // even in 0.1 seconds on 100k virtual pages + return $models->filter(function (Page $model): bool { + // remove all protected and hidden pages + if ($model->isListable() === false) { + return false; + } + + $intendedTemplate = $model->intendedTemplate()->name(); + + // filter by all set templates + if ( + $this->templates && + in_array($intendedTemplate, $this->templates, true) === false + ) { + return false; + } + + // exclude by all ignored templates + if ( + $this->templatesIgnore && + in_array($intendedTemplate, $this->templatesIgnore, true) === true + ) { + return false; + } + + return true; + }); + } +} diff --git a/public/kirby/src/Panel/Collector/UsersCollector.php b/public/kirby/src/Panel/Collector/UsersCollector.php new file mode 100644 index 0000000..5908f11 --- /dev/null +++ b/public/kirby/src/Panel/Collector/UsersCollector.php @@ -0,0 +1,62 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UsersCollector extends ModelsCollector +{ + public function __construct( + protected bool $flip = false, + protected int|null $limit = null, + protected int $page = 1, + protected Site|Page|User|null $parent = null, + protected string|null $query = null, + protected string|null $role = null, + protected string|null $search = null, + protected string|null $sortBy = null, + ) { + } + + protected function collect(): Users + { + return App::instance()->users(); + } + + protected function collectByQuery(): Users + { + return $this->parent()->query($this->query, Users::class) ?? new Users([]); + } + + protected function filter(Files|Pages|Users $models): Users + { + $user = App::instance()->user(); + + if ($user === null) { + return new Users([]); + } + + if ($user->role()->permissions()->for('access', 'users') === false) { + return new Users([]); + } + + if ($this->role !== null) { + $models = $models->role($this->role); + } + + return $models; + } +} 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..ce1107b --- /dev/null +++ b/public/kirby/src/Panel/Controller/Search.php @@ -0,0 +1,88 @@ + + * @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) => (new FileItem(file: $file, info: '{{ file.id }}'))->props()), + '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) => (new PageItem(page: $page, info: '{{ page.id }}'))->props()), + '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) => (new UserItem(user: $user))->props()), + 'pagination' => $users->pagination()?->toArray() + ]; + } +} diff --git a/public/kirby/src/Panel/Dialog.php b/public/kirby/src/Panel/Dialog.php new file mode 100644 index 0000000..ba59794 --- /dev/null +++ b/public/kirby/src/Panel/Dialog.php @@ -0,0 +1,86 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Dialog extends Json +{ + protected static string $key = '$dialog'; + + /** + * Renders dialogs + */ + public static function response($data, array $options = []): Response + { + // interpret true as success + if ($data === true) { + $data = [ + 'code' => 200 + ]; + } + + return parent::response($data, $options); + } + + /** + * Builds the routes for a dialog + */ + public static function routes( + string $id, + string $areaId, + string $prefix = '', + array $options = [] + ) { + $routes = []; + + // create the full pattern with dialogs prefix + $pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/'); + $type = str_replace('$', '', static::$key); + + // create load/submit events from controller class + if ($controller = $options['controller'] ?? null) { + if (is_string($controller) === true) { + if (method_exists($controller, 'for') === true) { + $controller = $controller::for(...); + } else { + $controller = fn (...$args) => new $controller(...$args); + } + } + + $options['load'] ??= fn (...$args) => $controller(...$args)->load(); + $options['submit'] ??= fn (...$args) => $controller(...$args)->submit(); + } + + // load event + $routes[] = [ + 'pattern' => $pattern, + 'type' => $type, + 'area' => $areaId, + 'action' => $options['load'] ?? fn () => 'The load handler is missing' + ]; + + // submit event + $routes[] = [ + 'pattern' => $pattern, + 'type' => $type, + 'area' => $areaId, + 'method' => 'POST', + 'action' => $options['submit'] ?? fn () => 'The submit handler is missing' + ]; + + return $routes; + } +} diff --git a/public/kirby/src/Panel/Document.php b/public/kirby/src/Panel/Document.php new file mode 100644 index 0000000..1c145f0 --- /dev/null +++ b/public/kirby/src/Panel/Document.php @@ -0,0 +1,72 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Document +{ + /** + * Renders the panel document + */ + public static function response(array $fiber): Response + { + $kirby = App::instance(); + $assets = new Assets(); + + // Full HTML response + // @codeCoverageIgnoreStart + try { + if ($assets->link() === true) { + usleep(1); + Response::go($kirby->url('base') . '/' . $kirby->path()); + } + } catch (Throwable $e) { + die('The Panel assets cannot be installed properly. ' . $e->getMessage()); + } + // @codeCoverageIgnoreEnd + + // get the uri object for the panel url + $uri = new Uri($kirby->url('panel')); + + // proper response code + $code = $fiber['$view']['code'] ?? 200; + + // load the main Panel view template + $body = Tpl::load($kirby->root('kirby') . '/views/panel.php', [ + 'assets' => $assets->external(), + 'icons' => $assets->icons(), + 'nonce' => $kirby->nonce(), + 'fiber' => $fiber, + 'panelUrl' => $uri->path()->toString(true) . '/', + ]); + + $frameAncestors = $kirby->option('panel.frameAncestors'); + $frameAncestors = match (true) { + $frameAncestors === true => "'self'", + is_array($frameAncestors) => "'self' " . implode(' ', $frameAncestors), + is_string($frameAncestors) => $frameAncestors, + default => "'none'" + }; + + return new Response($body, 'text/html', $code, [ + 'Content-Security-Policy' => 'frame-ancestors ' . $frameAncestors + ]); + } +} diff --git a/public/kirby/src/Panel/Drawer.php b/public/kirby/src/Panel/Drawer.php new file mode 100644 index 0000000..0952088 --- /dev/null +++ b/public/kirby/src/Panel/Drawer.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Drawer extends Dialog +{ + protected static string $key = '$drawer'; +} diff --git a/public/kirby/src/Panel/Dropdown.php b/public/kirby/src/Panel/Dropdown.php new file mode 100644 index 0000000..de01bf6 --- /dev/null +++ b/public/kirby/src/Panel/Dropdown.php @@ -0,0 +1,71 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Dropdown extends Json +{ + protected static string $key = '$dropdown'; + + /** + * Renders dropdowns + */ + public static function response($data, array $options = []): Response + { + if (is_array($data) === true) { + $data = [ + 'options' => array_values($data) + ]; + } + + return parent::response($data, $options); + } + + /** + * Routes for the dropdown + */ + public static function routes( + string $id, + string $areaId, + string $prefix = '', + Closure|array $options = [] + ): array { + // Handle shortcuts for dropdowns. The name is the pattern + // and options are defined in a Closure + if ($options instanceof Closure) { + $options = [ + 'pattern' => $id, + 'action' => $options + ]; + } + + // create the full pattern with dialogs prefix + $pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/'); + $type = str_replace('$', '', static::$key); + + return [ + // load event + [ + 'pattern' => $pattern, + 'type' => $type, + 'area' => $areaId, + 'method' => 'GET|POST', + 'action' => $options['options'] ?? $options['action'] + ] + ]; + } +} diff --git a/public/kirby/src/Panel/Field.php b/public/kirby/src/Panel/Field.php new file mode 100644 index 0000000..ff375b5 --- /dev/null +++ b/public/kirby/src/Panel/Field.php @@ -0,0 +1,313 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Field +{ + /** + * Creates the routes for a field dialog + * This is most definitely not a good place for this + * method, but as long as the other classes are + * not fully refactored, it still feels appropriate + */ + public static function dialog( + ModelWithContent $model, + string $fieldName, + string|null $path = null, + string $method = 'GET', + ) { + $field = Form::for($model)->field($fieldName); + $routes = []; + + foreach ($field->dialogs() as $dialogId => $dialog) { + $routes = [ + ...$routes, + ...Dialog::routes( + id: $dialogId, + areaId: 'site', + options: $dialog + ) + ]; + } + + return Router::execute($path, $method, $routes); + } + + /** + * Creates the routes for a field drawer + * This is most definitely not a good place for this + * method, but as long as the other classes are + * not fully refactored, it still feels appropriate + */ + public static function drawer( + ModelWithContent $model, + string $fieldName, + string|null $path = null, + string $method = 'GET', + ) { + $field = Form::for($model)->field($fieldName); + $routes = []; + + foreach ($field->drawers() as $drawerId => $drawer) { + $routes = [ + ...$routes, + ...Drawer::routes( + id: $drawerId, + areaId: 'site', + options: $drawer + ) + ]; + } + + return Router::execute($path, $method, $routes); + } + + /** + * A standard email field + */ + public static function email(array $props = []): array + { + return [ + 'label' => I18n::translate('email'), + 'type' => 'email', + 'counter' => false, + ...$props + ]; + } + + /** + * File position + */ + public static function filePosition(File $file, array $props = []): array + { + $index = 0; + $options = []; + + foreach ($file->siblings(false)->sorted() as $sibling) { + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + $options[] = [ + 'value' => $sibling->id(), + 'text' => $sibling->filename(), + 'disabled' => true + ]; + } + + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + return [ + 'label' => I18n::translate('file.sort'), + 'type' => 'select', + 'empty' => false, + 'options' => $options, + ...$props + ]; + } + + + public static function hidden(): array + { + return ['hidden' => true]; + } + + /** + * Page position + */ + public static function pagePosition(Page $page, array $props = []): array + { + $index = 0; + $options = []; + $siblings = $page->parentModel()->children()->listed()->not($page); + + foreach ($siblings as $sibling) { + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + $options[] = [ + 'value' => $sibling->id(), + 'text' => $sibling->title()->value(), + 'disabled' => true + ]; + } + + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + // if only one available option, + // hide field when not in debug mode + if (count($options) < 2) { + return static::hidden(); + } + + return [ + 'label' => I18n::translate('page.changeStatus.position'), + 'type' => 'select', + 'required' => true, + 'options' => $options, + ...$props + ]; + } + + /** + * A regular password field + */ + public static function password(array $props = []): array + { + return [ + 'label' => I18n::translate('password'), + 'type' => 'password', + ...$props + ]; + } + + /** + * User role radio buttons + */ + public static function role( + array $props = [], + Roles|null $roles = null + ): array { + $kirby = App::instance(); + + // if no $roles where provided, fall back to all roles + $roles ??= $kirby->roles(); + + // exclude the admin role, if the user + // is not allowed to change role to admin + $roles = $roles->filter( + fn ($role) => + $role->name() !== 'admin' || + $kirby->user()?->isAdmin() === true + ); + + // turn roles into radio field options + $roles = $roles->values(fn ($role) => [ + 'text' => $role->title(), + 'info' => $role->description() ?? I18n::translate('role.description.placeholder'), + 'value' => $role->name() + ]); + + return [ + 'label' => I18n::translate('role'), + 'type' => count($roles) < 1 ? 'hidden' : 'radio', + 'options' => $roles, + ...$props + ]; + } + + public static function slug(array $props = []): array + { + return [ + 'label' => I18n::translate('slug'), + 'type' => 'slug', + 'allow' => Str::$defaults['slug']['allowed'], + ...$props + ]; + } + + public static function template( + array|null $blueprints = [], + array|null $props = [] + ): array { + $options = []; + + foreach ($blueprints as $blueprint) { + $options[] = [ + 'text' => $blueprint['title'] ?? $blueprint['text'] ?? null, + 'value' => $blueprint['name'] ?? $blueprint['value'] ?? null, + ]; + } + + return [ + 'label' => I18n::translate('template'), + 'type' => 'select', + 'empty' => false, + 'options' => $options, + 'icon' => 'template', + 'disabled' => count($options) <= 1, + ...$props + ]; + } + + public static function title(array $props = []): array + { + return [ + 'label' => I18n::translate('title'), + 'type' => 'text', + 'icon' => 'title', + ...$props + ]; + } + + /** + * Panel translation select box + */ + public static function translation(array $props = []): array + { + $translations = []; + foreach (App::instance()->translations() as $translation) { + $translations[] = [ + 'text' => $translation->name(), + 'value' => $translation->code() + ]; + } + + return [ + 'label' => I18n::translate('language'), + 'type' => 'select', + 'icon' => 'translate', + 'options' => $translations, + 'empty' => false, + ...$props + ]; + } + + public static function username(array $props = []): array + { + return [ + 'icon' => 'user', + 'label' => I18n::translate('name'), + 'type' => 'text', + ...$props + ]; + } +} diff --git a/public/kirby/src/Panel/File.php b/public/kirby/src/Panel/File.php new file mode 100644 index 0000000..ec8e5af --- /dev/null +++ b/public/kirby/src/Panel/File.php @@ -0,0 +1,483 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class File extends Model +{ + /** + * @var \Kirby\Cms\File + */ + protected ModelWithContent $model; + + /** + * Breadcrumb array + */ + public function breadcrumb(): array + { + $breadcrumb = []; + $parent = $this->model->parent(); + + switch ($parent::CLASS_ALIAS) { + case 'user': + /** @var \Kirby\Cms\User $parent */ + // The breadcrumb is not necessary + // on the account view + if ($parent->isLoggedIn() === false) { + $breadcrumb[] = [ + 'label' => $parent->username(), + 'link' => $parent->panel()->url(true) + ]; + } + break; + case 'page': + /** @var \Kirby\Cms\Page $parent */ + $breadcrumb = $this->model->parents()->flip()->values( + fn ($parent) => [ + 'label' => $parent->title()->toString(), + 'link' => $parent->panel()->url(true), + ] + ); + } + + // add the file + $breadcrumb[] = [ + 'label' => $this->model->filename(), + 'link' => $this->url(true), + ]; + + 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 + * + * @param string|null $type (`auto`|`kirbytext`|`markdown`) + */ + public function dragText( + string|null $type = 'auto', + bool $absolute = false + ): string { + $type = $this->dragTextType($type); + $file = $this->model->type(); + $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 ($callback = $this->dragTextFromCallback($type, $url)) { + return $callback; + } + + if ($type === 'markdown') { + return match ($file) { + 'image' => '![' . $this->model->alt() . '](' . $url . ')', + default => '[' . $this->model->filename() . '](' . $url . ')' + }; + } + + return match ($file) { + 'image', 'video' => '(' . $file . ': ' . $url . ')', + default => '(file: ' . $url . ')' + }; + } + + /** + * Provides options for the file dropdown + */ + public function dropdown(array $options = []): array + { + $file = $this->model; + $request = $file->kirby()->request(); + $defaults = $request->get(['delete', 'sort', 'view']); + $options = [...$defaults, ...$options]; + + $permissions = $this->options(['preview']); + $view = $options['view'] ?? 'view'; + $url = $this->url(true); + $result = []; + + if ($view === 'list') { + $result[] = [ + 'link' => $file->previewUrl(), + 'target' => '_blank', + 'icon' => 'open', + 'text' => I18n::translate('open') + ]; + $result[] = '-'; + } + + $result[] = [ + 'dialog' => $url . '/changeName', + 'icon' => 'title', + 'text' => I18n::translate('rename'), + 'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions) + ]; + + if ($view === 'list') { + $result[] = [ + 'dialog' => $url . '/changeSort', + 'icon' => 'sort', + 'text' => I18n::translate('file.sort'), + 'disabled' => $this->isDisabledDropdownOption('sort', $options, $permissions) + ]; + } + + $result[] = [ + 'dialog' => $url . '/changeTemplate', + 'icon' => 'template', + 'text' => I18n::translate('file.changeTemplate'), + 'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions) + ]; + + $result[] = '-'; + + $result[] = [ + 'click' => 'replace', + 'icon' => 'upload', + 'text' => I18n::translate('replace'), + 'disabled' => $this->isDisabledDropdownOption('replace', $options, $permissions) + ]; + + $result[] = '-'; + $result[] = [ + 'dialog' => $url . '/delete', + 'icon' => 'trash', + 'text' => I18n::translate('delete'), + 'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions) + ]; + + return $result; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example + * + * @deprecated 5.1.4 Use the Kirby\Panel\Ui\Item\FileItem class instead + */ + public function dropdownOption(): array + { + return (new FileItem(file: $this->model))->props() + [ + 'icon' => 'image' + ]; + } + + /** + * Returns the Panel icon color + */ + protected function imageColor(): string + { + $types = [ + 'archive' => 'gray-500', + 'audio' => 'aqua-500', + 'code' => 'pink-500', + 'document' => 'red-500', + 'image' => 'orange-500', + 'video' => 'yellow-500', + ]; + + $extensions = [ + 'csv' => 'green-500', + 'doc' => 'blue-500', + 'docx' => 'blue-500', + 'indd' => 'purple-500', + 'rtf' => 'blue-500', + 'xls' => 'green-500', + 'xlsx' => 'green-500', + ]; + + return + $extensions[$this->model->extension()] ?? + $types[$this->model->type()] ?? + parent::imageDefaults()['color']; + } + + /** + * Default settings for the file's Panel image + */ + protected function imageDefaults(): array + { + return [ + ...parent::imageDefaults(), + 'color' => $this->imageColor(), + 'icon' => $this->imageIcon(), + ]; + } + + /** + * Returns the Panel icon type + */ + protected function imageIcon(): string + { + $types = [ + 'archive' => 'archive', + 'audio' => 'audio', + 'code' => 'code', + 'document' => 'document', + 'image' => 'image', + 'video' => 'video', + ]; + + $extensions = [ + 'csv' => 'table', + 'doc' => 'pen', + 'docx' => 'pen', + 'md' => 'markdown', + 'mdown' => 'markdown', + 'rtf' => 'pen', + 'xls' => 'table', + 'xlsx' => 'table', + ]; + + return + $extensions[$this->model->extension()] ?? + $types[$this->model->type()] ?? + 'file'; + } + + /** + * Returns the image file object based on provided query + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + if ($query === null && $this->model->isViewable()) { + return $this->model; + } + + return parent::imageSource($query); + } + + /** + * Whether focus can be added in Panel view + */ + public function isFocusable(): bool + { + // blueprint option + $option = $this->model->blueprint()->focus(); + // fallback to whether the file is viewable + // (images should be focusable by default, others not) + $option ??= $this->model->isViewable(); + + if ($option === false) { + return false; + } + + // ensure that user can update content file + if ($this->options()['update'] === false) { + return false; + } + + $kirby = $this->model->kirby(); + + // ensure focus is only added when editing primary/only language + if ( + $kirby->multilang() === false || + $kirby->languages()->count() === 0 || + $kirby->language()->isDefault() === true + ) { + return true; + } + + return false; + } + + /** + * Returns an array of all actions + * that can be performed in the Panel + * + * @param array $unlock An array of options that will be force-unlocked + */ + public function options(array $unlock = []): array + { + $options = parent::options($unlock); + + try { + // check if the file type is allowed at all, + // otherwise it cannot be replaced + $this->model->match($this->model->blueprint()->accept()); + } catch (Throwable) { + $options['replace'] = false; + } + + return $options; + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + return 'files/' . $this->model->filename(); + } + + /** + * Prepares the response data for file pickers + * and file fields + */ + public function pickerData(array $params = []): array + { + $name = $this->model->filename(); + $id = $this->model->id(); + $absolute = false; + + if (empty($params['model']) === false) { + $parent = $this->model->parent(); + $absolute = $parent !== $params['model']; + + // if the file belongs to the current parent model, + // store only name as ID to keep its path relative to the model + $id = match ($absolute) { + true => $id, + false => $name + }; + } + + $item = new FileItem( + file: $this->model, + dragTextIsAbsolute: $absolute, + image: $params['image'] ?? null, + info: $params['info'] ?? null, + layout: $params['layout'] ?? null, + text: $params['text'] ?? null, + ); + + return [ + ...$item->props(), + 'id' => $id, + 'sortable' => true, + 'type' => $this->model->type(), + ]; + } + + /** + * Returns the data array for the view's component props + */ + public function props(): array + { + $props = parent::props(); + $file = $this->model; + + // 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 + */ + public function prevNext(): array + { + $file = $this->model; + $siblings = $file->templateSiblings()->sortBy( + 'sort', + 'asc', + 'filename', + 'asc' + ); + + return [ + 'next' => function () use ($file, $siblings): array|null { + $next = $siblings->nth($siblings->indexOf($file) + 1); + return $this->toPrevNextLink($next, 'filename'); + }, + 'prev' => function () use ($file, $siblings): array|null { + $prev = $siblings->nth($siblings->indexOf($file) - 1); + return $this->toPrevNextLink($prev, 'filename'); + } + ]; + } + /** + * Returns the url to the editing view + * in the panel + */ + public function url(bool $relative = false): string + { + $parent = $this->model->parent()->panel()->url($relative); + return $parent . '/' . $this->path(); + } + + /** + * Returns the data array for this model's Panel view + */ + public function view(): array + { + return [ + 'breadcrumb' => fn (): array => $this->model->panel()->breadcrumb(), + 'component' => 'k-file-view', + 'props' => $this->props(), + 'search' => 'files', + 'title' => $this->model->filename(), + ]; + } +} diff --git a/public/kirby/src/Panel/Home.php b/public/kirby/src/Panel/Home.php new file mode 100644 index 0000000..cac3ad2 --- /dev/null +++ b/public/kirby/src/Panel/Home.php @@ -0,0 +1,259 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Home +{ + /** + * Returns an alternative URL if access + * to the first choice is blocked. + * + * It will go through the entire menu and + * take the first area which is not disabled + * or locked in other ways + */ + public static function alternative(User $user): string + { + $permissions = $user->role()->permissions(); + + // no access to the panel? The only good alternative is the main url + if ($permissions->for('access', 'panel') === false) { + return App::instance()->site()->url(); + } + + // needed to create a proper menu + $areas = Panel::areas(); + $menu = new Menu($areas, $permissions->toArray()); + $menu = $menu->entries(); + + // go through the menu and search for the first + // available view we can go to + foreach ($menu as $menuItem) { + // skip separators + if ($menuItem === '-') { + continue; + } + + // skip disabled items + if (($menuItem['disabled'] ?? false) === true) { + continue; + } + + // skip buttons that don't open a link + // (but e.g. a dialog) + if (isset($menuItem['link']) === false) { + continue; + } + + // skip the logout button + if ($menuItem['link'] === 'logout') { + continue; + } + + return Panel::url($menuItem['link']); + } + + throw new NotFoundException( + message: 'There’s no available Panel page to redirect to' + ); + } + + /** + * Checks if the user has access to the given + * panel path. This is quite tricky, because we + * need to call a trimmed down router to check + * for available routes and their firewall status. + */ + public static function hasAccess(User $user, string $path): bool + { + $areas = Panel::areas(); + $routes = Panel::routes($areas); + + // Remove fallback routes. Otherwise a route + // would be found even if the view does + // not exist at all. + foreach ($routes as $index => $route) { + if ($route['pattern'] === '(:all)') { + unset($routes[$index]); + } + } + + // create a dummy router to check if we can access this route at all + try { + return Router::execute($path, 'GET', $routes, function ($route) use ($user) { + $attrs = $route->attributes(); + $auth = $attrs['auth'] ?? true; + $areaId = $attrs['area'] ?? null; + $type = $attrs['type'] ?? 'view'; + + // only allow redirects to views + if ($type !== 'view') { + return false; + } + + // if auth is not required the redirect is allowed + if ($auth === false) { + return true; + } + + // check the firewall + return Panel::hasAccess($user, $areaId); + }); + } catch (Throwable) { + return false; + } + } + + /** + * Checks if the given Uri has the same domain + * as the index URL of the Kirby installation. + * This is used to block external URLs to third-party + * domains as redirect options. + */ + public static function hasValidDomain(Uri $uri): bool + { + $rootUrl = App::instance()->site()->url(); + $rootUri = new Uri($rootUrl); + return $uri->domain() === $rootUri->domain(); + } + + /** + * Checks if the given URL is a Panel Url + */ + public static function isPanelUrl(string $url): bool + { + $panel = App::instance()->url('panel'); + return Str::startsWith($url, $panel); + } + + /** + * Returns the path after /panel/ which can then + * be used in the router or to find a matching view + */ + public static function panelPath(string $url): string|null + { + $after = Str::after($url, App::instance()->url('panel')); + return trim($after, '/'); + } + + /** + * Returns the Url that has been stored in the session + * before the last logout. We take this Url if possible + * to redirect the user back to the last point where they + * left before they got logged out. + */ + public static function remembered(): string|null + { + // check for a stored path after login + if ($remembered = App::instance()->session()->pull('panel.path')) { + // convert the result to an absolute URL if available + return Panel::url($remembered); + } + + return null; + } + + /** + * Tries to find the best possible Url to redirect + * the user to after the login. + * + * When the user got logged out, we try to send them back + * to the point where they left. + * + * If they have a custom redirect Url defined in their blueprint + * via the `home` option, we send them there if no Url is stored + * in the session. + * + * If none of the options above find any result, we try to send + * them to the site view. + * + * Before the redirect happens, the final Url is sanitized, the query + * and params are removed to avoid any attacks and the domain is compared + * to avoid redirects to external Urls. + * + * Afterwards, we also check for permissions before the redirect happens + * to avoid redirects to inaccessible Panel views. In such a case + * the next best accessible view is picked from the menu. + */ + public static function url(): string + { + $user = App::instance()->user(); + + // if there's no authenticated user, all internal + // redirects will be blocked and the user is redirected + // to the login instead + if (!$user) { + return Panel::url('login'); + } + + // get the last visited url from the session or the custom home + $url = static::remembered() ?? $user->panel()->home(); + + // inspect the given URL + $uri = new Uri($url); + + // compare domains to avoid external redirects + if (static::hasValidDomain($uri) !== true) { + throw new InvalidArgumentException( + message: 'External URLs are not allowed for Panel redirects' + ); + } + + // remove all params to avoid + // possible attack vectors + $uri->params = ''; + $uri->query = ''; + + // get a clean version of the URL + $url = $uri->toString(); + + // Don't further inspect URLs outside of the Panel + if (static::isPanelUrl($url) === false) { + return $url; + } + + // get the plain panel path + $path = static::panelPath($url); + + // a redirect to login, logout or installation + // views would lead to an infinite redirect loop + if (in_array($path, ['', 'login', 'logout', 'installation'], true) === true) { + $path = 'site'; + } + + // Check if the user can access the URL + if (static::hasAccess($user, $path) === true) { + return Panel::url($path); + } + + // Try to find an alternative + return static::alternative($user); + } +} diff --git a/public/kirby/src/Panel/Json.php b/public/kirby/src/Panel/Json.php new file mode 100644 index 0000000..d13fcd3 --- /dev/null +++ b/public/kirby/src/Panel/Json.php @@ -0,0 +1,84 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Json +{ + protected static string $key = '$response'; + + /** + * Renders the error response with the provided message + */ + public static function error(string $message, int $code = 404): array + { + return [ + 'code' => $code, + 'error' => $message + ]; + } + + /** + * Prepares the JSON response for the Panel + */ + public static function response($data, array $options = []): Response + { + $data = static::responseData($data); + + // always inject the response code + $data['code'] ??= 200; + $data['path'] = $options['path'] ?? null; + $data['query'] = App::instance()->request()->query()->toArray(); + $data['referrer'] = Panel::referrer(); + + return Panel::json([static::$key => $data], $data['code']); + } + + public static function responseData(mixed $data): array + { + // handle redirects + if ($data instanceof Redirect) { + return [ + 'redirect' => $data->location(), + ]; + } + + // handle Kirby exceptions + if ($data instanceof Exception) { + return static::error($data->getMessage(), $data->getHttpCode()); + } + + // handle exceptions + if ($data instanceof Throwable) { + return static::error($data->getMessage(), 500); + } + + // only expect arrays from here on + if (is_array($data) === false) { + return static::error('Invalid response', 500); + } + + if ($data === []) { + return static::error('The response is empty', 404); + } + + return $data; + } +} diff --git a/public/kirby/src/Panel/Lab/Category.php b/public/kirby/src/Panel/Lab/Category.php new file mode 100644 index 0000000..7932871 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Category.php @@ -0,0 +1,134 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore + */ +class Category +{ + protected string $root; + + public function __construct( + protected string $id, + string|null $root = null, + protected array $props = [] + ) { + $this->root = $root ?? static::base() . '/' . $this->id; + + if (F::exists($this->root . '/index.php', static::base()) === true) { + $this->props = array_merge( + require $this->root . '/index.php', + $this->props + ); + } + } + + public static function all(): array + { + // all core lab examples from `kirby/panel/lab` + $examples = A::map( + Dir::inventory(static::base())['children'], + fn ($props) => (new static($props['dirname']))->toArray() + ); + + // all custom lab examples from `site/lab` + $custom = static::factory('site')->toArray(); + + array_push($examples, $custom); + + return $examples; + } + + public static function base(): string + { + return App::instance()->root('panel') . '/lab'; + } + + public function example(string $id, string|null $tab = null): Example + { + return new Example(parent: $this, id: $id, tab: $tab); + } + + public function examples(): array + { + return A::map( + Dir::inventory($this->root)['children'], + fn ($props) => $this->example($props['dirname'])->toArray() + ); + } + + public static function factory(string $id) + { + return match ($id) { + 'site' => static::site(), + default => new static($id) + }; + } + + public function icon(): string + { + return $this->props['icon'] ?? 'palette'; + } + + public function id(): string + { + return $this->id; + } + + public static function isInstalled(): bool + { + return Dir::exists(static::base()) === true; + } + + public function name(): string + { + return $this->props['name'] ?? ucfirst($this->id); + } + + public function root(): string + { + return $this->root; + } + + public static function site(): static + { + return new static( + 'site', + App::instance()->root('site') . '/lab', + [ + 'name' => 'Your examples', + 'icon' => 'live' + ] + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name(), + 'examples' => $this->examples(), + 'icon' => $this->icon(), + 'path' => Str::after( + $this->root(), + App::instance()->root('index') + ), + ]; + } +} 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 new file mode 100644 index 0000000..e0c5c82 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Docs.php @@ -0,0 +1,73 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore + */ +class Docs +{ + /** + * 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 = [...$files, ...Dir::inventory($tmp)['files']]; + } + + $docs = A::map( + $files, + function ($file) { + $component = 'k-' . Str::camelToKebab(F::name($file['filename'])); + return Doc::factory($component)?->toItem(); + } + ); + + $docs = array_filter($docs); + usort($docs, fn ($a, $b) => $a['text'] <=> $b['text']); + + return $docs; + } + + /** + * Whether the Lab docs are installed + */ + public static function isInstalled(): bool + { + return Dir::exists(static::root()) === true; + } + + /** + * 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) { + true => 'tmp', + default => 'dist/ui', + }; + } +} diff --git a/public/kirby/src/Panel/Lab/Example.php b/public/kirby/src/Panel/Lab/Example.php new file mode 100644 index 0000000..6e52aff --- /dev/null +++ b/public/kirby/src/Panel/Lab/Example.php @@ -0,0 +1,297 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore + */ +class Example +{ + protected string $root; + protected string|null $tab = null; + protected array $tabs; + + public function __construct( + protected Category $parent, + protected string $id, + string|null $tab = null, + ) { + $this->root = $this->parent->root() . '/' . $this->id; + + if ($this->exists() === false) { + throw new NotFoundException( + message: 'The example could not be found' + ); + } + + $this->tabs = $this->collectTabs(); + $this->tab = $this->collectTab($tab); + } + + public function collectTab(string|null $tab): string|null + { + if ($this->tabs === []) { + return null; + } + + if (array_key_exists($tab, $this->tabs) === true) { + return $tab; + } + + return array_key_first($this->tabs); + } + + public function collectTabs(): array + { + $tabs = []; + + foreach (Dir::inventory($this->root)['children'] as $child) { + $tabs[$child['dirname']] = [ + 'name' => $child['dirname'], + 'label' => $child['slug'], + 'link' => '/lab/' . $this->parent->id() . '/' . $this->id . '/' . $child['dirname'] + ]; + } + + return $tabs; + } + + public function exists(): bool + { + return Dir::exists($this->root, $this->parent->root()) === true; + } + + public function file(string $filename): string + { + return $this->parent->root() . '/' . $this->path() . '/' . $filename; + } + + public function id(): string + { + return $this->id; + } + + public function load(string $filename): array|null + { + if ($file = $this->file($filename)) { + return F::load($file); + } + + return null; + } + + public function module(): string + { + return $this->url() . '/index.vue'; + } + + public function path(): string + { + return match ($this->tab) { + null => $this->id, + default => $this->id . '/' . $this->tab + }; + } + + public function props(): array + { + if ($this->tab !== null) { + $props = $this->load('../index.php'); + } + + return array_replace_recursive( + $props ?? [], + $this->load('index.php') ?? [] + ); + } + + public function read(string $filename): string|null + { + $file = $this->file($filename); + + if (is_file($file) === false) { + return null; + } + + return F::read($file); + } + + public function root(): string + { + return $this->root; + } + + public function serve(): Response + { + return new Response($this->vue()['script'], 'application/javascript'); + } + + public function tab(): string|null + { + return $this->tab; + } + + public function tabs(): array + { + return $this->tabs; + } + + public function template(string $filename): string|null + { + $file = $this->file($filename); + + if (is_file($file) === false) { + return null; + } + + $data = $this->props(); + return (new Template($file))->render($data); + } + + public function title(): string + { + return basename($this->id); + } + + public function toArray(): array + { + return [ + 'image' => [ + 'icon' => $this->parent->icon(), + 'back' => 'light-dark(white, var(--color-gray-800))', + ], + 'text' => $this->title(), + 'link' => $this->url() + ]; + } + + public function url(): string + { + return '/lab/' . $this->parent->id() . '/' . $this->path(); + } + + public function vue(): array + { + // read the index.vue file (or programmabel Vue PHP file) + $file = $this->read('index.vue'); + $file ??= $this->template('index.vue.php'); + $file ??= ''; + + // extract parts + $parts['script'] = $this->vueScript($file); + $parts['template'] = $this->vueTemplate($file); + $parts['examples'] = $this->vueExamples($parts['template'], $parts['script']); + $parts['style'] = $this->vueStyle($file); + + return $parts; + } + + public function vueExamples(string|null $template, string|null $script): array + { + $template ??= ''; + $examples = []; + $scripts = []; + + if (preg_match_all('!\/\*\* \@script: (.*?)\*\/(.*?)\/\*\* \@script-end \*\/!s', $script, $matches)) { + foreach ($matches[1] as $key => $name) { + $code = $matches[2][$key]; + $code = preg_replace('!const (.*?) \=!', 'default', $code); + + $scripts[trim($name)] = $code; + } + } + + if (preg_match_all('!(.*?)<\/k-lab-example>!s', $template, $matches)) { + foreach ($matches[1] as $key => $name) { + $tail = $matches[2][$key]; + $code = $matches[3][$key]; + + $scriptId = trim(preg_replace_callback( + '!script="(.*?)"!', + fn ($match) => trim($match[1]), + $tail + )); + + $scriptBlock = $scripts[$scriptId] ?? null; + + if (empty($scriptBlock) === false) { + $js = PHP_EOL . PHP_EOL; + $js .= ''; + } else { + $js = ''; + } + + // only use the code between the @code and @code-end comments + if (preg_match('$(.*?)$s', $code, $match)) { + $code = $match[1]; + } + + if (preg_match_all('/^(\t*)\S/m', $code, $indents)) { + // get minimum indent + $indents = array_map(fn ($i) => strlen($i), $indents[1]); + $indents = min($indents); + + if (empty($js) === false) { + $indents--; + } + + // strip minimum indent from each line + $code = preg_replace('/^\t{' . $indents . '}/m', '', $code); + } + + $code = trim($code); + + if (empty($js) === false) { + $code = ''; + } + + $examples[$name] = $code . $js; + } + } + + return $examples; + } + + public function vueScript(string $file): string + { + if (preg_match('!!s', $file, $match)) { + return trim($match[1]); + } + + return 'export default {}'; + } + + public function vueStyle(string $file): string|null + { + if (preg_match('!!s', $file, $match)) { + return trim($match[1]); + } + + return null; + } + + public function vueTemplate(string $file): string|null + { + if (preg_match('!!s', $file, $match)) { + return preg_replace('!^\n!', '', $match[1]); + } + + return null; + } +} diff --git a/public/kirby/src/Panel/Lab/Snippet.php b/public/kirby/src/Panel/Lab/Snippet.php new file mode 100644 index 0000000..527cc59 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Snippet.php @@ -0,0 +1,25 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore + */ +class Snippet extends BaseSnippet +{ + public static function root(): string + { + return __DIR__ . '/snippets'; + } +} diff --git a/public/kirby/src/Panel/Lab/Template.php b/public/kirby/src/Panel/Lab/Template.php new file mode 100644 index 0000000..15c5612 --- /dev/null +++ b/public/kirby/src/Panel/Lab/Template.php @@ -0,0 +1,33 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @internal + * @codeCoverageIgnore + */ +class Template extends BaseTemplate +{ + public function __construct( + public string $file + ) { + parent::__construct( + name: basename($this->file) + ); + } + + public function file(): string|null + { + return $this->file; + } +} diff --git a/public/kirby/src/Panel/Menu.php b/public/kirby/src/Panel/Menu.php new file mode 100644 index 0000000..a5e8cbd --- /dev/null +++ b/public/kirby/src/Panel/Menu.php @@ -0,0 +1,219 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + * @unstable + */ +class Menu +{ + public function __construct( + protected array $areas = [], + protected array $permissions = [], + protected string|null $current = null + ) { + } + + /** + * Returns all areas that are configured for the menu + */ + public function areas(): array + { + // get from config option which areas should be listed in the menu + $kirby = App::instance(); + $areas = $kirby->option('panel.menu'); + + if ($areas instanceof Closure) { + $areas = $areas($kirby); + } + + // if no config is defined… + if ($areas === null) { + // ensure that some defaults are on top in the right order + $defaults = ['site', 'languages', 'users', 'system']; + // add all other areas after that + $additionals = array_diff(array_keys($this->areas), $defaults); + $areas = [...$defaults, ...$additionals]; + } + + $result = []; + + foreach ($areas as $id => $area) { + // separator, keep as is in array + if ($area === '-') { + $result[] = '-'; + continue; + } + + // for a simple id, get global area definition + if (is_numeric($id) === true) { + $id = $area; + $area = $this->areas[$id] ?? null; + } + + // did not receive custom entry definition in config, + // but also is not a global area + if ($area === null) { + continue; + } + + // merge area definition (e.g. from config) + // with global area definition + if (is_array($area) === true) { + $area = Panel::area($id, [ + ...$this->areas[$id] ?? [], + 'menu' => true, + ...$area + ]); + } + + $result[] = $area; + } + + return $result; + } + + /** + * Transforms an area definition into a menu entry + */ + public function entry(array $area): array|false + { + // areas without access permissions get skipped entirely + if ($this->hasPermission($area['id']) === false) { + return false; + } + + // check menu setting from the area definition + $menu = $area['menu'] ?? false; + + // menu setting can be a callback + // that returns true, false or 'disabled' + if ($menu instanceof Closure) { + $menu = $menu($this->areas, $this->permissions, $this->current); + } + + // false will remove the area/entry entirely + //just like with disabled permissions + if ($menu === false) { + return false; + } + + $menu = match ($menu) { + 'disabled' => ['disabled' => true], + true => [], + default => $menu + }; + + $entry = [ + 'current' => $this->isCurrent( + $area['id'], + $area['current'] ?? null + ), + 'icon' => $area['icon'] ?? null, + 'link' => $area['link'] ?? null, + 'dialog' => $area['dialog'] ?? null, + 'drawer' => $area['drawer'] ?? null, + '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 + if (isset($entry['dialog']) || isset($entry['drawer'])) { + unset($entry['link']); + } + + return array_filter($entry); + } + + /** + * Returns all menu entries + */ + public function entries(): array + { + $entries = []; + $areas = $this->areas(); + + foreach ($areas as $area) { + if ($area === '-') { + $entries[] = '-'; + } elseif ($entry = $this->entry($area)) { + $entries[] = $entry; + } + } + + $entries[] = '-'; + + return [...$entries, ...$this->options()]; + } + + /** + * Checks if the access permission to a specific area is granted. + * Defaults to allow access. + */ + public function hasPermission(string $id): bool + { + return $this->permissions['access'][$id] ?? true; + } + + /** + * Whether the menu entry should receive aria-current + */ + public function isCurrent( + string $id, + bool|Closure|null $callback = null + ): bool { + if ($callback !== null) { + if ($callback instanceof Closure) { + $callback = $callback($this->current); + } + + return $callback; + } + + return $this->current === $id; + } + + /** + * Default options entries for bottom of menu + */ + public function options(): array + { + $options = [ + [ + 'icon' => 'edit-line', + 'dialog' => 'changes', + 'text' => I18n::translate('changes'), + ], + [ + 'current' => $this->isCurrent('account'), + 'icon' => 'account', + 'link' => 'account', + 'disabled' => $this->hasPermission('account') === false, + 'text' => I18n::translate('view.account'), + ], + [ + 'icon' => 'logout', + 'link' => 'logout', + 'text' => I18n::translate('logout') + ] + ]; + + return $options; + } +} diff --git a/public/kirby/src/Panel/Model.php b/public/kirby/src/Panel/Model.php new file mode 100644 index 0000000..7826175 --- /dev/null +++ b/public/kirby/src/Panel/Model.php @@ -0,0 +1,479 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Model +{ + public function __construct( + protected ModelWithContent $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 $this->versions()['changes']; + } + + /** + * Returns the drag text from a custom callback + * if the callback is defined in the config + * @internal + * + * @param string $type markdown or kirbytext + */ + public function dragTextFromCallback(string $type, ...$args): string|null + { + $option = 'panel.' . $type . '.' . $this->model::CLASS_ALIAS . 'DragText'; + $callback = $this->model->kirby()->option($option); + + if ($callback instanceof Closure) { + return $callback($this->model, ...$args); + } + + return null; + } + + /** + * Returns the correct drag text type + * depending on the given type or the + * configuration + * + * @internal + * + * @param string|null $type (`auto`|`kirbytext`|`markdown`) + */ + public function dragTextType(string|null $type = 'auto'): string + { + $type ??= 'auto'; + + if ($type === 'auto') { + $kirby = $this->model->kirby(); + $type = $kirby->option('panel.kirbytext', true) ? 'kirbytext' : 'markdown'; + } + + return $type === 'markdown' ? 'markdown' : 'kirbytext'; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + */ + public function dropdownOption(): array + { + return [ + 'icon' => 'page', + 'image' => $this->image(['back' => 'black']), + 'link' => $this->url(true), + 'text' => $this->model->id(), + ]; + } + + /** + * Returns the Panel image definition + */ + public function image( + string|array|false|null $settings = [], + string $layout = 'list' + ): array|null { + // completely switched off + if ($settings === false) { + return null; + } + + // switched off from blueprint, + // only if not overwritten by $settings + $blueprint = $this->model->blueprint()->image(); + + if ($blueprint === false) { + if (empty($settings) === true) { + return null; + } + + $blueprint = null; + } + + // convert string blueprint settings to proper array + if (is_string($blueprint) === true) { + $blueprint = ['query' => $blueprint]; + } + + // skip image thumbnail if option + // is explicitly set to show the icon + if ($settings === 'icon') { + $settings = ['query' => false]; + } + + // convert string settings to proper array + if (is_string($settings) === true) { + $settings = ['query' => $settings]; + } + + // merge with defaults and blueprint option + $settings = [ + ...$this->imageDefaults(), + ...$settings ?? [], + ...$blueprint ?? [], + ]; + + if ($image = $this->imageSource($settings['query'] ?? null)) { + // main url + $settings['url'] = $image->url(); + + if ($image->isResizable() === true) { + // only create srcsets for resizable files + $settings['src'] = static::imagePlaceholder(); + $settings['srcset'] = $this->imageSrcset($image, $layout, $settings); + } elseif ($image->isViewable() === true) { + $settings['src'] = $image->url(); + } + } + + unset($settings['query']); + + // resolve remaining options defined as query + return A::map($settings, function ($option) { + if (is_string($option) === false) { + return $option; + } + + return $this->model->toString($option); + }); + } + + /** + * Default settings for Panel image + */ + protected function imageDefaults(): array + { + return [ + 'back' => 'pattern', + 'color' => 'gray-500', + 'cover' => false, + 'icon' => 'page' + ]; + } + + /** + * Data URI placeholder string for Panel image + */ + public static function imagePlaceholder(): string + { + return 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw'; + } + + /** + * Returns the image file object based on provided query + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + $image = $this->model->query($query ?? null); + + // validate the query result + if ( + $image instanceof CmsFile || + $image instanceof Asset + ) { + return $image; + } + + return null; + } + + /** + * Provides the correct srcset string based on + * the layout and settings + */ + protected function imageSrcset( + CmsFile|Asset $image, + string $layout, + array $settings + ): string|null { + // depending on layout type, set different sizes + // to have multiple options for the srcset attribute + $sizes = match ($layout) { + 'cards' => [352, 864, 1408], + 'cardlets' => [96, 192], + default => [38, 76] + }; + + // no additional modfications needed if `cover: false` + if (($settings['cover'] ?? false) === false) { + return $image->srcset($sizes); + } + + // for card layouts with `cover: true` provide + // crops based on the card ratio + if ($layout === 'cards') { + $ratio = $settings['ratio'] ?? '1/1'; + + if (is_numeric($ratio) === false) { + $ratio = explode('/', $ratio); + $ratio = $ratio[0] / $ratio[1]; + } + + return $image->srcset([ + $sizes[0] . 'w' => [ + 'width' => $sizes[0], + 'height' => round($sizes[0] / $ratio), + 'crop' => true + ], + $sizes[1] . 'w' => [ + 'width' => $sizes[1], + 'height' => round($sizes[1] / $ratio), + 'crop' => true + ], + $sizes[2] . 'w' => [ + 'width' => $sizes[2], + 'height' => round($sizes[2] / $ratio), + 'crop' => true + ] + ]); + } + + // for list and cardlets with `cover: true` + // provide square crops in two resolutions + return $image->srcset([ + '1x' => [ + 'width' => $sizes[0], + 'height' => $sizes[0], + 'crop' => true + ], + '2x' => [ + 'width' => $sizes[1], + 'height' => $sizes[1], + 'crop' => true + ] + ]); + } + + /** + * Checks for disabled dropdown options according + * to the given permissions + */ + public function isDisabledDropdownOption( + string $action, + array $options, + array $permissions + ): bool { + $option = $options[$action] ?? true; + + return + $permissions[$action] === false || + $option === false || + $option === 'false'; + } + + /** + * Returns the corresponding model object + * @since 5.0.0 + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * Returns an array of all actions + * that can be performed in the Panel + * This also checks for the lock status + * + * @param array $unlock An array of options that will be force-unlocked + */ + public function options(array $unlock = []): array + { + $options = $this->model->permissions()->toArray(); + + if ($this->model->lock()->isLocked() === true) { + foreach ($options as $key => $value) { + if (in_array($key, $unlock, true)) { + continue; + } + + $options[$key] = false; + } + } + + return $options; + } + + /** + * Returns the full path without leading slash + */ + abstract public function path(): string; + + /** + * Prepares the response data for page pickers + * and page fields + */ + public function pickerData(array $params = []): array + { + $item = new ModelItem( + model: $this->model, + image: $params['image'] ?? null, + info: $params['info'] ?? null, + layout: $params['layout'] ?? null, + text: $params['text'] ?? null, + ); + + return [ + ...$item->props(), + 'sortable' => true, + 'url' => $this->url(true) + ]; + } + + /** + * 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 = [ + '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 + // this will let the vue component define + // a proper default value + if ($tab) { + $props['tab'] = $tab; + } + + return $props; + } + + /** + * Returns link url and title + * for model (e.g. used for prev/next navigation) + */ + public function toLink(string $title = 'title'): array + { + return [ + 'link' => $this->url(true), + 'title' => $title = (string)$this->model->{$title}() + ]; + } + + /** + * Returns link url and title + * for optional sibling model and + * preserves tab selection + */ + protected function toPrevNextLink( + ModelWithContent|null $model = null, + string $title = 'title' + ): array|null { + if ($model === null) { + return null; + } + + $data = $model->panel()->toLink($title); + + if ($tab = $model->kirby()->request()->get('tab')) { + $uri = new Uri($data['link'], [ + 'query' => ['tab' => $tab] + ]); + + $data['link'] = $uri->toString(); + } + + return $data; + } + + /** + * Returns the url to the editing view + * in the Panel + */ + public function url(bool $relative = false): string + { + if ($relative === true) { + return '/' . $this->path(); + } + + return $this->model->kirby()->url('panel') . '/' . $this->path(); + } + + /** + * Creates an array with two versions of the content: + * `latest` and `changes`. + * + * 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 new file mode 100644 index 0000000..ed87aea --- /dev/null +++ b/public/kirby/src/Panel/Page.php @@ -0,0 +1,387 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Page extends Model +{ + /** + * @var \Kirby\Cms\Page + */ + protected ModelWithContent $model; + + /** + * Breadcrumb array + */ + public function breadcrumb(): array + { + $parents = $this->model->parents()->flip()->merge($this->model); + + return $parents->values( + fn ($parent) => [ + 'label' => $parent->title()->toString(), + 'link' => $parent->panel()->url(true), + ] + ); + } + + /** + * 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 + * + * @param string|null $type (`auto`|`kirbytext`|`markdown`) + */ + public function dragText(string|null $type = null): string + { + $type = $this->dragTextType($type); + + if ($callback = $this->dragTextFromCallback($type)) { + return $callback; + } + + $title = $this->model->title(); + + // type: markdown + if ($type === 'markdown') { + $url = $this->model->permalink() ?? $this->model->url(); + return '[' . $title . '](' . $url . ')'; + } + + // type: kirbytext + $link = $this->model->uuid() ?? $this->model->uri(); + return '(link: ' . $link . ' text: ' . $title . ')'; + } + + /** + * Provides options for the page dropdown + */ + public function dropdown(array $options = []): array + { + $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['open'] = [ + 'link' => $page->previewUrl(), + 'target' => '_blank', + 'icon' => 'open', + 'text' => I18n::translate('open'), + '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[] = '-'; + } + + $result['changeTitle'] = [ + 'dialog' => [ + 'url' => $url . '/changeTitle', + 'query' => [ + 'select' => 'title' + ] + ], + 'icon' => 'title', + 'text' => I18n::translate('rename'), + 'disabled' => $this->isDisabledDropdownOption('changeTitle', $options, $permissions) + ]; + + $result['changeSlug'] = [ + 'dialog' => [ + 'url' => $url . '/changeTitle', + 'query' => [ + 'select' => 'slug' + ] + ], + 'icon' => 'url', + 'text' => I18n::translate('page.changeSlug'), + 'disabled' => $this->isDisabledDropdownOption('changeSlug', $options, $permissions) + ]; + + $result['changeStatus'] = [ + 'dialog' => $url . '/changeStatus', + 'icon' => 'preview', + 'text' => I18n::translate('page.changeStatus'), + 'disabled' => $this->isDisabledDropdownOption('changeStatus', $options, $permissions) + ]; + + $siblings = $page->parentModel()->children()->listed()->not($page); + + $result['changeSort'] = [ + 'dialog' => $url . '/changeSort', + 'icon' => 'sort', + 'text' => I18n::translate('page.sort'), + 'disabled' => $siblings->count() === 0 || $this->isDisabledDropdownOption('sort', $options, $permissions) + ]; + + $result['changeTemplate'] = [ + 'dialog' => $url . '/changeTemplate', + 'icon' => 'template', + 'text' => I18n::translate('page.changeTemplate'), + 'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions) + ]; + + $result[] = '-'; + + $result['move'] = [ + 'dialog' => $url . '/move', + 'icon' => 'parent', + 'text' => I18n::translate('page.move'), + 'disabled' => $this->isDisabledDropdownOption('move', $options, $permissions) + ]; + + $result['duplicate'] = [ + 'dialog' => $url . '/duplicate', + 'icon' => 'copy', + 'text' => I18n::translate('duplicate'), + 'disabled' => $this->isDisabledDropdownOption('duplicate', $options, $permissions) + ]; + + $result[] = '-'; + + $result['delete'] = [ + 'dialog' => $url . '/delete', + 'icon' => 'trash', + 'text' => I18n::translate('delete'), + 'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions) + ]; + + return $result; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + * + * @deprecated 5.1.4 Use the Kirby\Panel\Ui\Item\PageItem class instead + */ + public function dropdownOption(): array + { + return (new PageItem(page: $this->model))->props() + [ + 'icon' => 'page' + ]; + } + + /** + * Returns the escaped Id, which is + * used in the panel to make routing work properly + */ + public function id(): string + { + return str_replace('/', '+', $this->model->id()); + } + + /** + * Default settings for the page's Panel image + */ + protected function imageDefaults(): array + { + $defaults = []; + + if ($icon = $this->model->blueprint()->icon()) { + $defaults['icon'] = $icon; + } + + return [ + ...parent::imageDefaults(), + ...$defaults + ]; + } + + /** + * Returns the image file object based on provided query + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + $query ??= 'page.image'; + return parent::imageSource($query); + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + return 'pages/' . $this->id(); + } + + /** + * Prepares the response data for page pickers + * and page fields + */ + public function pickerData(array $params = []): array + { + $item = new PageItem( + page: $this->model, + image: $params['image'] ?? null, + info: $params['info'] ?? null, + layout: $params['layout'] ?? null, + text: $params['text'] ?? null, + ); + + return [ + ...$item->props(), + 'hasChildren' => $this->model->hasChildren(), + 'sortable' => true + ]; + } + + /** + * The best applicable position for + * the position/status dialog + */ + public function position(): int + { + return + $this->model->num() ?? + $this->model->parentModel()->children()->listed()->not($this->model)->count() + 1; + } + + /** + * Returns navigation array with + * previous and next page + * based on blueprint definition + */ + public function prevNext(): array + { + $page = $this->model; + + // create siblings collection based on + // blueprint navigation + $siblings = static function (string $direction) use ($page) { + $navigation = $page->blueprint()->navigation(); + $sortBy = $navigation['sortBy'] ?? null; + $status = $navigation['status'] ?? null; + $template = $navigation['template'] ?? null; + $direction = $direction === 'prev' ? 'prev' : 'next'; + + // if status is defined in navigation, + // all items in the collection are used + // (drafts, listed and unlisted) otherwise + // it depends on the status of the page + $siblings = $status !== null ? $page->parentModel()->childrenAndDrafts() : $page->siblings(); + + // sort the collection if custom sortBy + // defined in navigation otherwise + // default sorting will apply + if ($sortBy !== null) { + $siblings = $siblings->sort(...$siblings::sortArgs($sortBy)); + } + + $siblings = $page->{$direction . 'All'}($siblings); + + if (empty($navigation) === false) { + $statuses = (array)($status ?? $page->status()); + $templates = (array)($template ?? $page->intendedTemplate()); + + // do not filter if template navigation is all + 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, true) === false) { + $siblings = $siblings->filter('status', 'in', $statuses); + } + } else { + $siblings = $siblings + ->filter('intendedTemplate', $page->intendedTemplate()) + ->filter('status', $page->status()); + } + + return $siblings->filter('isListable', true); + }; + + return [ + 'next' => fn () => $this->toPrevNextLink($siblings('next')->first()), + 'prev' => fn () => $this->toPrevNextLink($siblings('prev')->last()) + ]; + } + + /** + * Returns the data array for the view's component props + */ + public function props(): array + { + $props = parent::props(); + + // 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 + */ + public function view(): array + { + return [ + 'breadcrumb' => $this->model->panel()->breadcrumb(), + 'component' => 'k-page-view', + 'props' => $props = $this->props(), + 'title' => $props['title'], + ]; + } +} diff --git a/public/kirby/src/Panel/PageCreateDialog.php b/public/kirby/src/Panel/PageCreateDialog.php new file mode 100644 index 0000000..b69dbe4 --- /dev/null +++ b/public/kirby/src/Panel/PageCreateDialog.php @@ -0,0 +1,432 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageCreateDialog +{ + protected PageBlueprint $blueprint; + protected Page $model; + protected Page|Site $parent; + protected string $parentId; + protected string|null $sectionId; + protected string|null $slug; + protected string|null $template; + protected string|null $title; + protected string|null $uuid; + protected Page|Site|User|File $view; + protected string|null $viewId; + + public static array $fieldTypes = [ + 'checkboxes', + 'date', + 'email', + 'info', + 'line', + 'link', + 'list', + 'number', + 'multiselect', + 'radio', + 'range', + 'select', + 'slug', + 'tags', + 'tel', + 'text', + 'toggle', + 'toggles', + 'time', + 'url' + ]; + + public function __construct( + string|null $parentId, + string|null $sectionId, + string|null $template, + string|null $viewId, + + // optional + string|null $slug = null, + string|null $title = null, + string|null $uuid = null, + ) { + $this->parentId = $parentId ?? 'site'; + $this->parent = Find::parent($this->parentId); + $this->sectionId = $sectionId; + $this->slug = $slug; + $this->template = $template; + $this->title = $title; + $this->uuid = $uuid; + $this->viewId = $viewId; + $this->view = Find::parent($this->viewId ?? $this->parentId); + } + + /** + * Get the blueprint settings for the new page + */ + public function blueprint(): PageBlueprint + { + // create a temporary page object + return $this->blueprint ??= $this->model()->blueprint(); + } + + /** + * Get an array of all blueprints for the parent view + */ + public function blueprints(): array + { + return A::map( + $this->view->blueprints($this->sectionId), + function ($blueprint) { + $blueprint['name'] ??= $blueprint['value'] ?? null; + return $blueprint; + } + ); + } + + /** + * All the default fields for the dialog + */ + public function coreFields(): array + { + $fields = []; + + $title = $this->blueprint()->create()['title'] ?? null; + $slug = $this->blueprint()->create()['slug'] ?? null; + + if ($title === false || $slug === false) { + throw new InvalidArgumentException( + message: 'Page create dialog: title and slug must not be false' + ); + } + + // title field + if ($title === null || is_array($title) === true) { + $label = $title['label'] ?? 'title'; + $fields['title'] = Field::title([ + ...$title ?? [], + 'label' => I18n::translate($label, $label), + 'required' => true, + 'preselect' => true + ]); + } + + // slug field + if ($slug === null) { + $fields['slug'] = Field::slug([ + 'required' => true, + 'sync' => 'title', + 'path' => $this->parent instanceof Page ? '/' . $this->parent->id() . '/' : '/' + ]); + } + + // pass uuid field to the dialog if uuids are enabled + // to use the same uuid and prevent generating a new one + // when the page is created + if (Uuids::enabled() === true) { + $fields['uuid'] = Field::hidden(); + } + + return [ + ...$fields, + 'parent' => Field::hidden(), + 'section' => Field::hidden(), + 'template' => Field::hidden(), + 'view' => Field::hidden(), + ]; + } + + /** + * Loads custom fields for the page type + */ + public function customFields(): array + { + $custom = []; + $ignore = ['title', 'slug', 'parent', 'template', 'uuid']; + $blueprint = $this->blueprint(); + $fields = $blueprint->fields(); + + foreach ($blueprint->create()['fields'] ?? [] as $name) { + if (!$field = ($fields[$name] ?? null)) { + throw new InvalidArgumentException( + message: 'Unknown field "' . $name . '" 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) === true) { + throw new InvalidArgumentException( + message: 'Field name "' . $name . '" not allowed as custom field in create dialog' + ); + } + + // switch all fields to 1/1 + $field['width'] = '1/1'; + + // add the field to the form + $custom[$name] = $field; + } + + // create form so that field props, options etc. + // can be properly resolved + $form = new Form( + fields: $custom, + model: $this->model() + ); + + return $form->fields()->toProps(); + } + + /** + * Loads all the fields for the dialog + */ + public function fields(): array + { + return [ + ...$this->coreFields(), + ...$this->customFields() + ]; + } + + /** + * Provides all the props for the + * dialog, including the fields and + * initial values + */ + public function load(): array + { + $blueprints = $this->blueprints(); + + $this->template ??= $blueprints[0]['name']; + + $status = $this->blueprint()->create()['status'] ?? 'draft'; + $status = $this->blueprint()->status()[$status]['label'] ?? null; + $status ??= I18n::translate('page.status.' . $status); + + $fields = $this->fields(); + $visible = array_filter( + $fields, + fn ($field) => ($field['hidden'] ?? null) !== true + ); + + // immediately submit the dialog if there is no editable field + if ($visible === [] && count($blueprints) < 2) { + $input = $this->value(); + $response = $this->submit($input); + $response['redirect'] ??= $this->parent->panel()->url(true); + Panel::go($response['redirect']); + } + + return [ + 'component' => 'k-page-create-dialog', + 'props' => [ + 'blueprints' => $blueprints, + 'fields' => $fields, + 'submitButton' => I18n::template('page.create', [ + 'status' => $status + ]), + 'template' => $this->template, + 'value' => $this->value() + ] + ]; + } + + /** + * Temporary model for the page to + * be created, used to properly render + * the blueprint for fields + */ + public function model(): Page + { + if (isset($this->model) === true) { + return $this->model; + } + + $props = [ + 'slug' => '__new__', + 'template' => $this->template, + 'model' => $this->template, + 'parent' => $this->parent instanceof Page ? $this->parent : null + ]; + + // make sure that a UUID gets generated + // and added to content right away + if (Uuids::enabled() === true) { + $props['content'] = [ + 'uuid' => $this->uuid = Uuid::generate() + ]; + } + + $this->model = Page::factory($props); + + // change the storage to memory immediately + // since this is a temporary model + // so that the model does not write to disk + $this->model->changeStorage(MemoryStorage::class); + + return $this->model; + } + + /** + * Generates values for title and slug + * from template strings from the blueprint + */ + public function resolveFieldTemplates(array $input): array + { + $title = $this->blueprint()->create()['title'] ?? null; + $slug = $this->blueprint()->create()['slug'] ?? null; + + // create temporary page object + // to resolve the template strings + $page = $this->model()->clone(['content' => $input]); + + if (is_string($title)) { + $input['title'] = $page->toSafeString($title); + } + + if (is_string($slug)) { + $input['slug'] = $page->toSafeString($slug); + } + + return $input; + } + + /** + * Prepares and cleans up the input data + */ + public function sanitize(array $input): array + { + $input['title'] ??= $this->title ?? ''; + $input['slug'] ??= $this->slug ?? ''; + $input['uuid'] ??= $this->uuid ?? null; + + $input = $this->resolveFieldTemplates($input); + $content = ['title' => trim($input['title'])]; + + if ($uuid = $input['uuid'] ?? null) { + $content['uuid'] = $uuid; + } + + foreach ($this->customFields() as $name => $field) { + $content[$name] = $input[$name] ?? null; + } + + // create temporary form to sanitize the input + // and add default values + $form = Form::for($this->model())->fill(input: $content); + + return [ + 'content' => $form->strings(true), + 'slug' => $input['slug'], + 'template' => $this->template, + ]; + } + + /** + * Submits the dialog form and creates the new page + */ + public function submit(array $input): array + { + $input = $this->sanitize($input); + $status = $this->blueprint()->create()['status'] ?? 'draft'; + + // validate the input before creating the page + $this->validate($input, $status); + + $page = $this->parent->createChild($input); + + if ($status !== 'draft') { + // grant all permissions as the status is set in the blueprint and + // should not be treated as if the user would try to change it + $page->kirby()->impersonate( + 'kirby', + fn () => $page->changeStatus($status) + ); + } + + $payload = [ + 'event' => 'page.create' + ]; + + // add redirect, if not explicitly disabled + if (($this->blueprint()->create()['redirect'] ?? null) !== false) { + $payload['redirect'] = $page->panel()->url(true); + } + + return $payload; + } + + public function validate(array $input, string $status = 'draft'): bool + { + // basic validation + PageRules::validateTitleLength($input['content']['title']); + PageRules::validateSlugLength($input['slug']); + + // if the page is supposed to be published directly, + // ensure that all field validations are met + if ($status !== 'draft') { + // create temporary form to validate the input + $form = Form::for($this->model())->fill(input: $input['content']); + + if ($form->isInvalid() === true) { + throw new InvalidArgumentException( + key: 'page.changeStatus.incomplete' + ); + } + } + + return true; + } + + public function value(): array + { + $value = [ + 'parent' => $this->parentId, + 'section' => $this->sectionId, + 'slug' => $this->slug ?? '', + 'template' => $this->template, + 'title' => $this->title ?? '', + 'uuid' => $this->uuid, + 'view' => $this->viewId, + ]; + + // add default values for custom fields + foreach ($this->customFields() as $name => $field) { + if ($default = $field['default'] ?? null) { + $value[$name] = $default; + } + } + + return $value; + } +} diff --git a/public/kirby/src/Panel/Panel.php b/public/kirby/src/Panel/Panel.php new file mode 100644 index 0000000..0babb24 --- /dev/null +++ b/public/kirby/src/Panel/Panel.php @@ -0,0 +1,643 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Panel +{ + /** + * Normalize a panel area + */ + public static function area(string $id, array $area): array + { + $area['id'] = $id; + $area['label'] ??= $id; + $area['breadcrumb'] ??= []; + $area['breadcrumbLabel'] ??= $area['label']; + $area['title'] = $area['label']; + $area['menu'] ??= false; + $area['link'] ??= $id; + $area['search'] ??= null; + + return $area; + } + + /** + * Collect all registered areas + */ + public static function areas(): array + { + $kirby = App::instance(); + $system = $kirby->system(); + $user = $kirby->user(); + $areas = $kirby->load()->areas(); + + // the system is not ready + if ( + $system->isOk() === false || + $system->isInstalled() === false + ) { + return [ + 'installation' => static::area( + 'installation', + $areas['installation'] + ), + ]; + } + + // not yet authenticated + if (!$user) { + return [ + 'logout' => static::area('logout', $areas['logout']), + // login area last because it defines a fallback route + 'login' => static::area('login', $areas['login']), + ]; + } + + unset($areas['installation'], $areas['login']); + + // Disable the language area for single-language installations + // This does not check for installed languages. Otherwise you'd + // not be able to add the first language through the view + if (!$kirby->option('languages')) { + unset($areas['languages']); + } + + $result = []; + + foreach ($areas as $id => $area) { + $result[$id] = static::area($id, $area); + } + + 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 + */ + public static function firewall( + User|null $user = null, + string|null $areaId = null + ): bool { + // a user has to be logged in + if ($user === null) { + throw new PermissionException( + key: 'access.panel' + ); + } + + // get all access permissions for the user role + $permissions = $user->role()->permissions()->toArray()['access']; + + // check for general panel access + if (($permissions['panel'] ?? true) !== true) { + throw new PermissionException( + key: 'access.panel' + ); + } + + // don't check if the area is not defined + if (empty($areaId) === true) { + return true; + } + + // undefined area permissions means access + if (isset($permissions[$areaId]) === false) { + return true; + } + + // no access + if ($permissions[$areaId] !== true) { + 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 + * + * @throws \Kirby\Panel\Redirect + * @codeCoverageIgnore + */ + public static function go(string|null $url = null, int $code = 302, int|false $refresh = false): void + { + throw new Redirect(static::url($url), $code, $refresh); + } + + /** + * Check if the given user has access to the panel + * or to a given area + */ + public static function hasAccess( + User|null $user = null, + string|null $area = null + ): bool { + try { + static::firewall($user, $area); + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Checks for a Fiber request + * via get parameters or headers + */ + public static function isFiberRequest(): bool + { + $request = App::instance()->request(); + + if ($request->method() === 'GET') { + return + (bool)($request->get('_json') ?? + $request->header('X-Fiber')); + } + + return false; + } + + /** + * Returns a JSON response + * for Fiber calls + */ + public static function json(array $data, int $code = 200): Response + { + $request = App::instance()->request(); + + return Response::json($data, $code, $request->get('_pretty'), [ + 'X-Fiber' => 'true', + 'Cache-Control' => 'no-store, private' + ]); + } + + /** + * Checks for a multilanguage installation + */ + public static function multilang(): bool + { + // multilang setup check + $kirby = App::instance(); + return $kirby->option('languages') || $kirby->multilang(); + } + + /** + * Returns the referrer path if present + */ + public static function referrer(): string + { + $request = App::instance()->request(); + + $referrer = $request->header('X-Fiber-Referrer') + ?? $request->get('_referrer') + ?? ''; + + return '/' . trim($referrer, '/'); + } + + /** + * Creates a Response object from the result of + * a Panel route call + */ + public static function response($result, array $options = []): Response + { + // pass responses directly down to the Kirby router + if ($result instanceof Response) { + return $result; + } + + // interpret missing/empty results as not found + if ($result === null || $result === false) { + $result = new NotFoundException( + message: 'The data could not be found' + ); + + // interpret strings as errors + } elseif (is_string($result) === true) { + $result = new Exception($result); + } + + // handle different response types (view, dialog, ...) + return match ($options['type'] ?? null) { + 'dialog' => Dialog::response($result, $options), + 'drawer' => Drawer::response($result, $options), + 'dropdown' => Dropdown::response($result, $options), + 'request' => Request::response($result, $options), + 'search' => Search::response($result, $options), + default => View::response($result, $options) + }; + } + + /** + * Router for the Panel views + */ + public static function router(string|null $path = null): Response|null + { + $kirby = App::instance(); + + if ($kirby->option('panel') === false) { + 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 + static::setTranslation(); + + // set the language in multi-lang installations + static::setLanguage(); + + $areas = static::areas(); + $routes = static::routes($areas); + + // create a micro-router for the Panel + return Router::execute($path, $method = $kirby->request()->method(), $routes, function ($route) use ($areas, $kirby, $method, $path) { + // route needs authentication? + $auth = $route->attributes()['auth'] ?? true; + $areaId = $route->attributes()['area'] ?? null; + $type = $route->attributes()['type'] ?? 'view'; + $area = $areas[$areaId] ?? null; + + // call the route action to check the result + try { + // trigger hook + $route = $kirby->apply( + 'panel.route:before', + compact('route', 'path', 'method') + ); + + // check for access before executing area routes + if ($auth !== false) { + static::firewall($kirby->user(), $areaId); + } + + $result = $route->action()->call($route, ...$route->arguments()); + } catch (Throwable $e) { + $result = $e; + } + + $response = static::response($result, [ + 'area' => $area, + 'areas' => $areas, + 'path' => $path, + 'type' => $type + ]); + + return $kirby->apply( + 'panel.route:after', + compact('route', 'path', 'method', 'response'), + 'response' + ); + }); + } + + /** + * Extract the routes from the given array + * of active areas. + */ + public static function routes(array $areas): array + { + $kirby = App::instance(); + + // the browser incompatibility + // warning is always needed + $routes = [ + [ + 'pattern' => 'browser', + 'auth' => false, + 'action' => fn () => new Response( + Tpl::load($kirby->root('kirby') . '/views/browser.php') + ), + ] + ]; + + // register all routes from areas + foreach ($areas as $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 + // user is authenticated, those areas won't be + // included, which is why we add redirect routes + // to main Panel view as fallbacks + $routes[] = [ + 'pattern' => [ + '/', + 'installation', + 'login', + ], + 'action' => fn () => Panel::go(Home::url()), + 'auth' => false + ]; + + // catch all route + $routes[] = [ + 'pattern' => '(:all)', + 'action' => fn (string $pattern) => 'Could not find Panel view for route: ' . $pattern + ]; + + return $routes; + } + + /** + * Extract all routes from an area + */ + public static function routesForDialogs(string $areaId, array $area): array + { + $dialogs = $area['dialogs'] ?? []; + $routes = []; + + foreach ($dialogs as $dialogId => $dialog) { + $routes = [ + ...$routes, + ...Dialog::routes( + id: $dialogId, + areaId: $areaId, + prefix: 'dialogs', + options: $dialog + ) + ]; + } + + return $routes; + } + + /** + * Extract all routes from an area + */ + public static function routesForDrawers(string $areaId, array $area): array + { + $drawers = $area['drawers'] ?? []; + $routes = []; + + foreach ($drawers as $drawerId => $drawer) { + $routes = [ + ...$routes, + ...Drawer::routes( + id: $drawerId, + areaId: $areaId, + prefix: 'drawers', + options: $drawer + ) + ]; + } + + return $routes; + } + + /** + * Extract all routes for dropdowns + */ + public static function routesForDropdowns(string $areaId, array $area): array + { + $dropdowns = $area['dropdowns'] ?? []; + $routes = []; + + foreach ($dropdowns as $dropdownId => $dropdown) { + $routes = [ + ...$routes, + ...Dropdown::routes( + id: $dropdownId, + areaId: $areaId, + prefix: 'dropdowns', + options: $dropdown + ) + ]; + } + + return $routes; + } + + /** + * Extract all routes from an area + */ + public static function routesForRequests(string $areaId, array $area): array + { + $routes = $area['requests'] ?? []; + + foreach ($routes as $key => $route) { + $routes[$key]['area'] = $areaId; + $routes[$key]['type'] = 'request'; + } + + return $routes; + } + + /** + * Extract all routes for searches + */ + public static function routesForSearches(string $areaId, array $area): array + { + $searches = $area['searches'] ?? []; + $routes = []; + + foreach ($searches as $name => $params) { + // create the full routing pattern + $pattern = 'search/' . $name; + + // load event + $routes[] = [ + 'pattern' => $pattern, + 'type' => 'search', + 'area' => $areaId, + 'action' => function () use ($params) { + $kirby = App::instance(); + $request = $kirby->request(); + $query = $request->get('query'); + $limit = (int)$request->get('limit', $kirby->option('panel.search.limit', 10)); + $page = (int)$request->get('page', 1); + + return $params['query']($query, $limit, $page); + } + ]; + } + + return $routes; + } + + /** + * Extract all views from an area + */ + public static function routesForViews(string $areaId, array $area): array + { + $views = $area['views'] ?? []; + $routes = []; + + foreach ($views as $view) { + $view['area'] = $areaId; + $view['type'] = 'view'; + + $when = $view['when'] ?? null; + unset($view['when']); + + // enable the route by default, but if there is a + // when condition closure, it must return `true` + if ( + $when instanceof Closure === false || + $when($view, $area) === true + ) { + $routes[] = $view; + } + } + + return $routes; + } + + /** + * Set the current language in multi-lang + * installations based on the session or the + * query language query parameter + */ + public static function setLanguage(): string|null + { + $kirby = App::instance(); + + // language switcher + if (static::multilang()) { + $fallback = 'en'; + + if ($defaultLanguage = $kirby->defaultLanguage()) { + $fallback = $defaultLanguage->code(); + } + + $session = $kirby->session(); + $sessionLanguage = $session->get('panel.language', $fallback); + $language = $kirby->request()->get('language') ?? $sessionLanguage; + + // keep the language for the next visit + if ($language !== $sessionLanguage) { + $session->set('panel.language', $language); + } + + // activate the current language in Kirby + $kirby->setCurrentLanguage($language); + + return $language; + } + + return null; + } + + /** + * Set the currently active Panel translation + * based on the current user or config + */ + public static function setTranslation(): string + { + $kirby = App::instance(); + + // use the user language for the default translation or + // fall back to the language from the config + $translation = $kirby->user()?->language() ?? + $kirby->panelLanguage(); + + $kirby->setCurrentTranslation($translation); + + return $translation; + } + + /** + * Creates an absolute Panel URL + * independent of the Panel slug config + */ + public static function url(string|null $url = null, array $options = []): string + { + // only touch relative paths + if (Url::isAbsolute($url) === false) { + $kirby = App::instance(); + $slug = $kirby->option('panel.slug', 'panel'); + $path = trim($url ?? '', '/'); + + $baseUri = new Uri($kirby->url()); + $basePath = trim($baseUri->path()->toString(), '/'); + + // removes base path if relative path contains it + if (empty($basePath) === false && Str::startsWith($path, $basePath) === true) { + $path = Str::after($path, $basePath); + } + // add the panel slug prefix if it it's not + // included in the path yet + elseif (Str::startsWith($path, $slug . '/') === false) { + $path = $slug . '/' . $path; + } + + // create an absolute URL + $url = CmsUrl::to($path, $options); + } + + return $url; + } +} diff --git a/public/kirby/src/Panel/Plugins.php b/public/kirby/src/Panel/Plugins.php new file mode 100644 index 0000000..03af2e0 --- /dev/null +++ b/public/kirby/src/Panel/Plugins.php @@ -0,0 +1,139 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Plugins +{ + /** + * Cache of all collected plugin files + */ + public array|null $files = null; + + /** + * Collects and returns the plugin files for all plugins + */ + public function files(): array + { + if ($this->files !== null) { + return $this->files; + } + + $this->files = []; + + foreach (App::instance()->plugins() as $plugin) { + $this->files[] = $plugin->root() . '/index.css'; + $this->files[] = $plugin->root() . '/index.js'; + // During plugin development, kirbyup adds an index.dev.mjs as entry point, which + // Kirby will load instead of the regular index.js. Since kirbyup is based on Vite, + // it can't use the standard index.js as entry for its development server: + // Vite requires an entry of type module so it can use JavaScript imports, + // but Kirbyup needs index.js to load as a regular script, synchronously. + $this->files[] = $plugin->root() . '/index.dev.mjs'; + } + + return $this->files; + } + + /** + * Returns the last modification + * of the collected plugin files + */ + public function modified(): int + { + $files = $this->files(); + $modified = [0]; + + foreach ($files as $file) { + $modified[] = F::modified($file); + } + + return max($modified); + } + + /** + * Read the files from all plugins and concatenate them + */ + public function read(string $type): string + { + $dist = []; + + foreach ($this->files() as $file) { + // filter out files with a different type + if (F::extension($file) !== $type) { + continue; + } + + // filter out empty files and files that don't exist + $content = F::read($file); + if (!$content) { + continue; + } + + if ($type === 'mjs') { + // index.dev.mjs files are turned into data URIs so they + // can be imported without having to copy them to /media + // (avoids having to clean the files from /media again) + $content = F::uri($file); + } + + if ($type === 'js') { + // filter out all index.js files that shouldn't be loaded + // because an index.dev.mjs exists + if (F::exists(preg_replace('/\.js$/', '.dev.mjs', $file)) === true) { + continue; + } + + $content = trim($content); + + // make sure that each plugin is ended correctly + if (Str::endsWith($content, ';') === false) { + $content .= ';'; + } + } + + $dist[] = $content; + } + + if ($type === 'mjs') { + // if no index.dev.mjs modules exist, we MUST return an empty string instead + // 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 ($dist === []) { + return ''; + } + + $modules = Json::encode($dist); + $modulePromise = "Promise.all($modules.map(url => import(url)))"; + return "try { await $modulePromise } catch (e) { console.error(e) }" . PHP_EOL; + } + + return implode(PHP_EOL . PHP_EOL, $dist); + } + + /** + * Absolute url to the cache file + * This is used by the panel to link the plugins + */ + public function url(string $type): string + { + return App::instance()->url('media') . '/plugins/index.' . $type . '?' . $this->modified(); + } +} diff --git a/public/kirby/src/Panel/Redirect.php b/public/kirby/src/Panel/Redirect.php new file mode 100644 index 0000000..98e43b3 --- /dev/null +++ b/public/kirby/src/Panel/Redirect.php @@ -0,0 +1,60 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +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 + */ + public function code(): int + { + $codes = [301, 302, 303, 307, 308]; + + if (in_array($this->getCode(), $codes, true) === true) { + return $this->getCode(); + } + + return 302; + } + + /** + * Returns the URL for the redirect + */ + public function location(): string + { + return $this->getMessage(); + } + + /** + * Returns the refresh time in seconds + */ + public function refresh(): int|false + { + return $this->refresh; + } +} diff --git a/public/kirby/src/Panel/Request.php b/public/kirby/src/Panel/Request.php new file mode 100644 index 0000000..9656d7d --- /dev/null +++ b/public/kirby/src/Panel/Request.php @@ -0,0 +1,24 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Request +{ + /** + * Renders request responses + */ + public static function response($data, array $options = []): Response + { + $data = Json::responseData($data); + return Panel::json($data, $data['code'] ?? 200); + } +} diff --git a/public/kirby/src/Panel/Search.php b/public/kirby/src/Panel/Search.php new file mode 100644 index 0000000..f9b4295 --- /dev/null +++ b/public/kirby/src/Panel/Search.php @@ -0,0 +1,41 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Search extends Json +{ + protected static string $key = '$search'; + + public static function response($data, array $options = []): Response + { + if ( + is_array($data) === true && + array_key_exists('results', $data) === false + ) { + $data = [ + 'results' => $data, + 'pagination' => [ + 'page' => 1, + 'limit' => $total = count($data), + 'total' => $total + ] + ]; + } + + return parent::response($data, $options); + } +} diff --git a/public/kirby/src/Panel/Site.php b/public/kirby/src/Panel/Site.php new file mode 100644 index 0000000..c62cd2a --- /dev/null +++ b/public/kirby/src/Panel/Site.php @@ -0,0 +1,112 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Site extends Model +{ + /** + * @var \Kirby\Cms\Site + */ + 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 + * for example. + * + * @deprecated 5.1.4 + */ + public function dropdownOption(): array + { + return [ + 'icon' => 'home', + 'text' => $this->model->toSafeString('{{ site.title }}'), + ] + parent::dropdownOption(); + } + + /** + * Returns the image file object based on provided query + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + $query ??= 'site.image'; + return parent::imageSource($query); + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + return 'site'; + } + + /** + * Returns the data array for the view's component props + */ + public function props(): array + { + $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 + */ + public function view(): array + { + return [ + 'component' => 'k-site-view', + 'props' => $this->props() + ]; + } +} 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..cb97379 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Component.php @@ -0,0 +1,93 @@ + + * @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(), + fn ($prop) => $prop !== null + ) + ]; + } +} 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/Ui/Item/FileItem.php b/public/kirby/src/Panel/Ui/Item/FileItem.php new file mode 100644 index 0000000..cb8e4e1 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Item/FileItem.php @@ -0,0 +1,74 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class FileItem extends ModelItem +{ + /** + * @var \Kirby\Cms\File + */ + protected ModelWithContent $model; + + /** + * @var \Kirby\Panel\File + */ + protected Model $panel; + + public function __construct( + File $file, + protected bool $dragTextIsAbsolute = false, + string|array|false|null $image = [], + string|null $info = null, + string|null $layout = null, + string|null $text = null, + ) { + parent::__construct( + model: $file, + image: $image, + info: $info, + layout: $layout, + text: $text ?? '{{ file.filename }}', + ); + } + + protected function dragText(): string + { + return $this->panel->dragText(absolute: $this->dragTextIsAbsolute); + } + + protected function permissions(): array + { + $permissions = $this->model->permissions(); + + return [ + 'delete' => $permissions->can('delete'), + 'sort' => $permissions->can('sort'), + ]; + } + + public function props(): array + { + return [ + ...parent::props(), + 'dragText' => $this->dragText(), + 'extension' => $this->model->extension(), + 'filename' => $this->model->filename(), + 'mime' => $this->model->mime(), + 'parent' => $this->model->parent()->panel()->path(), + 'template' => $this->model->template(), + 'url' => $this->model->url(), + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/Item/ModelItem.php b/public/kirby/src/Panel/Ui/Item/ModelItem.php new file mode 100644 index 0000000..58914be --- /dev/null +++ b/public/kirby/src/Panel/Ui/Item/ModelItem.php @@ -0,0 +1,74 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class ModelItem extends Component +{ + protected string $layout; + protected Panel $panel; + protected string $text; + + public function __construct( + protected ModelWithContent $model, + protected string|array|false|null $image = [], + protected string|null $info = null, + string|null $layout = null, + string|null $text = null, + ) { + parent::__construct(component: 'k-item'); + + $this->layout = $layout ?? 'list'; + $this->panel = $this->model->panel(); + $this->text = $text ?? '{{ model.title }}'; + } + + protected function info(): string|null + { + return $this->model->toSafeString($this->info ?? false); + } + + protected function image(): array|null + { + return $this->panel->image($this->image, $this->layout); + } + + protected function link(): string + { + return $this->panel->url(true); + } + + protected function permissions(): array + { + return $this->model->permissions()->toArray(); + } + + public function props(): array + { + return [ + 'id' => $this->model->id(), + 'image' => $this->image(), + 'info' => $this->info(), + 'link' => $this->link(), + 'permissions' => $this->permissions(), + 'text' => $this->text(), + 'uuid' => $this->model->uuid()?->toString(), + ]; + } + + protected function text(): string + { + return $this->model->toSafeString($this->text); + } +} diff --git a/public/kirby/src/Panel/Ui/Item/PageItem.php b/public/kirby/src/Panel/Ui/Item/PageItem.php new file mode 100644 index 0000000..a5651d5 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Item/PageItem.php @@ -0,0 +1,74 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class PageItem extends ModelItem +{ + /** + * @var \Kirby\Cms\Page + */ + protected ModelWithContent $model; + + /** + * @var \Kirby\Panel\Page + */ + protected Model $panel; + + public function __construct( + Page $page, + string|array|false|null $image = [], + string|null $info = null, + string|null $layout = null, + string|null $text = null, + ) { + parent::__construct( + model: $page, + image: $image, + info: $info, + layout: $layout, + text: $text ?? '{{ page.title }}', + ); + } + + protected function dragText(): string + { + return $this->panel->dragText(); + } + + protected function permissions(): array + { + $permissions = $this->model->permissions(); + + return [ + 'changeSlug' => $permissions->can('changeSlug'), + 'changeStatus' => $permissions->can('changeStatus'), + 'changeTitle' => $permissions->can('changeTitle'), + 'delete' => $permissions->can('delete'), + 'sort' => $permissions->can('sort'), + ]; + } + + public function props(): array + { + return [ + ...parent::props(), + 'dragText' => $this->dragText(), + 'parent' => $this->model->parentId(), + 'status' => $this->model->status(), + 'template' => $this->model->intendedTemplate()->name(), + 'url' => $this->model->url(), + ]; + } +} diff --git a/public/kirby/src/Panel/Ui/Item/UserItem.php b/public/kirby/src/Panel/Ui/Item/UserItem.php new file mode 100644 index 0000000..d6ade9e --- /dev/null +++ b/public/kirby/src/Panel/Ui/Item/UserItem.php @@ -0,0 +1,38 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class UserItem extends ModelItem +{ + /** + * @var \Kirby\Cms\User + */ + protected ModelWithContent $model; + + public function __construct( + User $user, + string|array|false|null $image = [], + string|null $info = '{{ user.role.title }}', + string|null $layout = null, + string|null $text = null, + ) { + parent::__construct( + model: $user, + image: $image, + info: $info, + layout: $layout, + text: $text ?? '{{ user.username }}', + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Stat.php b/public/kirby/src/Panel/Ui/Stat.php new file mode 100644 index 0000000..931f1d3 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Stat.php @@ -0,0 +1,140 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class Stat extends Component +{ + public function __construct( + public array|string $label, + public array|string $value, + public string $component = 'k-stat', + public array|string|null $dialog = null, + public array|string|null $drawer = null, + public string|null $icon = null, + public array|string|null $info = null, + public array|string|null $link = null, + public ModelWithContent|null $model = null, + public string|null $theme = null, + ) { + } + + public function dialog(): string|null + { + return $this->stringTemplate( + $this->i18n($this->dialog) + ); + } + + public function drawer(): string|null + { + return $this->stringTemplate( + $this->i18n($this->drawer) + ); + } + + /** + * @psalm-suppress TooFewArguments + */ + public static function from( + array|string $input, + ModelWithContent|null $model = null, + ): static { + if ($model !== null) { + if (is_string($input) === true) { + $input = $model->query($input); + + if (is_array($input) === false) { + throw new InvalidArgumentException( + message: 'Invalid data from stat query. The query must return an array.' + ); + } + } + + $input['model'] = $model; + } + + return new static(...$input); + } + + public function icon(): string|null + { + return $this->stringTemplate($this->icon); + } + + public function info(): string|null + { + return $this->stringTemplate( + $this->i18n($this->info) + ); + } + + public function label(): string + { + return $this->stringTemplate( + $this->i18n($this->label) + ); + } + + public function link(): string|null + { + return $this->stringTemplate( + $this->i18n($this->link) + ); + } + + public function props(): array + { + return [ + 'dialog' => $this->dialog(), + 'drawer' => $this->drawer(), + 'icon' => $this->icon(), + 'info' => $this->info(), + 'label' => $this->label(), + 'link' => $this->link(), + 'theme' => $this->theme(), + 'value' => $this->value(), + ]; + } + + protected function stringTemplate(string|null $string = null): string|null + { + if ($this->model === null) { + return $string; + } + + if ($string !== null) { + return $this->model->toString($string); + } + + return null; + } + + public function theme(): string|null + { + return $this->stringTemplate($this->theme); + } + + protected function i18n(string|array|null $param = null): string|null + { + return empty($param) === false ? I18n::translate($param, $param) : null; + } + + public function value(): string + { + return $this->stringTemplate( + $this->i18n($this->value) + ); + } +} diff --git a/public/kirby/src/Panel/Ui/Stats.php b/public/kirby/src/Panel/Ui/Stats.php new file mode 100644 index 0000000..935bb29 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Stats.php @@ -0,0 +1,83 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class Stats extends Component +{ + public function __construct( + public string $component = 'k-stats', + public ModelWithContent|null $model = null, + public array $reports = [], + public string $size = 'large', + ) { + } + + public static function from( + ModelWithContent $model, + array|string $reports, + string $size = 'large' + ): static { + if (is_string($reports) === true) { + $reports = $model->query($reports); + + if (is_array($reports) === false) { + throw new InvalidArgumentException( + message: 'Invalid data from stats query. The query must return an array.' + ); + } + } + + return new static( + model: $model, + reports: $reports, + size: $size + ); + } + + public function props(): array + { + return [ + 'reports' => $this->reports(), + 'size' => $this->size(), + ]; + } + + public function reports(): array + { + $reports = []; + + foreach ($this->reports as $stat) { + // if not already a Stat object, convert it + if ($stat instanceof Stat === false) { + try { + $stat = Stat::from( + input: $stat, + model: $this->model + ); + } catch (InvalidArgumentException) { + continue; + } + } + + $reports[] = $stat->props(); + } + + return $reports; + } + + public function size(): string + { + return $this->size; + } +} diff --git a/public/kirby/src/Panel/Ui/Upload.php b/public/kirby/src/Panel/Ui/Upload.php new file mode 100644 index 0000000..a396922 --- /dev/null +++ b/public/kirby/src/Panel/Ui/Upload.php @@ -0,0 +1,62 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 5.1.0 + */ +class Upload +{ + public function __construct( + protected string $api, + protected string|null $accept = null, + protected array $attributes = [], + protected int|null $max = null, + protected bool $multiple = true, + protected array|bool|null $preview = null, + protected int|null $sort = null, + protected string|null $template = null, + ) { + } + + protected function attributes(): array + { + return [ + ...$this->attributes, + 'sort' => $this->sort, + 'template' => $this->template() + ]; + } + + protected function max(): int|null + { + return $this->multiple() === false ? 1 : $this->max; + } + + protected function multiple(): bool + { + return $this->multiple === true && ($this->max === null || $this->max > 1); + } + + public function props(): array + { + return [ + 'accept' => $this->accept, + 'api' => $this->api, + 'attributes' => $this->attributes(), + 'max' => $this->max(), + 'multiple' => $this->multiple(), + 'preview' => $this->preview, + ]; + } + + protected function template(): string|null + { + return $this->template === 'default' ? null : $this->template; + } +} diff --git a/public/kirby/src/Panel/User.php b/public/kirby/src/Panel/User.php new file mode 100644 index 0000000..fc89c47 --- /dev/null +++ b/public/kirby/src/Panel/User.php @@ -0,0 +1,300 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class User extends Model +{ + /** + * @var \Kirby\Cms\User + */ + protected ModelWithContent $model; + + /** + * Breadcrumb array + */ + public function breadcrumb(): array + { + return [ + [ + 'label' => $this->model->username(), + 'link' => $this->url(true), + ] + ]; + } + + /** + * 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 + */ + public function dropdown(array $options = []): array + { + $account = $this->model->isLoggedIn(); + $i18nPrefix = $account ? 'account' : 'user'; + $permissions = $this->options(['preview']); + $url = $this->url(true); + $result = []; + + $result[] = [ + 'dialog' => $url . '/changeName', + 'icon' => 'title', + 'text' => I18n::translate($i18nPrefix . '.changeName'), + 'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions) + ]; + + $result[] = '-'; + + $result[] = [ + 'dialog' => $url . '/changeEmail', + 'icon' => 'email', + 'text' => I18n::translate('user.changeEmail'), + 'disabled' => $this->isDisabledDropdownOption('changeEmail', $options, $permissions) + ]; + + $result[] = [ + 'dialog' => $url . '/changeRole', + 'icon' => 'bolt', + 'text' => I18n::translate('user.changeRole'), + 'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions) || $this->model->roles()->count() < 2 + ]; + + $result[] = [ + 'dialog' => $url . '/changeLanguage', + 'icon' => 'translate', + 'text' => I18n::translate('user.changeLanguage'), + 'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions) + ]; + + $result[] = '-'; + + $result[] = [ + 'dialog' => $url . '/changePassword', + 'icon' => 'key', + 'text' => I18n::translate('user.changePassword'), + 'disabled' => $this->isDisabledDropdownOption('changePassword', $options, $permissions) + ]; + + if ($this->model->kirby()->system()->is2FAWithTOTP() === true) { + if ($account || $this->model->kirby()->user()->isAdmin()) { + if ($this->model->secret('totp') !== null) { + $result[] = [ + 'dialog' => $url . '/totp/disable', + 'icon' => 'qr-code', + 'text' => I18n::translate('login.totp.disable.option'), + ]; + } elseif ($account) { + $result[] = [ + 'dialog' => $url . '/totp/enable', + 'icon' => 'qr-code', + 'text' => I18n::translate('login.totp.enable.option') + ]; + } + } + } + + $result[] = '-'; + + $result[] = [ + 'dialog' => $url . '/delete', + 'icon' => 'trash', + 'text' => I18n::translate($i18nPrefix . '.delete'), + 'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions) + ]; + + return $result; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + * + * @deprecated 5.1.4 Use the Kirby\Panel\Ui\Item\UserItem class instead + */ + public function dropdownOption(): array + { + return (new UserItem(user: $this->model))->props() + [ + 'icon' => 'user' + ]; + } + + public function home(): string|null + { + if ($home = ($this->model->blueprint()->home() ?? null)) { + $url = $this->model->toString($home); + return Url::to($url); + } + + return Panel::url('site'); + } + + /** + * Default settings for the user's Panel image + */ + protected function imageDefaults(): array + { + return [ + ...parent::imageDefaults(), + 'back' => 'black', + 'icon' => 'user', + 'ratio' => '1/1', + ]; + } + + /** + * Returns the image file object based on provided query + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + if ($query === null) { + return $this->model->avatar(); + } + + return parent::imageSource($query); + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + // path to your own account + if ($this->model->isLoggedIn() === true) { + return 'account'; + } + + return 'users/' . $this->model->id(); + } + + /** + * Returns prepared data for the panel user picker + */ + public function pickerData(array $params = []): array + { + $item = new UserItem( + user: $this->model, + image: $params['image'] ?? null, + info: $params['info'] ?? null, + layout: $params['layout'] ?? null, + text: $params['text'] ?? null, + ); + + return [ + ...$item->props(), + 'email' => $this->model->email(), + 'sortable' => true, + 'username' => $this->model->username(), + ]; + } + + /** + * Returns navigation array with + * previous and next user + */ + public function prevNext(): array + { + $user = $this->model; + + return [ + 'next' => fn () => $this->toPrevNextLink($user->next(), 'username'), + 'prev' => fn () => $this->toPrevNextLink($user->prev(), 'username') + ]; + } + + /** + * Returns the data array for the view's component props + */ + public function props(): array + { + $props = parent::props(); + $user = $this->model; + $permissions = $this->options(); + + // 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'], + ]; + } + + /** + * Returns the Translation object + * for the selected Panel language + */ + public function translation(): Translation + { + $kirby = $this->model->kirby(); + $lang = $this->model->language(); + return $kirby->translation($lang); + } + + /** + * Returns the data array for this model's Panel view + */ + public function view(): array + { + return [ + 'breadcrumb' => $this->breadcrumb(), + 'component' => 'k-user-view', + 'props' => $this->props(), + 'title' => $this->model->username(), + ]; + } +} diff --git a/public/kirby/src/Panel/UserTotpDisableDialog.php b/public/kirby/src/Panel/UserTotpDisableDialog.php new file mode 100644 index 0000000..7b318db --- /dev/null +++ b/public/kirby/src/Panel/UserTotpDisableDialog.php @@ -0,0 +1,116 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserTotpDisableDialog +{ + public App $kirby; + public User $user; + + public function __construct( + string|null $id = null + ) { + $this->kirby = App::instance(); + $this->user = $id ? Find::user($id) : $this->kirby->user(); + } + + /** + * Returns the Panel dialog state when opening the dialog + */ + public function load(): array + { + $currentUser = $this->kirby->user(); + $submitBtn = [ + 'text' => I18n::translate('disable'), + 'icon' => 'protected', + 'theme' => 'negative' + ]; + + // admins can disable TOTP for other users without + // entering their password (but not for themselves) + if ( + $currentUser->isAdmin() === true && + $currentUser->is($this->user) === false + ) { + $name = $this->user->name()->or($this->user->email()); + + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::template('login.totp.disable.admin', ['user' => Escape::html($name)]), + 'submitButton' => $submitBtn, + ] + ]; + } + + // everybody else + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'password' => [ + 'type' => 'password', + 'required' => true, + 'counter' => false, + 'label' => I18n::translate('login.totp.disable.label'), + 'help' => I18n::translate('login.totp.disable.help'), + ] + ], + 'submitButton' => $submitBtn, + ] + ]; + } + + /** + * Removes the user's TOTP secret when the dialog is submitted + */ + public function submit(): array + { + $password = $this->kirby->request()->get('password'); + + try { + if ($this->kirby->user()->is($this->user) === true) { + $this->user->validatePassword($password); + } elseif ($this->kirby->user()->isAdmin() === false) { + throw new PermissionException( + message: 'You are not allowed to disable TOTP for other users' + ); + } + + // Remove the TOTP secret from the account + $this->user->changeTotp(null); + + return [ + 'message' => I18n::translate('login.totp.disable.success') + ]; + } catch (InvalidArgumentException $e) { + // 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 + ); + } + } +} diff --git a/public/kirby/src/Panel/UserTotpEnableDialog.php b/public/kirby/src/Panel/UserTotpEnableDialog.php new file mode 100644 index 0000000..e2917db --- /dev/null +++ b/public/kirby/src/Panel/UserTotpEnableDialog.php @@ -0,0 +1,95 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserTotpEnableDialog +{ + public App $kirby; + public Totp $totp; + public User $user; + + public function __construct() + { + $this->kirby = App::instance(); + $this->user = $this->kirby->user(); + } + + /** + * Returns the Panel dialog state when opening the dialog + */ + public function load(): array + { + return [ + 'component' => 'k-totp-dialog', + 'props' => [ + 'qr' => $this->qr()->toSvg(size: '100%'), + 'value' => ['secret' => $this->secret()] + ] + ]; + } + + /** + * Creates a QR code with a new TOTP secret for the user + */ + public function qr(): QrCode + { + $issuer = $this->kirby->site()->title(); + $label = $this->user->email(); + $uri = $this->totp()->uri($issuer, $label); + return new QrCode($uri); + } + + public function secret(): string + { + return $this->totp()->secret(); + } + + /** + * Changes the user's TOTP secret when the dialog is submitted + */ + public function submit(): array + { + $secret = $this->kirby->request()->get('secret'); + $confirm = $this->kirby->request()->get('confirm'); + + if ($confirm === null) { + throw new InvalidArgumentException( + ['key' => 'login.totp.confirm.missing'] + ); + } + + if ($this->totp($secret)->verify($confirm) === false) { + throw new InvalidArgumentException( + ['key' => 'login.totp.confirm.invalid'] + ); + } + + $this->user->changeTotp($secret); + + return [ + 'message' => I18n::translate('login.totp.enable.success') + ]; + } + + public function totp(string|null $secret = null): Totp + { + return $this->totp ??= new Totp($secret); + } +} diff --git a/public/kirby/src/Panel/View.php b/public/kirby/src/Panel/View.php new file mode 100644 index 0000000..ab037ec --- /dev/null +++ b/public/kirby/src/Panel/View.php @@ -0,0 +1,381 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class View +{ + /** + * Filters the data array based on headers or + * query parameters. Requests can return only + * certain data fields that way or globals can + * be injected on demand. + */ + public static function apply(array $data): array + { + $request = App::instance()->request(); + $only = $request->header('X-Fiber-Only') ?? $request->get('_only'); + + if (empty($only) === false) { + return static::applyOnly($data, $only); + } + + $globals = + $request->header('X-Fiber-Globals') ?? + $request->get('_globals'); + + if (empty($globals) === false) { + return static::applyGlobals($data, $globals); + } + + return A::apply($data); + } + + /** + * Checks if globals should be included in a JSON Fiber request. They are normally + * only loaded with the full document request, but sometimes need to be updated. + * + * A global request can be activated with the `X-Fiber-Globals` header or the + * `_globals` query parameter. + */ + public static function applyGlobals( + array $data, + string|null $globals = null + ): array { + // split globals string into an array of fields + $globalKeys = Str::split($globals, ','); + + // add requested globals + if ($globalKeys === []) { + return $data; + } + + $globals = static::globals(); + + foreach ($globalKeys as $globalKey) { + if (isset($globals[$globalKey]) === true) { + $data[$globalKey] = $globals[$globalKey]; + } + } + + // merge with shared data + return A::apply($data); + } + + /** + * Checks if the request should only return a limited + * set of data. This can be activated with the `X-Fiber-Only` + * header or the `_only` query parameter in a request. + * + * Such requests can fetch shared data or globals. + * Globals will be loaded on demand. + */ + public static function applyOnly( + array $data, + string|null $only = null + ): array { + // split include string into an array of fields + $onlyKeys = Str::split($only, ','); + + // if a full request is made, return all data + if ($onlyKeys === []) { + return $data; + } + + // otherwise filter data based on + // dot notation, e.g. `$props.tab.columns` + $result = []; + + // check if globals are requested and need to be merged + if (Str::contains($only, '$')) { + $data = array_merge_recursive(static::globals(), $data); + } + + // make sure the data is already resolved to make + // nested data fetching work + $data = A::apply($data); + + // build a new array with all requested data + foreach ($onlyKeys as $onlyKey) { + $result[$onlyKey] = A::get($data, $onlyKey); + } + + // Nest dotted keys in array but ignore $translation + return A::nest($result, ['$translation']); + } + + /** + * Creates the shared data array for the individual views + * The full shared data is always sent on every JSON and + * full document request unless the `X-Fiber-Only` header or + * the `_only` query parameter is set. + */ + public static function data(array $view = [], array $options = []): array + { + $kirby = App::instance(); + + // multilang setup check + $multilang = Panel::multilang(); + + // get the authenticated user + $user = $kirby->user(); + + // user permissions + $permissions = $user?->role()->permissions()->toArray() ?? []; + + // current content language + $language = $kirby->language(); + + // shared data for all requests + return [ + '$direction' => function () use ($kirby, $multilang, $language, $user) { + if ($multilang === true && $language && $user) { + $default = $kirby->defaultLanguage(); + + if ( + $language->direction() !== $default->direction() && + $language->code() !== $user->language() + ) { + return $language->direction(); + } + } + }, + '$dialog' => null, + '$drawer' => null, + '$language' => fn () => match ($multilang) { + false => null, + true => $language?->toArray() + }, + '$languages' => fn (): array => match ($multilang) { + false => [], + true => $kirby->languages()->values( + fn ($language) => $language->toArray() + ) + }, + '$menu' => function () use ($options, $permissions) { + $menu = new Menu( + $options['areas'] ?? [], + $permissions, + $options['area']['id'] ?? null + ); + return $menu->entries(); + }, + '$permissions' => $permissions, + '$license' => $kirby->system()->license()->status()->value(), + '$multilang' => $multilang, + '$searches' => static::searches($options['areas'] ?? [], $permissions), + '$url' => $kirby->request()->url()->toString(), + '$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 = [ + 'breadcrumb' => [], + 'code' => 200, + 'path' => Str::after($kirby->path(), '/'), + 'props' => [], + 'query' => App::instance()->request()->query()->toArray(), + 'referrer' => Panel::referrer(), + 'search' => $kirby->option('panel.search.type', 'pages'), + 'timestamp' => (int)(microtime(true) * 1000), + ]; + + $view = array_replace_recursive( + $defaults, + $options['area'] ?? [], + $view + ); + + // make sure that views and dialogs are gone + unset( + $view['buttons'], + $view['dialogs'], + $view['drawers'], + $view['dropdowns'], + $view['requests'], + $view['searches'], + $view['views'] + ); + + // resolve all callbacks in the view array + return A::apply($view); + } + ]; + } + + /** + * Renders the error view with provided message + */ + public static function error(string $message, int $code = 404) + { + return [ + 'code' => $code, + 'component' => 'k-error-view', + 'error' => $message, + 'props' => [ + 'error' => $message, + 'layout' => Panel::hasAccess(App::instance()->user()) ? 'inside' : 'outside' + ], + 'title' => 'Error' + ]; + } + + /** + * Creates global data for the Panel. + * This will be injected in the full Panel + * view via the script tag. Global data + * is only requested once on the first page load. + * It can be loaded partially later if needed, + * but is otherwise not included in Fiber calls. + */ + public static function globals(): array + { + $kirby = App::instance(); + + return [ + '$config' => fn () => [ + 'api' => [ + 'methodOverride' => $kirby->option('api.methodOverride', true) + ], + 'debug' => $kirby->option('debug', false), + 'kirbytext' => $kirby->option('panel.kirbytext', true), + 'theme' => $kirby->option('panel.theme', 'system'), + 'translation' => $kirby->option('panel.language', 'en'), + 'upload' => Upload::chunkSize(), + ], + '$system' => function () use ($kirby) { + $locales = []; + + foreach ($kirby->translations() as $translation) { + $locales[$translation->code()] = $translation->locale(); + } + + return [ + 'ascii' => Str::$ascii, + 'csrf' => $kirby->auth()->csrfFromSession(), + 'isLocal' => $kirby->system()->isLocal(), + 'locales' => $locales, + 'slugs' => Str::$language, + 'title' => $kirby->site()->title()->or('Kirby Panel')->toString() + ]; + }, + '$translation' => function () use ($kirby) { + $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 () => [ + 'api' => $kirby->url('api'), + 'site' => $kirby->url('index') + ] + ]; + } + + /** + * Renders the main panel view either as + * JSON response or full HTML document based + * on the request header or query params + */ + public static function response($data, array $options = []): Response + { + // handle redirects + if ($data instanceof Redirect) { + // if the redirect is a refresh, return a refresh response + if ($data->refresh() !== false) { + return Response::refresh($data->location(), $data->code(), $data->refresh()); + } + + return Response::redirect($data->location(), $data->code()); + } + + // handle Kirby exceptions + if ($data instanceof Exception) { + $data = static::error($data->getMessage(), $data->getHttpCode()); + + // handle regular exceptions + } elseif ($data instanceof Throwable) { + $data = static::error($data->getMessage(), 500); + + // only expect arrays from here on + } elseif (is_array($data) === false) { + $data = static::error('Invalid Panel response', 500); + } + + // get all data for the request + $fiber = static::data($data, $options); + + // if requested, send $fiber data as JSON + if (Panel::isFiberRequest() === true) { + // filter data, if only or globals headers or + // query parameters are set + $fiber = static::apply($fiber); + + return Panel::json($fiber, $fiber['$view']['code'] ?? 200); + } + + // load globals for the full document response + $globals = static::globals(); + + // resolve and merge globals and shared data + $fiber = array_merge_recursive(A::apply($globals), A::apply($fiber)); + + // render the full HTML document + return Document::response($fiber); + } + + public static function searches(array $areas, array $permissions): array + { + $searches = []; + + foreach ($areas as $areaId => $area) { + // by default, all areas are accessible unless + // the permissions are explicitly set to false + if (($permissions['access'][$areaId] ?? true) !== false) { + foreach ($area['searches'] ?? [] as $id => $params) { + $searches[$id] = [ + 'icon' => $params['icon'] ?? 'search', + 'label' => $params['label'] ?? Str::ucfirst($id), + 'id' => $id + ]; + } + } + } + return $searches; + } +} diff --git a/public/kirby/src/Parsley/Element.php b/public/kirby/src/Parsley/Element.php new file mode 100644 index 0000000..02a04d5 --- /dev/null +++ b/public/kirby/src/Parsley/Element.php @@ -0,0 +1,157 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 3.5.0 + */ +class Element +{ + public function __construct( + protected DOMElement $node, + protected array $marks = [] + ) { + } + + /** + * The returns the attribute value or + * the given fallback if the attribute does not exist + */ + public function attr( + string $attr, + string|null $fallback = null + ): string|null { + if ($this->node->hasAttribute($attr) === true) { + return $this->node->getAttribute($attr) ?? $fallback; + } + + return $fallback; + } + + /** + * Returns a list of all child elements + */ + public function children(): DOMNodeList + { + return $this->node->childNodes; + } + + /** + * Returns an array with all class names + */ + public function classList(): array + { + return Str::split($this->className(), ' '); + } + + /** + * Returns the value of the class attribute + */ + public function className(): string|null + { + return $this->attr('class'); + } + + /** + * Returns the original dom element + */ + public function element(): DOMElement + { + return $this->node; + } + + /** + * Returns an array with all nested elements + * that could be found for the given query + */ + public function filter(string $query): array + { + $result = []; + + if ($queryResult = $this->query($query)) { + foreach ($queryResult as $node) { + $result[] = new static($node); + } + } + + return $result; + } + + /** + * Tries to find a single nested element by + * query and otherwise returns null + */ + public function find(string $query): static|null + { + if ($result = $this->query($query)[0]) { + return new static($result); + } + + return null; + } + + /** + * Returns the inner HTML of the element + * + * @param array|null $marks List of allowed marks + */ + public function innerHtml(array|null $marks = null): string + { + $marks ??= $this->marks; + $inline = new Inline($this->node, $marks); + return $inline->innerHtml(); + } + + /** + * Returns the contents as plain text + */ + public function innerText(): string + { + return trim($this->node->textContent); + } + + /** + * Returns the full HTML for the element + */ + public function outerHtml(array|null $marks = null): string + { + return $this->node->ownerDocument->saveHtml($this->node); + } + + /** + * Searches nested elements + */ + public function query(string $query): DOMNodeList|null + { + $path = new DOMXPath($this->node->ownerDocument); + return $path->query($query, $this->node); + } + + /** + * Removes the element from the DOM + */ + public function remove(): void + { + $this->node->parentNode->removeChild($this->node); + } + + /** + * Returns the name of the element + */ + public function tagName(): string + { + return $this->node->tagName; + } +} diff --git a/public/kirby/src/Parsley/Inline.php b/public/kirby/src/Parsley/Inline.php new file mode 100644 index 0000000..63efaf1 --- /dev/null +++ b/public/kirby/src/Parsley/Inline.php @@ -0,0 +1,159 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 3.5.0 + */ +class Inline +{ + protected string $html = ''; + protected array $marks = []; + + public function __construct(DOMNode $node, array $marks = []) + { + $this->createMarkRules($marks); + + $html = static::parseNode($node, $this->marks) ?? ''; + + // only trim HTML if it doesn't consist of only spaces + if (trim($html) !== '') { + $html = trim($html); + } + + $this->html = $html; + } + + /** + * Loads all mark rules + */ + protected function createMarkRules(array $marks): array + { + foreach ($marks as $mark) { + $this->marks[$mark['tag']] = $mark; + } + + return $this->marks; + } + + /** + * Get all allowed attributes for a DOMElement + * as clean array + */ + public static function parseAttrs( + DOMElement $node, + array $marks = [] + ): array { + $attrs = []; + $mark = $marks[$node->tagName]; + $defaults = $mark['defaults'] ?? []; + + foreach ($mark['attrs'] ?? [] as $attr) { + $attrs[$attr] = match ($node->hasAttribute($attr)) { + true => $node->getAttribute($attr), + default => $defaults[$attr] ?? null + }; + } + + return $attrs; + } + + /** + * Parses all children and creates clean HTML + * for each of them. + */ + public static function parseChildren( + DOMNodeList $children, + array $marks + ): string { + $html = ''; + foreach ($children as $child) { + $html .= static::parseNode($child, $marks); + } + return $html; + } + + /** + * Go through all child elements and create + * clean inner HTML for them + */ + public static function parseInnerHtml( + DOMElement $node, + array $marks = [] + ): string|null { + $html = static::parseChildren($node->childNodes, $marks); + + // trim the inner HTML for paragraphs + if ($node->tagName === 'p') { + $html = trim($html); + } + + // return null for empty inner HTML + if ($html === '') { + return null; + } + + return $html; + } + + /** + * Converts the given node to clean HTML + */ + public static function parseNode(DOMNode $node, array $marks = []): string|null + { + if ($node instanceof DOMText) { + return Html::encode($node->textContent); + } + + if ($node instanceof DOMElement) { + // unknown marks + if (array_key_exists($node->tagName, $marks) === false) { + return static::parseChildren($node->childNodes, $marks); + } + + // collect all allowed attributes + $attrs = static::parseAttrs($node, $marks); + + // close self-closing elements + if (Html::isVoid($node->tagName) === true) { + return '<' . $node->tagName . Html::attr($attrs, null, ' ') . ' />'; + } + + $innerHtml = static::parseInnerHtml($node, $marks); + + // skip empty paragraphs + if ($innerHtml === null && $node->tagName === 'p') { + return null; + } + + // create the outer html for the element + $html = '<' . $node->tagName . Html::attr($attrs, null, ' ') . '>'; + $html .= $innerHtml; + $html .= 'tagName . '>'; + return $html; + } + + return null; + } + + /** + * Returns the HTML contents of the element + */ + public function innerHtml(): string + { + return $this->html; + } +} diff --git a/public/kirby/src/Parsley/Parsley.php b/public/kirby/src/Parsley/Parsley.php new file mode 100644 index 0000000..392cfcb --- /dev/null +++ b/public/kirby/src/Parsley/Parsley.php @@ -0,0 +1,303 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 3.5.0 + */ +class Parsley +{ + protected array $blocks = []; + protected DOMDocument $doc; + protected Dom $dom; + protected array $inline = []; + protected array $marks = []; + protected array $nodes = []; + protected Schema $schema; + protected array $skip = []; + + public static bool $useXmlExtension = true; + + public function __construct(string $html, Schema|null $schema = null) + { + // fail gracefully if the XML extension is not installed + // or should be skipped + if ($this->useXmlExtension() === false) { + $this->blocks[] = [ + 'type' => 'markdown', + 'content' => ['text' => $html] + ]; + return; + } + + if (!preg_match('//', $html)) { + $html = '
    ' . $html . '
    '; + } + + $this->dom = new Dom($html); + $this->doc = $this->dom->document(); + $this->schema = $schema ?? new Plain(); + $this->skip = $this->schema->skip(); + $this->marks = $this->schema->marks(); + $this->inline = []; + + // load all allowed nodes from the schema + $this->createNodeRules($this->schema->nodes()); + + // start parsing at the top level and go through + // all children of the document + foreach ($this->doc->childNodes as $childNode) { + $this->parseNode($childNode); + } + + // needs to be called at last to fetch remaining + // inline elements after parsing has ended + $this->endInlineBlock(); + } + + /** + * Returns all detected blocks + */ + public function blocks(): array + { + return $this->blocks; + } + + /** + * Load all node rules from the schema + */ + public function createNodeRules(array $nodes): array + { + foreach ($nodes as $node) { + $this->nodes[$node['tag']] = $node; + } + + return $this->nodes; + } + + /** + * Checks if the given element contains + * any other block level elements + */ + public function containsBlock(DOMNode $element): bool + { + if ($element->hasChildNodes() === false) { + return false; + } + + foreach ($element->childNodes as $childNode) { + if ( + $this->isBlock($childNode) === true || + $this->containsBlock($childNode) + ) { + return true; + } + } + + return false; + } + + /** + * Takes all inline elements in the inline cache + * and combines them in a final block. The block + * will either be merged with the previous block + * if the type matches, or will be appended. + * + * The inline cache will be reset afterwards + */ + public function endInlineBlock(): void + { + if ($this->inline === []) { + return; + } + + $html = []; + + foreach ($this->inline as $inline) { + $node = new Inline($inline, $this->marks); + $html[] = $node->innerHTML(); + } + + $innerHTML = implode(' ', $html); + + if ($fallback = $this->fallback($innerHTML)) { + $this->mergeOrAppend($fallback); + } + + $this->inline = []; + } + + /** + * Creates a fallback block type for the given + * element. The element can either be a element object + * or a simple HTML/plain text string + */ + public function fallback(Element|string $element): array|null + { + if ($fallback = $this->schema->fallback($element)) { + return $fallback; + } + + return null; + } + + /** + * Checks if the given DOMNode is a block element + */ + public function isBlock(DOMNode $element): bool + { + if ($element instanceof DOMElement) { + return array_key_exists($element->tagName, $this->nodes) === true; + } + + return false; + } + + /** + * Checks if the given DOMNode is an inline element + */ + public function isInline(DOMNode $element): bool + { + if ($element instanceof DOMText) { + return true; + } + + if ($element instanceof DOMElement) { + // all spans will be treated as inline elements + if ($element->tagName === 'span') { + return true; + } + + if ($this->containsBlock($element) === true) { + return false; + } + + if ($element->tagName === 'p') { + return false; + } + + $marks = array_column($this->marks, 'tag'); + return in_array($element->tagName, $marks, true); + } + + return false; + } + + public function mergeOrAppend(array $block): void + { + $lastIndex = count($this->blocks) - 1; + $lastItem = $this->blocks[$lastIndex] ?? null; + + // merge with previous block + if ( + $block['type'] === 'text' && + $lastItem && + $lastItem['type'] === 'text' + ) { + $this->blocks[$lastIndex]['content']['text'] .= ' ' . $block['content']['text']; + + // append + } else { + $this->blocks[] = $block; + } + } + + /** + * Parses the given DOM node and tries to + * convert it to a block or a list of blocks + */ + public function parseNode(DOMNode $element): bool + { + // unwanted element types + if ( + $element instanceof DOMComment || + $element instanceof DOMDocumentType + ) { + return false; + } + + // inline context + if ($this->isInline($element) === true) { + $this->inline[] = $element; + return true; + } + + $this->endInlineBlock(); + + + // known block nodes + if ($this->isBlock($element) === true) { + /** + * @var DOMElement $element + */ + if ($parser = $this->nodes[$element->tagName]['parse'] ?? null) { + if ($result = $parser(new Element($element, $this->marks))) { + $this->blocks[] = $result; + } + } + return true; + } + + // has only unknown children (div, etc.) + if ($this->containsBlock($element) === false) { + /** + * @var DOMElement $element + */ + if (in_array($element->tagName, $this->skip, true) === true) { + return false; + } + + $wrappers = [ + 'body', + 'head', + 'html', + ]; + + // wrapper elements should never be converted + // to a simple fallback block. Their children + // have to be parsed individually. + if (in_array($element->tagName, $wrappers, true) === false) { + $node = new Element($element, $this->marks); + + if ($block = $this->fallback($node)) { + $this->mergeOrAppend($block); + } + + return true; + } + } + + // parse all children + foreach ($element->childNodes as $childNode) { + $this->parseNode($childNode); + } + + return true; + } + + public function useXmlExtension(): bool + { + if (static::$useXmlExtension !== true) { + return false; + } + + return Dom::isSupported(); + } +} diff --git a/public/kirby/src/Parsley/Schema.php b/public/kirby/src/Parsley/Schema.php new file mode 100644 index 0000000..404d9ca --- /dev/null +++ b/public/kirby/src/Parsley/Schema.php @@ -0,0 +1,53 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 3.5.0 + */ +class Schema +{ + /** + * Returns the fallback block when no + * other block type can be detected + */ + public function fallback(Element|string $element): array|null + { + return null; + } + + /** + * Returns a list of allowed inline marks + * and their parsing rules + */ + public function marks(): array + { + return []; + } + + /** + * Returns a list of allowed nodes and + * their parsing rules + */ + public function nodes(): array + { + return []; + } + + /** + * Returns a list of all elements that should be + * skipped and not be parsed at all + */ + public function skip(): array + { + return []; + } +} diff --git a/public/kirby/src/Parsley/Schema/Blocks.php b/public/kirby/src/Parsley/Schema/Blocks.php new file mode 100644 index 0000000..7ad6fc7 --- /dev/null +++ b/public/kirby/src/Parsley/Schema/Blocks.php @@ -0,0 +1,370 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 3.5.0 + */ +class Blocks extends Plain +{ + public function blockquote(Element $node): array + { + $text = []; + + // get all the text for the quote + foreach ($node->children() as $child) { + if ($child instanceof DOMText) { + $text[] = trim($child->textContent); + } + + if ( + $child instanceof DOMElement && + $child->tagName !== 'footer' + ) { + $element = new Element($child); + $text[] = $element->innerHTML($this->marks()); + } + } + + // filter empty blocks and separate text blocks with breaks + $text = implode('', array_filter($text)); + + // get the citation from the footer + $citation = $node->find('footer')?->innerHTML($this->marks()); + + return [ + 'content' => [ + 'citation' => $citation, + 'text' => $text + ], + 'type' => 'quote', + ]; + } + + /** + * Creates the fallback block type + * if no other block can be found + */ + public function fallback(Element|string $element): array|null + { + if ($element instanceof Element) { + $html = $element->innerHtml(); + + // wrap the inner HTML in a p tag if it doesn't + // contain one yet. + if (Str::contains($html, '

    ') === false) { + $html = '

    ' . $html . '

    '; + } + } elseif (is_string($element) === true) { + $html = trim($element); + + if (Str::length($html) === 0) { + return null; + } + + $html = '

    ' . $html . '

    '; + } else { + return null; + } + + return [ + 'content' => [ + 'text' => $html, + ], + 'type' => 'text', + ]; + } + + /** + * Converts a heading element to a heading block + */ + public function heading(Element $node): array + { + $content = [ + 'level' => strtolower($node->tagName()), + 'text' => $node->innerHTML() + ]; + + if ($id = $node->attr('id')) { + $content['id'] = $id; + } + + ksort($content); + + return [ + 'content' => $content, + 'type' => 'heading', + ]; + } + + public function iframe(Element $node): array + { + $src = $node->attr('src'); + $figcaption = $node->find('ancestor::figure[1]//figcaption'); + $caption = $figcaption?->innerHTML($this->marks()); + + // avoid parsing the caption twice + $figcaption?->remove(); + + // reverse engineer video URLs + if (preg_match('!player.vimeo.com\/video\/([0-9]+)!i', $src, $array) === 1) { + $src = 'https://vimeo.com/' . $array[1]; + } elseif (preg_match('!youtube.com\/embed\/([a-zA-Z0-9_-]+)!', $src, $array) === 1) { + $src = 'https://youtube.com/watch?v=' . $array[1]; + } elseif (preg_match('!youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)!', $src, $array) === 1) { + $src = 'https://youtube.com/watch?v=' . $array[1]; + } else { + $src = false; + } + + // correct video URL + if ($src) { + return [ + 'content' => [ + 'caption' => $caption, + 'url' => $src + ], + 'type' => 'video', + ]; + } + + return [ + 'content' => [ + 'text' => $node->outerHTML() + ], + 'type' => 'markdown', + ]; + } + + public function img(Element $node): array + { + $link = $node->find('ancestor::a')?->attr('href'); + $figcaption = $node->find('ancestor::figure[1]//figcaption'); + $caption = $figcaption?->innerHTML($this->marks()); + + // avoid parsing the caption twice + $figcaption?->remove(); + + return [ + 'content' => [ + 'alt' => $node->attr('alt'), + 'caption' => $caption, + 'link' => $link, + 'location' => 'web', + 'src' => $node->attr('src'), + ], + 'type' => 'image', + ]; + } + + /** + * Converts a list element to HTML + */ + public function list(Element $node): string + { + $html = []; + + foreach ($node->filter('li') as $li) { + $innerHtml = ''; + + foreach ($li->children() as $child) { + if ($child instanceof DOMText) { + $innerHtml .= $child->textContent; + } elseif ($child instanceof DOMElement) { + $child = new Element($child); + $list = ['ul', 'ol']; + $innerHtml .= match (in_array($child->tagName(), $list, true)) { + true => $this->list($child), + default => $child->innerHTML($this->marks()) + }; + } + } + + $html[] = '
  • ' . trim($innerHtml) . '
  • '; + } + + $outerHtml = '<' . $node->tagName() . '>'; + $outerHtml .= implode($html); + $outerHtml .= 'tagName() . '>'; + return $outerHtml; + } + + /** + * Returns a list of allowed inline marks + * and their parsing rules + */ + public function marks(): array + { + return [ + [ + 'tag' => 'a', + 'attrs' => ['href', 'rel', 'target', 'title'], + 'defaults' => [ + 'rel' => 'noreferrer' + ] + ], + [ + 'tag' => 'abbr', + ], + [ + 'tag' => 'b' + ], + [ + 'tag' => 'br', + ], + [ + 'tag' => 'code' + ], + [ + 'tag' => 'del', + ], + [ + 'tag' => 'em', + ], + [ + 'tag' => 'i', + ], + [ + 'tag' => 'p', + ], + [ + 'tag' => 'strike', + ], + [ + 'tag' => 'sub', + ], + [ + 'tag' => 'sup', + ], + [ + 'tag' => 'strong', + ], + [ + 'tag' => 'u', + ], + ]; + } + + /** + * Returns a list of allowed nodes and + * their parsing rules + * + * @codeCoverageIgnore + */ + public function nodes(): array + { + return [ + [ + 'tag' => 'blockquote', + 'parse' => fn (Element $node) => $this->blockquote($node) + ], + [ + 'tag' => 'h1', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h2', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h3', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h4', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h5', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h6', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'hr', + 'parse' => fn (Element $node) => ['type' => 'line'] + ], + [ + 'tag' => 'iframe', + 'parse' => fn (Element $node) => $this->iframe($node) + ], + [ + 'tag' => 'img', + 'parse' => fn (Element $node) => $this->img($node) + ], + [ + 'tag' => 'ol', + 'parse' => fn (Element $node) => [ + 'content' => [ + 'text' => $this->list($node) + ], + 'type' => 'list', + ] + ], + [ + 'tag' => 'pre', + 'parse' => fn (Element $node) => $this->pre($node) + ], + [ + 'tag' => 'table', + 'parse' => fn (Element $node) => $this->table($node) + ], + [ + 'tag' => 'ul', + 'parse' => fn (Element $node) => [ + 'content' => [ + 'text' => $this->list($node) + ], + 'type' => 'list', + ] + ], + ]; + } + + public function pre(Element $node): array + { + $language = 'text'; + + if ($code = $node->find('//code')) { + foreach ($code->classList() as $className) { + if (preg_match('!language-(.*?)!', $className)) { + $language = str_replace('language-', '', $className); + break; + } + } + } + + return [ + 'content' => [ + 'code' => $node->innerText(), + 'language' => $language + ], + 'type' => 'code', + ]; + } + + public function table(Element $node): array + { + return [ + 'content' => [ + 'text' => $node->outerHTML(), + ], + 'type' => 'markdown', + ]; + } +} diff --git a/public/kirby/src/Parsley/Schema/Plain.php b/public/kirby/src/Parsley/Schema/Plain.php new file mode 100644 index 0000000..32f0d71 --- /dev/null +++ b/public/kirby/src/Parsley/Schema/Plain.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 3.5.0 + */ +class Plain extends Schema +{ + /** + * Creates the fallback block type + * if no other block can be found + */ + public function fallback(Element|string $element): array|null + { + if ($element instanceof Element) { + $text = $element->innerText(); + } elseif (is_string($element) === true) { + $text = trim($element); + + if (Str::length($text) === 0) { + return null; + } + } else { + return null; + } + + return [ + 'content' => [ + 'text' => $text + ], + 'type' => 'text', + ]; + } + + /** + * Returns a list of all elements that + * should be skipped during parsing + */ + public function skip(): array + { + return [ + 'base', + 'link', + 'meta', + 'script', + 'style', + 'title' + ]; + } +} diff --git a/public/kirby/src/Plugin/Asset.php b/public/kirby/src/Plugin/Asset.php new file mode 100644 index 0000000..e49f149 --- /dev/null +++ b/public/kirby/src/Plugin/Asset.php @@ -0,0 +1,124 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Asset implements Stringable +{ + public function __construct( + protected string $path, + protected string $root, + protected Plugin $plugin + ) { + } + + public function extension(): string + { + return F::extension($this->path()); + } + + public function filename(): string + { + return F::filename($this->path()); + } + + /** + * Create a unique media hash + */ + public function mediaHash(): string + { + return crc32($this->filename()) . '-' . $this->modified(); + } + + /** + * Absolute path to the asset file in the media folder + */ + public function mediaRoot(): string + { + return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path(); + } + + /** + * Public accessible url path for the asset + */ + public function mediaUrl(): string + { + return $this->plugin()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->path(); + } + + /** + * Timestamp when asset file was last modified + */ + public function modified(): int|false + { + return F::modified($this->root()); + } + + public function path(): string + { + return $this->path; + } + + public function plugin(): Plugin + { + return $this->plugin; + } + + /** + * Publishes the asset file to the plugin's media folder + * by creating a symlink + */ + public function publish(): void + { + F::link($this->root(), $this->mediaRoot(), 'symlink'); + } + + /** + * @internal + * @since 4.0.0 + * @deprecated 4.0.0 + * @codeCoverageIgnore + */ + public function publishAt(string $path): void + { + F::link( + $this->root(), + $this->plugin()->mediaRoot() . '/' . $path, + 'symlink' + ); + } + + public function root(): string + { + return $this->root; + } + + /** + * @see self::mediaUrl() + */ + public function url(): string + { + return $this->mediaUrl(); + } + + /** + * @see self::url() + */ + public function __toString(): string + { + return $this->url(); + } +} diff --git a/public/kirby/src/Plugin/Assets.php b/public/kirby/src/Plugin/Assets.php new file mode 100644 index 0000000..7eaa122 --- /dev/null +++ b/public/kirby/src/Plugin/Assets.php @@ -0,0 +1,188 @@ + + * @author Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @extends \Kirby\Cms\Collection<\Kirby\Plugin\Asset> + */ +class Assets extends Collection +{ + /** + * Clean old/deprecated assets on every resolve + */ + public static function clean(string $pluginName): void + { + if ($plugin = App::instance()->plugin($pluginName)) { + $media = $plugin->mediaRoot(); + $assets = $plugin->assets(); + + // get all media files + $files = Dir::index($media, true); + + // get all active assets' paths from the plugin + $active = $assets->values( + function ($asset) { + $path = $asset->mediaHash() . '/' . $asset->path(); + $paths = []; + $parts = explode('/', $path); + + // collect all path segments + // (e.g. foo/, foo/bar/, foo/bar/baz.css) for the asset + for ($i = 1, $max = count($parts); $i <= $max; $i++) { + $paths[] = implode('/', array_slice($parts, 0, $i)); + + // TODO: remove when media hash is enforced as mandatory + $paths[] = implode('/', array_slice($parts, 1, $i)); + } + + return $paths; + } + ); + + // flatten the array and remove duplicates + $active = array_unique(array_merge(...array_values($active))); + + // get outdated media files by comparing all + // files in the media folder against the set of asset paths + $stale = array_diff($files, $active); + + foreach ($stale as $file) { + $root = $media . '/' . $file; + + if (is_file($root) === true) { + F::remove($root); + } else { + Dir::remove($root); + } + } + } + } + + /** + * Filters assets collection by CSS files + */ + public function css(): static + { + return $this->filter(fn ($asset) => $asset->extension() === 'css'); + } + + /** + * Creates a new collection for the plugin's assets + * by considering the plugin's `asset` extension + * (and `assets` directory as fallback) + */ + public static function factory(Plugin $plugin): static + { + // get assets defined in the plugin extension + if ($assets = $plugin->extends()['assets'] ?? null) { + if ($assets instanceof Closure) { + $assets = $assets(); + } + + // normalize array: use relative path as + // key when no key is defined + foreach ($assets as $key => $root) { + if (is_int($key) === true) { + unset($assets[$key]); + $path = Str::after($root, $plugin->root() . '/'); + $assets[$path] = $root; + } + } + } + + // fallback: if no assets are defined in the plugin extension, + // use all files in the plugin's `assets` directory + if ($assets === null) { + $assets = []; + $root = $plugin->root() . '/assets'; + + foreach (Dir::index($root, true) as $path) { + if (is_file($root . '/' . $path) === true) { + $assets[$path] = $root . '/' . $path; + } + } + } + + $collection = new static([], $plugin); + + foreach ($assets as $path => $root) { + $collection->data[$path] = new Asset($path, $root, $plugin); + } + + return $collection; + } + + /** + * Filters assets collection by JavaScript files + */ + public function js(): static + { + return $this->filter(fn ($asset) => $asset->extension() === 'js'); + } + + public function plugin(): Plugin + { + return $this->parent; + } + + /** + * Create a symlink for a plugin asset and + * return the public URL + */ + public static function resolve( + string $pluginName, + string $hash, + string $path + ): Response|null { + if ($plugin = App::instance()->plugin($pluginName)) { + // do some spring cleaning for older files + static::clean($pluginName); + + // @codeCoverageIgnoreStart + // TODO: deprecated media URL without hash + if (empty($hash) === true) { + $asset = $plugin->asset($path); + $asset->publishAt($path); + return Response::file($asset->root()); + } + + // TODO: deprecated media URL with hash (but path) + if ($asset = $plugin->asset($hash . '/' . $path)) { + $asset->publishAt($hash . '/' . $path); + return Response::file($asset->root()); + } + // @codeCoverageIgnoreEnd + + if ($asset = $plugin->asset($path)) { + if ($asset->mediaHash() === $hash) { + // create a symlink if possible + $asset->publish(); + + // return the file response + return Response::file($asset->root()); + } + } + } + + return null; + } +} 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/Plugin/Plugin.php b/public/kirby/src/Plugin/Plugin.php new file mode 100644 index 0000000..ab3ee6e --- /dev/null +++ b/public/kirby/src/Plugin/Plugin.php @@ -0,0 +1,354 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Plugin +{ + protected Assets $assets; + protected License|Closure|array|string $license; + protected UpdateStatus|null $updateStatus = null; + + /** + * @param string $name Plugin name within Kirby (`vendor/plugin`) + * @param array $extends Associative array of plugin extensions + * + * @throws \Kirby\Exception\InvalidArgumentException If the plugin name has an invalid format + */ + public function __construct( + 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, + ) { + static::validateName($name); + + // TODO: Remove in v7 + if ($root = $extends['root'] ?? null) { + Helpers::deprecated('Plugin "' . $name . '": Passing the `root` inside the `extends` array has been deprecated. Pass it directly as named argument `root`.', 'plugin-extends-root'); + $this->root ??= $root; + unset($this->extends['root']); + } + + $this->root ??= dirname(debug_backtrace()[0]['file']); + + // TODO: Remove in v7 + if ($info = $extends['info'] ?? null) { + Helpers::deprecated('Plugin "' . $name . '": Passing an `info` array inside the `extends` array has been deprecated. Pass the individual entries directly as named `info` argument.', 'plugin-extends-root'); + + if (empty($info) === false && is_array($info) === true) { + $this->info = [...$info, ...$this->info]; + } + + unset($this->extends['info']); + } + + // read composer.json and use as info fallback + $info = Data::read($this->manifest(), fail: false); + $this->info = [...$info, ...$this->info]; + $this->license = $license ?? $this->info['license'] ?? '-'; + } + + /** + * Allows access to any composer.json field by method call + */ + public function __call(string $key, array|null $arguments = null): mixed + { + return $this->info()[$key] ?? null; + } + + /** + * Returns the plugin asset object for a specific asset + */ + public function asset(string $path): Asset|null + { + return $this->assets()->get($path); + } + + /** + * Returns the plugin assets collection + */ + public function assets(): Assets + { + return $this->assets ??= Assets::factory($this); + } + + /** + * Returns the array with author information + * from the composer.json file + */ + public function authors(): array + { + return $this->info()['authors'] ?? []; + } + + /** + * Returns a comma-separated list with all author names + */ + public function authorsNames(): string + { + $names = []; + + foreach ($this->authors() as $author) { + $names[] = $author['name'] ?? null; + } + + return implode(', ', array_filter($names)); + } + + /** + * Returns the associative array of extensions the plugin bundles + */ + public function extends(): array + { + return $this->extends; + } + + /** + * Returns the unique ID for the plugin + * (alias for the plugin name) + */ + public function id(): string + { + return $this->name(); + } + + /** + * Returns the info data (from composer.json) + */ + public function info(): array + { + return $this->info; + } + + /** + * Current $kirby instance + */ + public function kirby(): App + { + return App::instance(); + } + + /** + * Returns the link to the plugin homepage + */ + public function link(): string|null + { + $info = $this->info(); + $homepage = $info['homepage'] ?? null; + $docs = $info['support']['docs'] ?? null; + $source = $info['support']['source'] ?? null; + + $link = $homepage ?? $docs ?? $source; + + 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 + */ + public function manifest(): string + { + return $this->root() . '/composer.json'; + } + + /** + * Returns the root where plugin assets are copied to + */ + public function mediaRoot(): string + { + return $this->kirby()->root('media') . '/plugins/' . $this->name(); + } + + /** + * Returns the base URL for plugin assets + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/plugins/' . $this->name(); + } + + /** + * Returns the plugin name (`vendor/plugin`) + */ + public function name(): string + { + return $this->name; + } + + /** + * Returns a Kirby option value for this plugin + */ + public function option(string $key) + { + return $this->kirby()->option($this->prefix() . '.' . $key); + } + + /** + * Returns the option prefix (`vendor.plugin`) + */ + public function prefix(): string + { + return str_replace('/', '.', $this->name()); + } + + /** + * Returns the root where the plugin files are stored + */ + public function root(): string + { + return $this->root; + } + + /** + * Returns all available plugin metadata + */ + public function toArray(): array + { + return [ + 'authors' => $this->authors(), + 'description' => $this->description(), + 'name' => $this->name(), + 'license' => $this->license()->toArray(), + 'link' => $this->link(), + 'root' => $this->root(), + 'version' => $this->version() + ]; + } + + /** + * Returns the update status object unless the + * update check has been disabled for the plugin + * @since 3.8.0 + * + * @param array|null $data Custom override for the getkirby.com update data + */ + public function updateStatus(array|null $data = null): UpdateStatus|null + { + if ($this->updateStatus !== null) { + return $this->updateStatus; + } + + $kirby = $this->kirby(); + $option = $kirby->option('updates.plugins'); + + // specific configuration per plugin + if (is_array($option) === true) { + // filter all option values by glob match + $option = A::filter( + $option, + fn ($value, $key) => fnmatch($key, $this->name()) === true + ); + + // sort the matches by key length (with longest key first) + $keys = array_map('strlen', array_keys($option)); + array_multisort($keys, SORT_DESC, $option); + + if ($option !== []) { + // use the first and therefore longest key (= most specific match) + $option = reset($option); + } else { + // fallback to the default option value + $option = true; + } + } + + $option ??= $kirby->option('updates') ?? true; + + if ($option !== true) { + return null; + } + + return $this->updateStatus = new UpdateStatus($this, false, $data); + } + + /** + * Checks if the name follows the required pattern + * and throws an exception if not + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function validateName(string $name): void + { + if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) { + throw new InvalidArgumentException( + message: 'The plugin name must follow the format "a-z0-9-/a-z0-9-"' + ); + } + } + + /** + * Returns the normalized version number + * from the composer.json file + */ + public function version(): string|null + { + $name = $this->info()['name'] ?? null; + + try { + // try to get version from "vendor/composer/installed.php", + // this is the most reliable source for the version + $version = InstalledVersions::getPrettyVersion($name); + } catch (Throwable) { + $version = null; + } + + // fallback to the version provided in the plugin's index.php: as named + // argument, entry in the info array or from the composer.json file + $version ??= $this->version ?? $this->info()['version'] ?? null; + + if ( + is_string($version) !== true || + $version === '' || + Str::endsWith($version, '+no-version-set') + ) { + return null; + } + + // normalize the version number to be without leading `v` + $version = ltrim($version, 'vV'); + + // ensure that the version number now starts with a digit + if (preg_match('/^[0-9]/', $version) !== 1) { + return null; + } + + return $version; + } +} diff --git a/public/kirby/src/Query/AST/ArgumentListNode.php b/public/kirby/src/Query/AST/ArgumentListNode.php new file mode 100644 index 0000000..b0ed95d --- /dev/null +++ b/public/kirby/src/Query/AST/ArgumentListNode.php @@ -0,0 +1,37 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class ArgumentListNode extends Node +{ + public function __construct( + public array $arguments = [] + ) { + } + + public function resolve(Visitor $visitor): array|string + { + // Resolve each argument + $arguments = array_map( + fn ($argument) => $argument->resolve($visitor), + $this->arguments + ); + + // Keep as array or convert to string + // depending on the visitor type + return $visitor->arguments($arguments); + } +} diff --git a/public/kirby/src/Query/AST/ArithmeticNode.php b/public/kirby/src/Query/AST/ArithmeticNode.php new file mode 100644 index 0000000..18ca20d --- /dev/null +++ b/public/kirby/src/Query/AST/ArithmeticNode.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class ArithmeticNode extends Node +{ + public function __construct( + public Node $left, + public string $operator, + public Node $right + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->arithmetic( + left: $this->left->resolve($visitor), + operator: $this->operator, + right: $this->right->resolve($visitor) + ); + } +} diff --git a/public/kirby/src/Query/AST/ArrayListNode.php b/public/kirby/src/Query/AST/ArrayListNode.php new file mode 100644 index 0000000..fac7655 --- /dev/null +++ b/public/kirby/src/Query/AST/ArrayListNode.php @@ -0,0 +1,37 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class ArrayListNode extends Node +{ + public function __construct( + public array $elements, + ) { + } + + public function resolve(Visitor $visitor): array|string + { + // Resolve each array element + $elements = array_map( + fn ($element) => $element->resolve($visitor), + $this->elements + ); + + // Keep as array or convert to string + // depending on the visitor type + return $visitor->arrayList($elements); + } +} diff --git a/public/kirby/src/Query/AST/ClosureNode.php b/public/kirby/src/Query/AST/ClosureNode.php new file mode 100644 index 0000000..515bdb7 --- /dev/null +++ b/public/kirby/src/Query/AST/ClosureNode.php @@ -0,0 +1,33 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class ClosureNode extends Node +{ + /** + * @param string[] $arguments The arguments names + */ + public function __construct( + public array $arguments, + public Node $body, + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->closure($this); + } +} diff --git a/public/kirby/src/Query/AST/CoalesceNode.php b/public/kirby/src/Query/AST/CoalesceNode.php new file mode 100644 index 0000000..af4af6f --- /dev/null +++ b/public/kirby/src/Query/AST/CoalesceNode.php @@ -0,0 +1,33 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class CoalesceNode extends Node +{ + public function __construct( + public Node $left, + public Node $right, + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->coalescence( + left: $this->left->resolve($visitor), + right: $this->right->resolve($visitor) + ); + } +} diff --git a/public/kirby/src/Query/AST/ComparisonNode.php b/public/kirby/src/Query/AST/ComparisonNode.php new file mode 100644 index 0000000..9dd6d3e --- /dev/null +++ b/public/kirby/src/Query/AST/ComparisonNode.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class ComparisonNode extends Node +{ + public function __construct( + public Node $left, + public string $operator, + public Node $right + ) { + } + + public function resolve(Visitor $visitor): bool|string + { + return $visitor->comparison( + left: $this->left->resolve($visitor), + operator: $this->operator, + right: $this->right->resolve($visitor) + ); + } +} diff --git a/public/kirby/src/Query/AST/GlobalFunctionNode.php b/public/kirby/src/Query/AST/GlobalFunctionNode.php new file mode 100644 index 0000000..7bda18e --- /dev/null +++ b/public/kirby/src/Query/AST/GlobalFunctionNode.php @@ -0,0 +1,33 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class GlobalFunctionNode extends Node +{ + public function __construct( + public string $name, + public ArgumentListNode $arguments, + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->function( + name: $this->name, + arguments: $this->arguments->resolve($visitor) + ); + } +} diff --git a/public/kirby/src/Query/AST/LiteralNode.php b/public/kirby/src/Query/AST/LiteralNode.php new file mode 100644 index 0000000..7e861d7 --- /dev/null +++ b/public/kirby/src/Query/AST/LiteralNode.php @@ -0,0 +1,29 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class LiteralNode extends Node +{ + public function __construct( + public mixed $value, + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->literal($this->value); + } +} diff --git a/public/kirby/src/Query/AST/LogicalNode.php b/public/kirby/src/Query/AST/LogicalNode.php new file mode 100644 index 0000000..a7158b5 --- /dev/null +++ b/public/kirby/src/Query/AST/LogicalNode.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class LogicalNode extends Node +{ + public function __construct( + public Node $left, + public string $operator, + public Node $right + ) { + } + + public function resolve(Visitor $visitor): bool|string + { + return $visitor->logical( + left: $this->left->resolve($visitor), + operator: $this->operator, + right: $this->right->resolve($visitor) + ); + } +} diff --git a/public/kirby/src/Query/AST/MemberAccessNode.php b/public/kirby/src/Query/AST/MemberAccessNode.php new file mode 100644 index 0000000..75f24da --- /dev/null +++ b/public/kirby/src/Query/AST/MemberAccessNode.php @@ -0,0 +1,37 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class MemberAccessNode extends Node +{ + public function __construct( + public Node $object, + public Node $member, + public ArgumentListNode|null $arguments = null, + public bool $nullSafe = false, + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->memberAccess( + object: $this->object->resolve($visitor), + member: $this->member->resolve($visitor), + arguments: $this->arguments?->resolve($visitor), + nullSafe: $this->nullSafe + ); + } +} diff --git a/public/kirby/src/Query/AST/Node.php b/public/kirby/src/Query/AST/Node.php new file mode 100644 index 0000000..ff5f150 --- /dev/null +++ b/public/kirby/src/Query/AST/Node.php @@ -0,0 +1,23 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + * + * @codeCoverageIgnore + */ +abstract class Node +{ + abstract public function resolve(Visitor $visitor); +} diff --git a/public/kirby/src/Query/AST/TernaryNode.php b/public/kirby/src/Query/AST/TernaryNode.php new file mode 100644 index 0000000..a0b8aaa --- /dev/null +++ b/public/kirby/src/Query/AST/TernaryNode.php @@ -0,0 +1,36 @@ + + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class TernaryNode extends Node +{ + public function __construct( + public Node $condition, + public Node $false, + public Node|null $true = null + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->ternary( + condition: $this->condition->resolve($visitor), + true: $this->true?->resolve($visitor), + false: $this->false->resolve($visitor) + ); + } +} diff --git a/public/kirby/src/Query/AST/VariableNode.php b/public/kirby/src/Query/AST/VariableNode.php new file mode 100644 index 0000000..2d5b8ff --- /dev/null +++ b/public/kirby/src/Query/AST/VariableNode.php @@ -0,0 +1,29 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class VariableNode extends Node +{ + public function __construct( + public string $name, + ) { + } + + public function resolve(Visitor $visitor): mixed + { + return $visitor->variable($this->name); + } +} diff --git a/public/kirby/src/Query/Argument.php b/public/kirby/src/Query/Argument.php new file mode 100644 index 0000000..2fccef5 --- /dev/null +++ b/public/kirby/src/Query/Argument.php @@ -0,0 +1,119 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @todo Deprecate in v6 + */ +class Argument +{ + public function __construct( + public mixed $value + ) { + } + + /** + * Sanitizes argument string into actual + * PHP type/object as new Argument instance + */ + public static function factory(string $argument): static + { + $argument = trim($argument); + + // remove grouping parantheses + if ( + Str::startsWith($argument, '(') && + Str::endsWith($argument, ')') + ) { + $argument = trim(substr($argument, 1, -1)); + } + + // string with single quotes + if ( + Str::startsWith($argument, "'") && + Str::endsWith($argument, "'") + ) { + $string = substr($argument, 1, -1); + $string = str_replace("\'", "'", $string); + return new static($string); + } + + // string with double quotes + if ( + Str::startsWith($argument, '"') && + Str::endsWith($argument, '"') + ) { + $string = substr($argument, 1, -1); + $string = str_replace('\"', '"', $string); + return new static($string); + } + + // array: split and recursive sanitizing + if ( + Str::startsWith($argument, '[') && + Str::endsWith($argument, ']') + ) { + $array = substr($argument, 1, -1); + $array = Arguments::factory($array); + return new static($array); + } + + // numeric + if (is_numeric($argument) === true) { + if (str_contains($argument, '.') === false) { + return new static((int)$argument); + } + + return new static((float)$argument); + } + + // Closure + if (Str::startsWith($argument, '() =>')) { + $query = Str::after($argument, '() =>'); + $query = trim($query); + return new static(fn () => $query); + } + + return new static(match ($argument) { + 'null' => null, + 'true' => true, + 'false' => false, + + // resolve parameter for objects and methods itself + default => new Query($argument) + }); + } + + /** + * Return the argument value and + * resolves nested objects to scaler types + */ + public function resolve(array|object $data = []): mixed + { + // don't resolve the Closure immediately, instead + // resolve it to the sub-query and create a new Closure + // that resolves the sub-query with the same data set once called + if ($this->value instanceof Closure) { + $query = ($this->value)(); + return fn () => static::factory($query)->resolve($data); + } + + if (is_object($this->value) === true) { + return $this->value->resolve($data); + } + + return $this->value; + } +} diff --git a/public/kirby/src/Query/Arguments.php b/public/kirby/src/Query/Arguments.php new file mode 100644 index 0000000..1659a05 --- /dev/null +++ b/public/kirby/src/Query/Arguments.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @todo Deprecate in v6 + * + * @extends \Kirby\Toolkit\Collection<\Kirby\Query\Argument> + */ +class Arguments extends Collection +{ + // skip all matches inside of parantheses + public const NO_PNTH = '\([^)]+\)(*SKIP)(*FAIL)'; + // skip all matches inside of square brackets + public const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)'; + // skip all matches inside of double quotes + public const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)'; + // skip all matches inside of single quotes + public const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)'; + // skip all matches inside of any of the above skip groups + public const OUTSIDE = + self::NO_PNTH . '|' . self::NO_SQBR . '|' . + self::NO_DLQU . '|' . self::NO_SLQU; + + /** + * Splits list of arguments into individual + * Argument instances while respecting skip groups + */ + public static function factory(string $arguments): static + { + $arguments = A::map( + // split by comma, but not inside skip groups + preg_split('!,|' . self::OUTSIDE . '!', $arguments), + fn ($argument) => Argument::factory($argument) + ); + + return new static($arguments); + } + + /** + * Resolve each argument, so that they can + * passed together to the actual method call + */ + public function resolve(array|object $data = []): array + { + return A::map( + $this->data, + fn ($argument) => $argument->resolve($data) + ); + } +} diff --git a/public/kirby/src/Query/Expression.php b/public/kirby/src/Query/Expression.php new file mode 100644 index 0000000..028ec24 --- /dev/null +++ b/public/kirby/src/Query/Expression.php @@ -0,0 +1,123 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @todo Deprecate in v6 + */ +class Expression +{ + public function __construct( + public array $parts + ) { + } + + /** + * Parses an expression string into its parts + */ + public static function factory(string $expression, Query|null $parent = null): static|Segments + { + // split into different expression parts and operators + $parts = static::parse($expression); + + // shortcut: if expression has only one part, directly + // continue with the segments chain + if (count($parts) === 1) { + return Segments::factory(query: $parts[0], parent: $parent); + } + + // turn all non-operator parts into an Argument + // which takes care of converting string, arrays booleans etc. + // into actual types and treats all other parts as their own queries + $parts = A::map( + $parts, + fn ($part) => match ($part) { + '?', ':', '?:', '??' => $part, + default => Argument::factory($part) + } + ); + + return new static(parts: $parts); + } + + /** + * Splits a comparison string into an array + * of expressions and operators + * @unstable + */ + public static function parse(string $string): array + { + // split by multiples of `?` and `:`, but not inside skip groups + // (parantheses, quotes etc.) + return preg_split( + '/\s+([\?\:]+)\s+|' . Arguments::OUTSIDE . '/', + trim($string), + flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + } + + /** + * Resolves the expression by evaluating + * the supported comparisons and consecutively + * resolving the resulting query/argument + */ + public function resolve(array|object $data = []): mixed + { + $base = null; + + foreach ($this->parts as $index => $part) { + // `a ?? b` + // if the base/previous (e.g. `a`) isn't null, + // stop the expression chain and return `a` + if ($part === '??') { + if ($base !== null) { + return $base; + } + + continue; + } + + // `a ?: b` + // if `a` isn't false, return `a`, otherwise `b` + if ($part === '?:') { + if ($base != false) { + return $base; + } + + return $this->parts[$index + 1]->resolve($data); + } + + // `a ? b : c` + // if `a` isn't false, return `b`, otherwise `c` + if ($part === '?') { + if (($this->parts[$index + 2] ?? null) !== ':') { + throw new LogicException( + message: 'Query: Incomplete ternary operator (missing matching `? :`)' + ); + } + + if ($base != false) { + return $this->parts[$index + 1]->resolve($data); + } + + return $this->parts[$index + 3]->resolve($data); + } + + $base = $part->resolve($data); + } + + return $base; + } +} diff --git a/public/kirby/src/Query/Parser/Parser.php b/public/kirby/src/Query/Parser/Parser.php new file mode 100644 index 0000000..96b2651 --- /dev/null +++ b/public/kirby/src/Query/Parser/Parser.php @@ -0,0 +1,476 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class Parser +{ + protected Token $current; + protected Token|null $previous = null; + + /** + * @var Iterator + */ + protected Iterator $tokens; + + public function __construct(string|Iterator $query) + { + if (is_string($query) === true) { + $tokenizer = new Tokenizer($query); + $query = $tokenizer->tokens(); + } + + $this->tokens = $query; + $this->current = $this->tokens->current(); + } + + /** + * Move to the next token + */ + protected function advance(): Token|null + { + if ($this->isAtEnd() === false) { + $this->previous = $this->current; + $this->tokens->next(); + $this->current = $this->tokens->current(); + } + + return $this->previous; + } + + /** + * Parses an array + */ + private function array(): ArrayListNode|null + { + if ($this->consume(TokenType::T_OPEN_BRACKET)) { + return new ArrayListNode( + elements: $this->consumeList(TokenType::T_CLOSE_BRACKET) + ); + } + + return null; + } + + /** + * Parses a list of arguments + */ + private function argumentList(): ArgumentListNode + { + return new ArgumentListNode( + arguments: $this->consumeList(TokenType::T_CLOSE_PAREN) + ); + } + + /** + * Checks for and parses several atomic expressions + */ + private function atomic(): Node + { + $token = $this->scalar(); + $token ??= $this->array(); + $token ??= $this->identifier(); + $token ??= $this->grouping(); + + if ($token === null) { + throw new Exception('Expect expression'); // @codeCoverageIgnore + } + + return $token; + } + + /** + * Checks for and parses a coalesce expression + */ + private function coalesce(): Node + { + $node = $this->logical(); + + while ($this->consume(TokenType::T_COALESCE)) { + $node = new CoalesceNode( + left: $node, + right: $this->logical() + ); + } + + return $node; + } + + /** + * Collect the next token of a type + * + * @throws \Exception when next token is not of specified type + */ + protected function consume( + TokenType $type, + string|false $error = false + ): Token|false { + if ($this->is($type) === true) { + return $this->advance(); + } + + if (is_string($error) === true) { + throw new Exception($error); + } + + return false; + } + + /** + * Move to next token if of any specific type + */ + protected function consumeAny(array $types): Token|false + { + foreach ($types as $type) { + if ($this->is($type) === true) { + return $this->advance(); + } + } + + return false; + } + + /** + * Collect all list element until closing token + */ + private function consumeList(TokenType $until): array + { + $elements = []; + + while ( + $this->isAtEnd() === false && + $this->is($until) === false + ) { + $elements[] = $this->expression(); + + if ($this->consume(TokenType::T_COMMA) === false) { + break; + } + } + + // consume the closing token + $this->consume($until, 'Expect closing bracket after list'); + + return $elements; + } + + /** + * Returns the current token + */ + public function current(): Token + { + return $this->current; + } + + /** + * Convert a full query expression into a node + */ + private function expression(): Node + { + // Top-level expression should be ternary + return $this->ternary(); + } + + /** + * Parses comparison expressions with proper precedence + */ + private function comparison(): Node + { + $left = $this->arithmetic(); + + while ($token = $this->consumeAny([ + TokenType::T_EQUAL, + TokenType::T_IDENTICAL, + TokenType::T_NOT_EQUAL, + TokenType::T_NOT_IDENTICAL, + TokenType::T_LESS_THAN, + TokenType::T_LESS_EQUAL, + TokenType::T_GREATER_THAN, + TokenType::T_GREATER_EQUAL + ])) { + $left = new ComparisonNode( + left: $left, + operator: $token->lexeme, + right: $this->arithmetic() + ); + } + + return $left; + } + + /** + * Parses a grouping (e.g. closure) + */ + private function grouping(): ClosureNode|Node|null + { + if ($this->consume(TokenType::T_OPEN_PAREN)) { + $list = $this->consumeList(TokenType::T_CLOSE_PAREN); + + if ($this->consume(TokenType::T_ARROW)) { + $expression = $this->expression(); + + /** + * Assert that all elements are VariableNodes + * @var VariableNode[] $list + */ + foreach ($list as $element) { + if ($element instanceof VariableNode === false) { + throw new Exception('Expecting only variables in closure argument list'); + } + } + + $arguments = array_map(fn ($element) => $element->name, $list); + + return new ClosureNode( + arguments: $arguments, + body: $expression + ); + } + + if (count($list) > 1) { + throw new Exception('Expecting "=>" after closure argument list'); + } + + // this is just a grouping + return $list[0]; + } + + return null; + } + + /** + * Parses an identifier (global functions or variables) + */ + private function identifier(): GlobalFunctionNode|VariableNode|null + { + if ($token = $this->consume(TokenType::T_IDENTIFIER)) { + if ($this->consume(TokenType::T_OPEN_PAREN)) { + return new GlobalFunctionNode( + name: $token->lexeme, + arguments: $this->argumentList() + ); + } + + return new VariableNode(name: $token->lexeme); + } + + return null; + } + + /** + * Whether the current token is of a specific type + */ + protected function is(TokenType $type): bool + { + if ($this->isAtEnd() === true) { + return false; + } + + return $this->current->is($type); + } + + /** + * Whether the parser has reached the end of the query + */ + protected function isAtEnd(): bool + { + return $this->current->is(TokenType::T_EOF); + } + + /** + * Checks for and parses a member access expression + */ + private function memberAccess(): Node + { + $object = $this->atomic(); + + while ($token = $this->consumeAny([ + TokenType::T_DOT, + TokenType::T_NULLSAFE, + TokenType::T_OPEN_BRACKET + ])) { + if ($token->is(TokenType::T_OPEN_BRACKET) === true) { + // For subscript notation, parse the inside as expression… + $member = $this->expression(); + + // …and ensure consuming the closing bracket + $this->consume( + TokenType::T_CLOSE_BRACKET, + 'Expect subscript closing bracket' + ); + } elseif ($member = $this->consume(TokenType::T_IDENTIFIER)) { + $member = new LiteralNode($member->lexeme); + } elseif ($member = $this->consume(TokenType::T_INTEGER)) { + $member = new LiteralNode($member->literal); + } else { + throw new Exception('Expect property name after "."'); + } + + $object = new MemberAccessNode( + object: $object, + member: $member, + arguments: match ($this->consume(TokenType::T_OPEN_PAREN)) { + false => null, + default => $this->argumentList(), + }, + nullSafe: $token->is(TokenType::T_NULLSAFE) + ); + } + + return $object; + } + + /** + * Parses arithmetic expressions with proper precedence + */ + private function arithmetic(): Node + { + $left = $this->term(); + + while ($token = $this->consumeAny([ + TokenType::T_PLUS, + TokenType::T_MINUS + ])) { + $left = new ArithmeticNode( + left: $left, + operator: $token->lexeme, + right: $this->term() + ); + } + + return $left; + } + + /** + * Parses multiplication, division, and modulo expressions + */ + private function term(): Node + { + $left = $this->memberAccess(); + + while ($token = $this->consumeAny([ + TokenType::T_MULTIPLY, + TokenType::T_DIVIDE, + TokenType::T_MODULO + ])) { + $left = new ArithmeticNode( + left: $left, + operator: $token->lexeme, + right: $this->memberAccess() + ); + } + + return $left; + } + + /** + * Parses logical expressions with proper precedence + */ + private function logical(): Node + { + $left = $this->comparison(); + + while ($token = $this->consumeAny([ + TokenType::T_AND, + TokenType::T_OR + ])) { + $left = new LogicalNode( + left: $left, + operator: $token->lexeme, + right: $this->comparison() + ); + } + + return $left; + } + + /** + * Parses the tokenized query into AST node tree + */ + public function parse(): Node + { + // Start parsing chain + $expression = $this->expression(); + + // Ensure that we consumed all tokens + if ($this->isAtEnd() === false) { + $this->consume(TokenType::T_EOF, 'Expect end of expression'); // @codeCoverageIgnore + } + + return $expression; + } + + private function scalar(): LiteralNode|null + { + if ($token = $this->consumeAny([ + TokenType::T_TRUE, + TokenType::T_FALSE, + TokenType::T_NULL, + TokenType::T_STRING, + TokenType::T_INTEGER, + TokenType::T_FLOAT, + ])) { + return new LiteralNode(value: $token->literal); + } + + return null; + } + + /** + * Checks for and parses a ternary expression + * (full `a ? b : c` or elvis shorthand `a ?: c`) + */ + private function ternary(): Node + { + $condition = $this->coalesce(); + + if ($token = $this->consumeAny([ + TokenType::T_QUESTION_MARK, + TokenType::T_TERNARY_DEFAULT + ])) { + if ($token->is(TokenType::T_TERNARY_DEFAULT) === false) { + $true = $this->expression(); + $this->consume( + type: TokenType::T_COLON, + error: 'Expect ":" after true branch' + ); + } + + return new TernaryNode( + condition: $condition, + true: $true ?? null, + false: $this->expression() + ); + } + + return $condition; + } +} diff --git a/public/kirby/src/Query/Parser/Token.php b/public/kirby/src/Query/Parser/Token.php new file mode 100644 index 0000000..0d7b280 --- /dev/null +++ b/public/kirby/src/Query/Parser/Token.php @@ -0,0 +1,30 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class Token +{ + public function __construct( + public TokenType $type, + public string $lexeme, + public mixed $literal = null, + ) { + } + + public function is(TokenType $type): bool + { + return $this->type === $type; + } +} diff --git a/public/kirby/src/Query/Parser/TokenType.php b/public/kirby/src/Query/Parser/TokenType.php new file mode 100644 index 0000000..9ad2b75 --- /dev/null +++ b/public/kirby/src/Query/Parser/TokenType.php @@ -0,0 +1,61 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +enum TokenType +{ + case T_DOT; + case T_COLON; + case T_QUESTION_MARK; + case T_OPEN_PAREN; + case T_CLOSE_PAREN; + case T_OPEN_BRACKET; + case T_CLOSE_BRACKET; + case T_TERNARY_DEFAULT; // ?: + case T_NULLSAFE; // ?. + case T_COALESCE; // ?? + case T_COMMA; + case T_ARROW; + case T_WHITESPACE; + case T_EOF; + + // Comparison operators + case T_EQUAL; // == + case T_IDENTICAL; // === + case T_NOT_EQUAL; // != + case T_NOT_IDENTICAL; // !== + case T_LESS_THAN; // < + case T_LESS_EQUAL; // <= + case T_GREATER_THAN; // > + case T_GREATER_EQUAL; // >= + + // Math operators + case T_PLUS; // + + case T_MINUS; // - + case T_MULTIPLY; // * + case T_DIVIDE; // / + case T_MODULO; // % + + // Logical operators + case T_AND; // AND or && + case T_OR; // OR or || + + // Literals + case T_STRING; + case T_INTEGER; + case T_FLOAT; + case T_TRUE; + case T_FALSE; + case T_NULL; + + case T_IDENTIFIER; +} diff --git a/public/kirby/src/Query/Parser/Tokenizer.php b/public/kirby/src/Query/Parser/Tokenizer.php new file mode 100644 index 0000000..41f1105 --- /dev/null +++ b/public/kirby/src/Query/Parser/Tokenizer.php @@ -0,0 +1,256 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class Tokenizer +{ + private int $length = 0; + + /** + * The more complex regexes are written here in nowdoc format + * so we don't need to double or triple escape backslashes + * (that becomes ridiculous rather fast). + * + * Identifiers can contain letters, numbers and underscores. + * They can't start with a number. + * For more complex identifier strings, subscript member access + * should be used. With `this` to access the global context. + */ + private const IDENTIFIER_REGEX = <<<'REGEX' + (?:[\p{L}\p{N}_])* + REGEX; + + private const SINGLEQUOTE_STRING_REGEX = <<<'REGEX' + '([^'\\]*(?:\\.[^'\\]*)*)' + REGEX; + + private const DOUBLEQUOTE_STRING_REGEX = <<<'REGEX' + "([^"\\]*(?:\\.[^"\\]*)*)" + REGEX; + + public function __construct( + private readonly string $query, + ) { + $this->length = mb_strlen($query); + } + + /** + * Matches a regex pattern at the current position in the query string. + * The matched lexeme will be stored in the $lexeme variable. + * + * @param int $offset Current position in the query string + * @param string $regex Regex pattern without delimiters/flags + */ + public static function match( + string $query, + int $offset, + string $regex, + bool $caseInsensitive = false + ): string|null { + // Add delimiters and flags to the regex + $regex = '/\G' . $regex . '/u'; + + if ($caseInsensitive === true) { + $regex .= 'i'; + } + + if (preg_match($regex, $query, $matches, 0, $offset) !== 1) { + return null; + } + + return $matches[0]; + } + + /** + * Scans the source string for a next token + * starting from the given position + * + * @param int $current The current position in the source string + * + * @throws \Exception If an unexpected character is encountered + */ + public static function token(string $query, int $current): Token + { + $char = $query[$current]; + + // Multi character tokens (check these first): + // Whitespace + if ($lex = static::match($query, $current, '\s+')) { + return new Token(TokenType::T_WHITESPACE, $lex); + } + + // true + if ($lex = static::match($query, $current, 'true', true)) { + return new Token(TokenType::T_TRUE, $lex, true); + } + + // false + if ($lex = static::match($query, $current, 'false', true)) { + return new Token(TokenType::T_FALSE, $lex, false); + } + + // null + if ($lex = static::match($query, $current, 'null', true)) { + return new Token(TokenType::T_NULL, $lex, null); + } + + // "string" + if ($lex = static::match($query, $current, static::DOUBLEQUOTE_STRING_REGEX)) { + return new Token( + TokenType::T_STRING, + $lex, + stripcslashes(substr($lex, 1, -1)) + ); + } + + // 'string' + if ($lex = static::match($query, $current, static::SINGLEQUOTE_STRING_REGEX)) { + return new Token( + TokenType::T_STRING, + $lex, + stripcslashes(substr($lex, 1, -1)) + ); + } + + // float (check before single character tokens) + $lex = static::match($query, $current, '-?\d+\.\d+\b'); + if ($lex !== null) { + return new Token(TokenType::T_FLOAT, $lex, (float)$lex); + } + + // int (check before single character tokens) + $lex = static::match($query, $current, '-?\d+\b'); + if ($lex !== null) { + return new Token(TokenType::T_INTEGER, $lex, (int)$lex); + } + + // Two character tokens: + // ?? + if ($lex = static::match($query, $current, '\?\?')) { + return new Token(TokenType::T_COALESCE, $lex); + } + + // ?. + if ($lex = static::match($query, $current, '\?\s*\.')) { + return new Token(TokenType::T_NULLSAFE, $lex); + } + + // ?: + if ($lex = static::match($query, $current, '\?\s*:')) { + return new Token(TokenType::T_TERNARY_DEFAULT, $lex); + } + + // => + if ($lex = static::match($query, $current, '=>')) { + return new Token(TokenType::T_ARROW, $lex); + } + + // Logical operators (check before comparison operators) + if ($lex = static::match($query, $current, '&&|AND')) { + return new Token(TokenType::T_AND, $lex); + } + + if ($lex = static::match($query, $current, '\|\||OR')) { + return new Token(TokenType::T_OR, $lex); + } + + // Comparison operators (three characters first, then two, then one) + // === (must come before ==) + if ($lex = static::match($query, $current, '===')) { + return new Token(TokenType::T_IDENTICAL, $lex); + } + + // !== (must come before !=) + if ($lex = static::match($query, $current, '!==')) { + return new Token(TokenType::T_NOT_IDENTICAL, $lex); + } + + // <= (must come before <) + if ($lex = static::match($query, $current, '<=')) { + return new Token(TokenType::T_LESS_EQUAL, $lex); + } + + // >= (must come before >) + if ($lex = static::match($query, $current, '>=')) { + return new Token(TokenType::T_GREATER_EQUAL, $lex); + } + + // == + if ($lex = static::match($query, $current, '==')) { + return new Token(TokenType::T_EQUAL, $lex); + } + + // != + if ($lex = static::match($query, $current, '!=')) { + return new Token(TokenType::T_NOT_EQUAL, $lex); + } + + // Single character tokens (check these last): + $token = match ($char) { + '.' => new Token(TokenType::T_DOT, '.'), + '(' => new Token(TokenType::T_OPEN_PAREN, '('), + ')' => new Token(TokenType::T_CLOSE_PAREN, ')'), + '[' => new Token(TokenType::T_OPEN_BRACKET, '['), + ']' => new Token(TokenType::T_CLOSE_BRACKET, ']'), + ',' => new Token(TokenType::T_COMMA, ','), + ':' => new Token(TokenType::T_COLON, ':'), + '+' => new Token(TokenType::T_PLUS, '+'), + '-' => new Token(TokenType::T_MINUS, '-'), + '*' => new Token(TokenType::T_MULTIPLY, '*'), + '/' => new Token(TokenType::T_DIVIDE, '/'), + '%' => new Token(TokenType::T_MODULO, '%'), + '?' => new Token(TokenType::T_QUESTION_MARK, '?'), + '<' => new Token(TokenType::T_LESS_THAN, '<'), + '>' => new Token(TokenType::T_GREATER_THAN, '>'), + default => null + }; + + if ($token !== null) { + return $token; + } + + // Identifier + if ($lex = static::match($query, $current, static::IDENTIFIER_REGEX)) { + return new Token(TokenType::T_IDENTIFIER, $lex); + } + + // Unknown token + throw new Exception('Invalid character in query: ' . $query[$current]); + } + + /** + * Tokenizes the query string and returns a generator of tokens. + * @return Generator + */ + public function tokens(): Generator + { + $current = 0; + + while ($current < $this->length) { + $token = static::token($this->query, $current); + + // Don't yield whitespace tokens (ignore them) + if ($token->type !== TokenType::T_WHITESPACE) { + yield $token; + } + + $current += mb_strlen($token->lexeme); + } + + yield new Token(TokenType::T_EOF, '', null); + } +} diff --git a/public/kirby/src/Query/Query.php b/public/kirby/src/Query/Query.php new file mode 100644 index 0000000..a0bc1d4 --- /dev/null +++ b/public/kirby/src/Query/Query.php @@ -0,0 +1,171 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query +{ + public static array $cache = []; + public static array $entries = []; + + public Runner|string $runner; + + /** + * Creates a new Query object + */ + public function __construct( + public string|null $query = null + ) { + if ($query !== null) { + $this->query = trim($query); + } + + $this->runner = App::instance()->option('query.runner', 'legacy'); + + if ($this->runner !== 'legacy') { + + if (is_subclass_of($this->runner, Runner::class) === false) { + throw new InvalidArgumentException("Query runner $this->runner must extend " . Runner::class); + } + + $this->runner = $this->runner::for($this); + } + } + + /** + * Creates a new Query object + */ + public static function factory(string|null $query): static + { + return new static(query: $query); + } + + /** + * Method to help classes that extend Query + * to intercept a segment's result. + */ + public function intercept(mixed $result): mixed + { + return $result; + } + + /** + * Returns the query result if anything + * can be found, otherwise returns null + * + * @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query + * @throws \Kirby\Exception\InvalidArgumentException If an invalid query runner is set in the config option + */ + public function resolve(array|object $data = []): mixed + { + if (empty($this->query) === true) { + return $data; + } + + // TODO: switch to 'interpreted' as default in v6 + // TODO: remove in v7 + // @codeCoverageIgnoreStart + + if ($this->runner === 'legacy') { + return $this->resolveLegacy($data); + } + // @codeCoverageIgnoreEnd + + return $this->runner->run($this->query, (array)$data); + } + + /** + * @deprecated 5.1.0 + * @codeCoverageIgnore + */ + private function resolveLegacy(array|object $data = []): mixed + { + // merge data with default entries + if (is_array($data) === true) { + $data = [...static::$entries, ...$data]; + } + + // direct data array access via key + if ( + is_array($data) === true && + array_key_exists($this->query, $data) === true + ) { + $value = $data[$this->query]; + + if ($value instanceof Closure) { + $value = $value(); + } + + return $value; + } + + // loop through all segments to resolve query + return Expression::factory($this->query, $this)->resolve($data); + } +} + +/** + * Default entries/functions + */ +Query::$entries['kirby'] = function (): App { + return App::instance(); +}; + +Query::$entries['collection'] = function (string $name): Collection|null { + return App::instance()->collection($name); +}; + +Query::$entries['file'] = function (string $id): File|null { + return App::instance()->file($id); +}; + +Query::$entries['page'] = function (string $id): Page|null { + return App::instance()->page($id); +}; + +Query::$entries['qr'] = function (string $data): QrCode { + return new QrCode($data); +}; + +Query::$entries['site'] = function (): Site { + return App::instance()->site(); +}; + +Query::$entries['t'] = function ( + string $key, + string|array|null $fallback = null, + string|null $locale = null +): string|null { + return I18n::translate($key, $fallback, $locale); +}; + +Query::$entries['user'] = function (string|null $id = null): User|null { + return App::instance()->user($id); +}; + +Query::$entries['users'] = function (): Users { + return App::instance()->users(); +}; diff --git a/public/kirby/src/Query/Runners/DefaultRunner.php b/public/kirby/src/Query/Runners/DefaultRunner.php new file mode 100644 index 0000000..03d9df2 --- /dev/null +++ b/public/kirby/src/Query/Runners/DefaultRunner.php @@ -0,0 +1,69 @@ + + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class DefaultRunner extends Runner +{ + /** + * Creates a runner for the Query + */ + public static function for(Query $query): static + { + return new static( + global: $query::$entries, + interceptor: $query->intercept(...), + cache: $query::$cache + ); + } + + protected function resolver(string $query): Closure + { + // Load closure from cache + if (isset($this->cache[$query]) === true) { + return $this->cache[$query]; + } + + // Parse query as AST + $parser = new Parser($query); + $ast = $parser->parse(); + + // Cache closure to resolve same query + return $this->cache[$query] = fn (array $context) => $ast->resolve( + new DefaultVisitor($this->global, $context, $this->interceptor) + ); + } + + /** + * Executes a query within a given data context + * + * @param array $context Optional variables to be passed to the query + * + * @throws \Exception when query is invalid or executor not callable + */ + public function run(string $query, array $context = []): mixed + { + // Try resolving query directly from data context or global functions + $entry = Scope::get($query, $context, $this->global, false); + + if ($entry !== false) { + return $entry; + } + + return $this->resolver($query)($context); + } +} diff --git a/public/kirby/src/Query/Runners/Runner.php b/public/kirby/src/Query/Runners/Runner.php new file mode 100644 index 0000000..50745ae --- /dev/null +++ b/public/kirby/src/Query/Runners/Runner.php @@ -0,0 +1,43 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +abstract class Runner +{ + /** + * @param array $global Allowed global function closures + */ + public function __construct( + public array $global = [], + protected Closure|null $interceptor = null, + protected ArrayAccess|array &$cache = [], + ) { + } + + /** + * Creates a runner instance for the Query + */ + abstract public static function for(Query $query): static; + + /** + * Executes a query within a given data context + * + * @param array $context Optional variables to be passed to the query + * + * @throws \Exception when query is invalid or executor not callable + */ + abstract public function run(string $query, array $context = []): mixed; +} diff --git a/public/kirby/src/Query/Runners/Scope.php b/public/kirby/src/Query/Runners/Scope.php new file mode 100644 index 0000000..6eecf45 --- /dev/null +++ b/public/kirby/src/Query/Runners/Scope.php @@ -0,0 +1,94 @@ + + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class Scope +{ + /** + * Access the key on the object/array during runtime + */ + public static function access( + array|object|null $object, + string|int $key, + bool $nullSafe = false, + ...$arguments + ): mixed { + if ($object === null && $nullSafe === true) { + return null; + } + + if (is_array($object) === true) { + if ($item = $object[$key] ?? $object[(string)$key] ?? null) { + if ($arguments) { + return $item(...$arguments); + } + + if ($item instanceof Closure) { + return $item(); + } + } + + return $item; + } + + if (is_object($object) === true) { + $key = (string)$key; + + if ( + method_exists($object, $key) === true || + method_exists($object, '__call') === true + ) { + return $object->$key(...$arguments); + } + + return $object->$key ?? null; + } + + throw new Exception("Cannot access \"$key\" on " . gettype($object)); + } + + /** + * Resolves a mapping from global context or functions during runtime + */ + public static function get( + string $name, + array $context = [], + array $global = [], + false|null $fallback = null + ): mixed { + // What looks like a variable might actually be a global function + // but if there is a variable with the same name, + // the variable takes precedence + if (isset($context[$name]) === true) { + if ($context[$name] instanceof Closure) { + return $context[$name](); + } + + return $context[$name]; + } + + if (isset($global[$name]) === true) { + return $global[$name](); + } + + // Alias to access the global context + if ($name === 'this') { + return $context; + } + + return $fallback; + } +} diff --git a/public/kirby/src/Query/Segment.php b/public/kirby/src/Query/Segment.php new file mode 100644 index 0000000..d1699e2 --- /dev/null +++ b/public/kirby/src/Query/Segment.php @@ -0,0 +1,186 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @todo Deprecate in v6 + */ +class Segment +{ + public function __construct( + public string $method, + public int $position, + public Arguments|null $arguments = null, + ) { + } + + /** + * Throws an exception for an access to an invalid method + * @unstable + * + * @param mixed $data Variable on which the access was tried + * @param string $name Name of the method/property that was accessed + * @param string $label Type of the name (`method`, `property` or `method/property`) + * + * @throws \Kirby\Exception\BadMethodCallException + */ + public static function error(mixed $data, string $name, string $label): void + { + $type = strtolower(gettype($data)); + + if ($type === 'double') { + $type = 'float'; + } + + $nonExisting = in_array($type, ['array', 'object'], true) ? 'non-existing ' : ''; + + $error = 'Access to ' . $nonExisting . $label . ' "' . $name . '" on ' . $type; + + throw new BadMethodCallException($error); + } + + /** + * Parses a segment into the property/method name and its arguments + * + * @param int $position String position of the segment inside the full query + */ + public static function factory( + string $segment, + int $position = 0 + ): static { + if (Str::endsWith($segment, ')') === false) { + return new static(method: $segment, position: $position); + } + + // the args are everything inside the *outer* parentheses + $args = Str::substr($segment, Str::position($segment, '(') + 1, -1); + + return new static( + method: Str::before($segment, '('), + position: $position, + arguments: Arguments::factory($args) + ); + } + + /** + * Automatically resolves the segment depending on the + * segment position and the type of the base + * + * @param mixed $base Current value of the query chain + */ + public function resolve(mixed $base = null, array|object $data = []): mixed + { + // resolve arguments to array + $args = $this->arguments?->resolve($data) ?? []; + + // 1st segment, use $data as base + if ($this->position === 0) { + $base = $data; + } + + if (is_array($base) === true) { + return $this->resolveArray($base, $args); + } + + if (is_object($base) === true) { + return $this->resolveObject($base, $args); + } + + // trying to access further segments on a scalar/null value + static::error($base, $this->method, 'method/property'); + } + + /** + * Resolves segment by calling the corresponding array key + */ + protected function resolveArray(array $array, array $args): mixed + { + // the directly provided array takes precedence + // to look up a matching entry + if (array_key_exists($this->method, $array) === true) { + $value = $array[$this->method]; + + // if this is a Closure we can directly use it, as + // Closures from the $array should always have priority + // over the Query::$entries Closures + if ($value instanceof Closure) { + return $value(...$args); + } + + // if we have no arguments to pass, we also can directly + // use the value from the $array as it must not be different + // to the one from Query::$entries with the same name + if ($args === []) { + return $value; + } + } + + // fallback time: only if we are handling the first segment, + // we can also try to resolve the segment with an entry from the + // default Query::$entries + if ($this->position === 0) { + if (array_key_exists($this->method, Query::$entries) === true) { + return Query::$entries[$this->method](...$args); + } + } + + // if we have not been able to return anything so far, + // we just need to differntiate between two different error messages + + // this one is in case the original array contained the key, + // but was not a Closure while the segment had arguments + if ( + array_key_exists($this->method, $array) && + $args !== [] + ) { + throw new InvalidArgumentException( + message: 'Cannot access array element "' . $this->method . '" with arguments' + ); + } + + // last, the standard error for trying to access something + // that does not exist + static::error($array, $this->method, 'property'); + } + + /** + * Resolves segment by calling the method/ + * accessing the property on the base object + */ + protected function resolveObject(object $object, array $args): mixed + { + if ( + method_exists($object, $this->method) === true || + method_exists($object, '__call') === true + ) { + return $object->{$this->method}(...$args); + } + + if ( + $args === [] && + ( + property_exists($object, $this->method) === true || + method_exists($object, '__get') === true + ) + ) { + return $object->{$this->method}; + } + + $label = ($args === []) ? 'method/property' : 'method'; + static::error($object, $this->method, $label); + } +} diff --git a/public/kirby/src/Query/Segments.php b/public/kirby/src/Query/Segments.php new file mode 100644 index 0000000..e7a3a49 --- /dev/null +++ b/public/kirby/src/Query/Segments.php @@ -0,0 +1,104 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @todo Deprecate in v6 + * + * @extends \Kirby\Toolkit\Collection<\Kirby\Query\Segment> + */ +class Segments extends Collection +{ + public function __construct( + array $data = [], + protected Query|null $parent = null, + ) { + parent::__construct($data); + } + + /** + * Split query string into segments by dot + * but not inside (nested) parens + */ + public static function factory(string $query, Query|null $parent = null): static + { + $segments = static::parse($query); + $position = 0; + + $segments = A::map( + $segments, + function ($segment) use (&$position) { + // leave connectors as they are + if (in_array($segment, ['.', '?.'], true) === true) { + return $segment; + } + + // turn all other parts into Segment objects + // and pass their position in the chain (ignoring connectors) + $position++; + return Segment::factory($segment, $position - 1); + } + ); + + return new static($segments, $parent); + } + + /** + * Splits the string of a segment chaing into an + * array of segments as well as conenctors (`.` or `?.`) + * @unstable + */ + public static function parse(string $string): array + { + return preg_split( + '/(\??\.)|(\(([^()]+|(?2))*+\))(*SKIP)(*FAIL)/', + trim($string), + flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + } + + /** + * Resolves the segments chain by looping through + * each segment call to be applied to the value of + * all previous segment calls, returning gracefully at + * `?.` when current value is `null` + */ + public function resolve(array|object $data = []) + { + $value = null; + + foreach ($this->data as $segment) { + // optional chaining: stop if current value is null + if ($segment === '?.' && $value === null) { + return null; + } + + // for regular connectors and optional chaining on non-null, + // just skip this connecting segment + if ($segment === '.' || $segment === '?.') { + continue; + } + + // offer possibility to intercept on objects + if ($value !== null) { + $value = $this->parent?->intercept($value) ?? $value; + } + + $value = $segment->resolve($value, $data); + } + + return $value; + } +} diff --git a/public/kirby/src/Query/Visitors/DefaultVisitor.php b/public/kirby/src/Query/Visitors/DefaultVisitor.php new file mode 100644 index 0000000..4f7a96c --- /dev/null +++ b/public/kirby/src/Query/Visitors/DefaultVisitor.php @@ -0,0 +1,188 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + */ +class DefaultVisitor extends Visitor +{ + /** + * Processes list of arguments + */ + public function arguments(array $arguments): array + { + return $arguments; + } + + /** + * Processes arithmetic operation + */ + public function arithmetic( + int|float $left, + string $operator, + int|float $right + ): mixed { + return match ($operator) { + '+' => $left + $right, + '-' => $left - $right, + '*' => $left * $right, + '/' => $left / $right, + '%' => $left % $right, + default => throw new Exception("Unknown arithmetic operator: $operator") + }; + } + + /** + * Processes array + */ + public function arrayList(array $elements): array + { + return $elements; + } + + /** + * Processes node into actual closure + */ + public function closure(ClosureNode $node): Closure + { + $self = $this; + + return function (...$params) use ($self, $node) { + // [key1, key2] + [value1, value2] => + // [key1 => value1, key2 => value2] + $arguments = array_combine( + $node->arguments, + $params + ); + + // Create new nested visitor with combined + // data context for resolving the closure body + $visitor = new static( + global: $self->global, + context: [...$self->context, ...$arguments], + interceptor: $self->interceptor + ); + + return $node->body->resolve($visitor); + }; + } + + /** + * Processes coalescence operator + */ + public function coalescence(mixed $left, mixed $right): mixed + { + return $left ?? $right; + } + + /** + * Processes comparison operation + */ + public function comparison( + mixed $left, + string $operator, + mixed $right + ): bool { + return match ($operator) { + '==' => $left == $right, + '===' => $left === $right, + '!=' => $left != $right, + '!==' => $left !== $right, + '<' => $left < $right, + '<=' => $left <= $right, + '>' => $left > $right, + '>=' => $left >= $right, + default => throw new Exception("Unknown comparison operator: $operator") + }; + } + + /** + * Processes global function + */ + public function function(string $name, array $arguments = []): mixed + { + $function = $this->global[$name] ?? null; + + if ($function === null) { + throw new Exception("Invalid global function in query: $name"); + } + + return $function(...$arguments); + } + + /** + * Processes literals + */ + public function literal(mixed $value): mixed + { + return $value; + } + + /** + * Processes logical operation + */ + public function logical( + mixed $left, + string $operator, + mixed $right + ): bool { + return match ($operator) { + '&&', 'AND' => $left && $right, + '||', 'OR' => $left || $right, + default => throw new Exception("Unknown logical operator: $operator") + }; + } + + /** + * Processes member access + */ + public function memberAccess( + mixed $object, + string|int $member, + array|null $arguments = null, + bool $nullSafe = false + ): mixed { + if ($this->interceptor !== null) { + $object = ($this->interceptor)($object); + } + + return Scope::access($object, $member, $nullSafe, ...$arguments ?? []); + } + + /** + * Processes ternary operator + */ + public function ternary( + mixed $condition, + mixed $true, + mixed $false + ): mixed { + if ($true === null) { + return $condition ?: $false; + } + + return $condition ? $true : $false; + } + + /** + * Get variable from context or global function + */ + public function variable(string $name): mixed + { + return Scope::get($name, $this->context, $this->global); + } +} diff --git a/public/kirby/src/Query/Visitors/Visitor.php b/public/kirby/src/Query/Visitors/Visitor.php new file mode 100644 index 0000000..6425206 --- /dev/null +++ b/public/kirby/src/Query/Visitors/Visitor.php @@ -0,0 +1,46 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @license https://opensource.org/licenses/MIT + * @since 5.1.0 + * @unstable + * + * Every visitor class must implement the following methods. + * As PHP won't allow increasing the typing specificity, we + * aren't actually adding them here in the abstract class, so that + * the actual visitor classes can work with much more specific type hints. + * + * @method mixed arguments(array $arguments) + * @method mixed arithmetic(mixed $left, string $operator, mixed $right) + * @method mixed arrayList(array $elements) + * @method mixed closure($ClosureNode $node)) + * @method mixed coalescence($left, $right) + * @method mixed comparison(mixed $left, string $operator, mixed $right) + * @method mixed function($name, $arguments) + * @method mixed literal($value) + * @method mixed logical(mixed $left, string $operator, mixed $right) + * @method mixed memberAccess($object, string|int $member, $arguments, bool $nullSafe = false) + * @method mixed ternary($condition, $true, $false) + * @method mixed variable(string $name) + */ +abstract class Visitor +{ + /** + * @param array $global valid global function closures + * @param array $context data bindings for the query + */ + public function __construct( + public array $global = [], + public array $context = [], + protected Closure|null $interceptor = null + ) { + } +} diff --git a/public/kirby/src/Sane/DomHandler.php b/public/kirby/src/Sane/DomHandler.php new file mode 100644 index 0000000..3ef7516 --- /dev/null +++ b/public/kirby/src/Sane/DomHandler.php @@ -0,0 +1,179 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @SuppressWarnings(PHPMD.LongVariable) + */ +class DomHandler extends Handler +{ + /** + * List of all MIME types that may + * be used in data URIs + */ + public static array $allowedDataUris = [ + 'data:image/png', + 'data:image/gif', + 'data:image/jpg', + 'data:image/jpe', + 'data:image/pjp', + 'data:img/png', + 'data:img/gif', + 'data:img/jpg', + 'data:img/jpe', + 'data:img/pjp', + ]; + + /** + * Allowed hostnames for HTTP(S) URLs + */ + public static array|true $allowedDomains = true; + + /** + * Whether URLs that begin with `/` should be allowed even if the + * site index URL is in a subfolder (useful when using the HTML + * `` element where the sanitized code will be rendered) + */ + public static bool $allowHostRelativeUrls = true; + + /** + * Names of allowed XML processing instructions + */ + public static array $allowedPIs = []; + + /** + * The document type (`'HTML'` or `'XML'`) + * (to be set in child classes) + */ + protected static string $type = 'XML'; + + /** + * Sanitizes the given string + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed + */ + public static function sanitize( + string $string, + bool $isExternal = false + ): string { + $dom = static::parse($string); + $dom->sanitize(static::options($isExternal)); + return $dom->toString(); + } + + /** + * Validates file contents + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @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 { + $dom = static::parse($string); + $errors = $dom->sanitize(static::options($isExternal)); + + // there may be multiple errors, we can only throw one of them at a time + if (count($errors) > 0) { + throw $errors[0]; + } + } + + /** + * Custom callback for additional attribute sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeAttr( + DOMAttr $attr, + array $options + ): array { + // to be extended in child classes + return []; + } + + /** + * Custom callback for additional element sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeElement( + DOMElement $element, + array $options + ): array { + // to be extended in child classes + return []; + } + + /** + * Custom callback for additional doctype validation + * @internal + */ + public static function validateDoctype( + DOMDocumentType $doctype, + array $options + ): void { + // to be extended in child classes + } + + /** + * Returns the sanitization options for the handler + * (to be extended in child classes) + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + */ + protected static function options(bool $isExternal): array + { + $options = [ + 'allowedDataUris' => static::$allowedDataUris, + 'allowedDomains' => static::$allowedDomains, + 'allowHostRelativeUrls' => static::$allowHostRelativeUrls, + 'allowedPIs' => static::$allowedPIs, + 'attrCallback' => static::sanitizeAttr(...), + 'doctypeCallback' => static::validateDoctype(...), + 'elementCallback' => static::sanitizeElement(...), + ]; + + // never allow host-relative URLs in external files as we + // cannot set a `` element for them when accessed directly + if ($isExternal === true) { + $options['allowHostRelativeUrls'] = false; + } + + return $options; + } + + /** + * Parses the given string into a `Toolkit\Dom` object + * + * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed + */ + protected static function parse(string $string): Dom + { + return new Dom($string, static::$type); + } +} diff --git a/public/kirby/src/Sane/Handler.php b/public/kirby/src/Sane/Handler.php new file mode 100644 index 0000000..bd6b4e5 --- /dev/null +++ b/public/kirby/src/Sane/Handler.php @@ -0,0 +1,92 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Handler +{ + /** + * Sanitizes the given string + * + * @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; + + /** + * Sanitizes the contents of a file by overwriting + * the file with the sanitized version + * + * @throws \Kirby\Exception\Exception If the file does not exist + * @throws \Kirby\Exception\Exception On other errors + */ + public static function sanitizeFile(string $file): void + { + $content = static::readFile($file); + $sanitized = static::sanitize($content, isExternal: true); + F::write($file, $sanitized); + } + + /** + * Validates file contents + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @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; + + /** + * Validates the contents of a file + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\Exception If the file does not exist + * @throws \Kirby\Exception\Exception On other errors + */ + public static function validateFile(string $file): void + { + $content = static::readFile($file); + static::validate($content, isExternal: true); + } + + /** + * Reads the contents of a file + * for sanitization or validation + * + * @throws \Kirby\Exception\Exception If the file does not exist + */ + protected static function readFile(string $file): string + { + $contents = F::read($file); + + if ($contents === false) { + 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 new file mode 100644 index 0000000..0ad2e1f --- /dev/null +++ b/public/kirby/src/Sane/Html.php @@ -0,0 +1,127 @@ +, + * Lukas Bestle + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Html extends DomHandler +{ + /** + * Global list of allowed attribute prefixes + */ + public static array $allowedAttrPrefixes = [ + 'aria-', + 'data-', + ]; + + /** + * Global list of allowed attributes + */ + public static array $allowedAttrs = [ + 'class', + 'id', + ]; + + /** + * Associative array of all allowed tag names with the value + * of either an array with the list of all allowed attributes + * for this tag, `true` to allow any attribute from the + * `allowedAttrs` list or `false` to allow the tag without + * any attributes + */ + public static array $allowedTags = [ + 'a' => ['href', 'rel', 'title', 'target'], + 'abbr' => ['title'], + 'b' => true, + 'body' => true, + 'blockquote' => true, + 'br' => true, + 'code' => true, + 'dl' => true, + 'dd' => true, + 'del' => true, + 'div' => true, + 'dt' => true, + 'em' => true, + 'footer' => true, + 'h1' => true, + 'h2' => true, + 'h3' => true, + 'h4' => true, + 'h5' => true, + 'h6' => true, + 'hr' => true, + 'html' => true, + 'i' => true, + 'ins' => true, + 'li' => true, + 'small' => true, + 'span' => true, + 'strong' => true, + 'sub' => true, + 'sup' => true, + 'ol' => true, + 'p' => true, + 'pre' => true, + 's' => true, + 'u' => true, + 'ul' => true, + ]; + + /** + * Array of explicitly disallowed tags + * + * IMPORTANT: Use lower-case names here because + * of the case-insensitive matching + */ + public static array $disallowedTags = [ + 'iframe', + 'meta', + 'object', + 'script', + 'style', + ]; + + /** + * List of attributes that may contain URLs + */ + public static array $urlAttrs = [ + 'href', + 'src', + 'xlink:href', + ]; + + /** + * The document type (`'HTML'` or `'XML'`) + */ + protected static string $type = 'HTML'; + + /** + * Returns the sanitization options for the handler + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + */ + protected static function options(bool $isExternal): array + { + return [ + ...parent::options($isExternal), + 'allowedAttrPrefixes' => static::$allowedAttrPrefixes, + 'allowedAttrs' => static::$allowedAttrs, + 'allowedNamespaces' => [], + 'allowedPIs' => [], + '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 new file mode 100644 index 0000000..9d753ee --- /dev/null +++ b/public/kirby/src/Sane/Sane.php @@ -0,0 +1,216 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Sane +{ + /** + * Handler Type Aliases + */ + public static array $aliases = [ + 'application/xml' => 'xml', + 'image/svg' => 'svg', + 'image/svg+xml' => 'svg', + 'text/html' => 'html', + 'text/xml' => 'xml', + ]; + + /** + * All registered handlers + */ + public static array $handlers = [ + 'html' => Html::class, + 'svg' => Svg::class, + 'svgz' => Svgz::class, + 'xml' => Xml::class, + ]; + + /** + * Handler getter + * + * @param bool $lazy If set to `true`, `null` is returned for undefined handlers + * + * @throws \Kirby\Exception\NotFoundException If no handler was found and `$lazy` was set to `false` + */ + public static function handler( + string $type, + bool $lazy = false + ): Handler|null { + // normalize the type + $type = mb_strtolower($type); + + // find a handler or alias + $handler = static::$handlers[$type] ?? null; + + if ($alias = static::$aliases[$type] ?? null) { + $handler ??= static::$handlers[$alias] ?? null; + } + + if (empty($handler) === false && class_exists($handler) === true) { + return new $handler(); + } + + if ($lazy === true) { + return null; + } + + throw new NotFoundException( + message: 'Missing handler for type: "' . $type . '"' + ); + } + + /** + * Sanitizes the given string with the specified handler + * @since 3.6.0 + * + * @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 { + return static::handler($type)->sanitize($string, $isExternal); + } + + /** + * Sanitizes the contents of a file by overwriting + * the file with the sanitized version; + * the sane handlers are automatically chosen by + * the extension and MIME type if not specified + * @since 3.6.0 + * + * @param string|bool $typeLazy Explicit handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\LogicException If more than one handler applies + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public static function sanitizeFile( + string $file, + string|bool $typeLazy = false + ): void { + if (is_string($typeLazy) === true) { + static::handler($typeLazy)->sanitizeFile($file); + return; + } + + // try to find exactly one matching handler + $handlers = static::handlersForFile($file, $typeLazy === true); + switch (count($handlers)) { + case 0: + // lazy autodetection didn't find a handler + break; + case 1: + $handlers[0]->sanitizeFile($file); + break; + default: + // more than one matching handler; + // sanitizing with all handlers will not leave much in the output + throw new LogicException( + 'Cannot sanitize file as more than one handler applies: ' . + implode(', ', A::map($handlers, fn ($handler) => $handler::class)) + ); + } + } + + /** + * Validates file contents with the specified handler + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public static function validate(string $string, string $type, bool $isExternal = false): void + { + static::handler($type)->validate($string, $isExternal); + } + + /** + * Validates the contents of a file; + * the sane handlers are automatically chosen by + * the extension and MIME type if not specified + * + * @param string|bool $typeLazy Explicit handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public static function validateFile( + string $file, + string|bool $typeLazy = false + ): void { + if (is_string($typeLazy) === true) { + static::handler($typeLazy)->validateFile($file); + return; + } + + $handlers = static::handlersForFile($file, $typeLazy === true); + + foreach ($handlers as $handler) { + $handler->validateFile($file); + } + } + + /** + * Returns all handler objects that apply to the given file based on + * file extension and MIME type + * + * @param bool $lazy If set to `true`, undefined handlers are skipped + * @return array<\Kirby\Sane\Handler> + */ + protected static function handlersForFile( + string $file, + bool $lazy = false + ): array { + $handlers = $handlerClasses = []; + + // all values that can be used for the handler search; + // filter out all empty options + $options = array_filter([F::extension($file), F::mime($file)]); + + foreach ($options as $option) { + $handler = static::handler($option, $lazy); + $handlerClass = $handler ? $handler::class : null; + + // ensure that each handler class is only returned once + if ( + $handler && + in_array($handlerClass, $handlerClasses, true) === false + ) { + $handlers[] = $handler; + $handlerClasses[] = $handlerClass; + } + } + + return $handlers; + } +} diff --git a/public/kirby/src/Sane/Svg.php b/public/kirby/src/Sane/Svg.php new file mode 100644 index 0000000..7d8d22d --- /dev/null +++ b/public/kirby/src/Sane/Svg.php @@ -0,0 +1,505 @@ +, + * Lukas Bestle + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Svg extends Xml +{ + /** + * Allow and block lists are inspired by DOMPurify + * + * @link https://github.com/cure53/DOMPurify + * @copyright 2015 Mario Heiderich + * @license https://www.apache.org/licenses/LICENSE-2.0 + */ + + /** + * Global list of allowed attribute prefixes + */ + public static array $allowedAttrPrefixes = [ + 'aria-', + 'data-', + ]; + + /** + * Global list of allowed attributes + */ + public static array $allowedAttrs = [ + // core attributes + 'id', + 'lang', + 'tabindex', + 'xml:id', + 'xml:lang', + 'xml:space', + + // styling attributes + 'class', + 'style', + + // conditional processing attributes + 'systemLanguage', + + // presentation attributes + 'alignment-baseline', + 'baseline-shift', + 'clip', + 'clip-path', + 'clip-rule', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'color-profile', + 'color-rendering', + 'd', + 'direction', + 'display', + 'dominant-baseline', + 'enable-background', + 'fill', + 'fill-opacity', + 'fill-rule', + 'filter', + 'flood-color', + 'flood-opacity', + 'font-family', + 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-weight', + 'image-rendering', + 'kerning', + 'letter-spacing', + 'lighting-color', + 'marker-end', + 'marker-mid', + 'marker-start', + 'mask', + 'opacity', + 'overflow', + 'paint-order', + 'shape-rendering', + 'stop-color', + 'stop-opacity', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'text-anchor', + 'text-decoration', + 'text-rendering', + 'transform', + 'visibility', + 'word-spacing', + 'writing-mode', + + // animation attribute target attributes + 'attributeName', + 'attributeType', + + // animation timing attributes + 'begin', + 'dur', + 'end', + 'max', + 'min', + 'repeatCount', + 'repeatDur', + 'restart', + + // animation value attributes + 'by', + 'from', + 'keySplines', + 'keyTimes', + 'to', + 'values', + + // animation addition attributes + 'accumulate', + 'additive', + + // filter primitive attributes + 'height', + 'result', + 'width', + 'x', + 'y', + + // transfer function attributes + 'amplitude', + 'exponent', + 'intercept', + 'offset', + 'slope', + 'tableValues', + 'type', + + // other attributes specific to one or multiple elements + 'azimuth', + 'baseFrequency', + 'bias', + 'clipPathUnits', + 'cx', + 'cy', + 'diffuseConstant', + 'divisor', + 'dx', + 'dy', + 'edgeMode', + 'elevation', + 'filterUnits', + 'fr', + 'fx', + 'fy', + 'g1', + 'g2', + 'glyph-name', + 'glyphRef', + 'gradientTransform', + 'gradientUnits', + 'href', + 'hreflang', + 'in', + 'in2', + 'k', + 'k1', + 'k2', + 'k3', + 'k4', + 'kernelMatrix', + 'kernelUnitLength', + 'keyPoints', + 'lengthAdjust', + 'limitingConeAngle', + 'markerHeight', + 'markerUnits', + 'markerWidth', + 'maskContentUnits', + 'maskUnits', + 'media', + 'method', + 'mode', + 'numOctaves', + 'operator', + 'order', + 'orient', + 'orientation', + 'path', + 'pathLength', + 'patternContentUnits', + 'patternTransform', + 'patternUnits', + 'points', + 'pointsAtX', + 'pointsAtY', + 'pointsAtZ', + 'preserveAlpha', + 'preserveAspectRatio', + 'primitiveUnits', + 'r', + 'radius', + 'refX', + 'refY', + 'rotate', + 'rx', + 'ry', + 'scale', + 'seed', + 'side', + 'spacing', + 'specularConstant', + 'specularExponent', + 'spreadMethod', + 'startOffset', + 'stdDeviation', + 'stitchTiles', + 'surfaceScale', + 'targetX', + 'targetY', + 'textLength', + 'u1', + 'u2', + 'unicode', + 'version', + 'vert-adv-y', + 'vert-origin-x', + 'vert-origin-y', + 'viewBox', + 'x1', + 'x2', + 'xChannelSelector', + 'xlink:href', + 'xlink:title', + 'y1', + 'y2', + 'yChannelSelector', + 'z', + 'zoomAndPan', + ]; + + /** + * Allowed hostnames for HTTP(S) URLs + */ + public static array|true $allowedDomains = []; + + /** + * Associative array of all allowed namespace URIs + */ + public static array $allowedNamespaces = [ + '' => 'http://www.w3.org/2000/svg', + 'xlink' => 'http://www.w3.org/1999/xlink' + ]; + + /** + * Associative array of all allowed tag names with the value + * of either an array with the list of all allowed attributes + * for this tag, `true` to allow any attribute from the + * `allowedAttrs` list or `false` to allow the tag without + * any attributes + */ + public static array $allowedTags = [ + 'a' => true, + 'altGlyph' => true, + 'altGlyphDef' => true, + 'altGlyphItem' => true, + 'animateColor' => true, + 'animateMotion' => true, + 'animateTransform' => true, + 'circle' => true, + 'clipPath' => true, + 'defs' => true, + 'desc' => true, + 'ellipse' => true, + 'feBlend' => true, + 'feColorMatrix' => true, + 'feComponentTransfer' => true, + 'feComposite' => true, + 'feConvolveMatrix' => true, + 'feDiffuseLighting' => true, + 'feDisplacementMap' => true, + 'feDistantLight' => true, + 'feFlood' => true, + 'feFuncA' => true, + 'feFuncB' => true, + 'feFuncG' => true, + 'feFuncR' => true, + 'feGaussianBlur' => true, + 'feMerge' => true, + 'feMergeNode' => true, + 'feMorphology' => true, + 'feOffset' => true, + 'fePointLight' => true, + 'feSpecularLighting' => true, + 'feSpotLight' => true, + 'feTile' => true, + 'feTurbulence' => true, + 'filter' => true, + 'font' => true, + 'g' => true, + 'glyph' => true, + 'glyphRef' => true, + 'hkern' => true, + 'image' => true, + 'line' => true, + 'linearGradient' => true, + 'marker' => true, + 'mask' => true, + 'metadata' => true, + 'mpath' => true, + 'path' => true, + 'pattern' => true, + 'polygon' => true, + 'polyline' => true, + 'radialGradient' => true, + 'rect' => true, + 'stop' => true, + 'style' => true, + 'svg' => true, + 'switch' => true, + 'symbol' => true, + 'text' => true, + 'textPath' => true, + 'title' => true, + 'tref' => true, + 'tspan' => true, + 'use' => true, + 'view' => true, + 'vkern' => true, + ]; + + /** + * Array of explicitly disallowed tags + * + * IMPORTANT: Use lower-case names here because + * of the case-insensitive matching + */ + public static array $disallowedTags = [ + 'animate', + 'color-profile', + 'cursor', + 'discard', + 'fedropshadow', + 'feimage', + 'font-face', + 'font-face-format', + 'font-face-name', + 'font-face-src', + 'font-face-uri', + 'foreignobject', + 'hatch', + 'hatchpath', + 'mesh', + 'meshgradient', + 'meshpatch', + 'meshrow', + 'missing-glyph', + 'script', + 'set', + 'solidcolor', + 'unknown', + ]; + + /** + * Custom callback for additional attribute sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeAttr(DOMAttr $attr, array $options): array + { + $element = $attr->ownerElement; + $name = $attr->name; + $value = $attr->value; + $errors = []; + + // block nested elements ("Billion Laughs" DoS attack) + if ( + $element->localName === 'use' && + Str::contains($name, 'href') !== false && + Str::startsWith($value, '#') === true + ) { + // find the target (used element) + $id = str_replace('"', '', mb_substr($value, 1)); + $path = new DOMXPath($attr->ownerDocument); + $target = $path->query('//*[@id="' . $id . '"]')->item(0); + + // the target must not contain any other elements + if ( + $target instanceof DOMElement && + $target->getElementsByTagName('use')->count() > 0 + ) { + $errors[] = new InvalidArgumentException( + 'Nested "use" elements are not allowed' . + ' (used in line ' . $element->getLineNo() . ')' + ); + $element->removeAttributeNode($attr); + } + } + + return $errors; + } + + /** + * Custom callback for additional element sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeElement( + DOMElement $element, + array $options + ): array { + $errors = []; + + // check for URLs inside + * + * text + */ + public static function css(string $string): string + { + return static::escaper()->escapeCss($string); + } + + /** + * Get the escaper instance (and create if needed) + */ + protected static function escaper(): Escaper + { + return static::$escaper ??= new Escaper('utf-8'); + } + + /** + * Escape HTML element content + * + * This can be used to put untrusted data directly into the HTML body somewhere. + * This includes inside normal tags like div, p, b, td, etc. + * + * Escapes &, <, >, ", and ' with HTML entity encoding to prevent switching + * into any execution context, such as script, style, or event handlers. + * + * ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE... + *
    ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...
    + */ + public static function html(string $string): string + { + return static::escaper()->escapeHtml($string); + } + + /** + * Escape JavaScript data values + * + * This can be used to put dynamically generated JavaScript code + * into both script blocks and event-handler attributes. + * + * + * + *
    + */ + public static function js(string $string): string + { + return static::escaper()->escapeJs($string); + } + + /** + * Escape URL parameter values + * + * This can be used to put untrusted data into HTTP GET parameter values. + * This should not be used to escape an entire URI. + * + * link + */ + public static function url(string $string): string + { + return rawurlencode($string); + } + + /** + * Escape XML element content + * + * Removes offending characters that could be wrongfully interpreted as XML markup. + * + * The following characters are reserved in XML and will be replaced with their + * corresponding XML entities: + * + * ' is replaced with ' + * " is replaced with " + * & is replaced with & + * < is replaced with < + * > is replaced with > + */ + public static function xml(string $string): string + { + return htmlspecialchars($string, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } +} diff --git a/public/kirby/src/Toolkit/Facade.php b/public/kirby/src/Toolkit/Facade.php new file mode 100644 index 0000000..04e9e15 --- /dev/null +++ b/public/kirby/src/Toolkit/Facade.php @@ -0,0 +1,30 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Facade +{ + /** + * Returns the instance that should be + * available statically + */ + abstract public static function instance(); + + /** + * Proxy for all public instance calls + */ + public static function __callStatic(string $method, array|null $args = null) + { + return static::instance()->$method(...$args); + } +} diff --git a/public/kirby/src/Toolkit/Html.php b/public/kirby/src/Toolkit/Html.php new file mode 100644 index 0000000..1c010cb --- /dev/null +++ b/public/kirby/src/Toolkit/Html.php @@ -0,0 +1,671 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Html extends Xml +{ + /** + * An internal store for an HTML entities translation table + */ + public static array|null $entities = null; + + /** + * List of HTML tags that can be used inline + */ + public static array $inlineList = [ + 'b', + 'i', + 'small', + 'abbr', + 'cite', + 'code', + 'dfn', + 'em', + 'kbd', + 'strong', + 'samp', + 'var', + 'a', + 'bdo', + 'br', + 'img', + 'q', + 'span', + 'sub', + 'sup' + ]; + + /** + * Closing string for void tags; + * can be used to switch to trailing slashes if required + * + * ```php + * Html::$void = ' />' + * ``` + */ + public static string $void = '>'; + + /** + * List of HTML tags that are considered to be self-closing + */ + public static array $voidList = [ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr' + ]; + + /** + * Generic HTML tag generator + * Can be called like `Html::p('A paragraph', ['class' => 'text'])` + * + * @param string $tag Tag name + * @param array $arguments Further arguments for the Html::tag() method + */ + public static function __callStatic( + string $tag, + array $arguments = [] + ): string { + if (static::isVoid($tag) === true) { + return static::tag($tag, null, ...$arguments); + } + + return static::tag($tag, ...$arguments); + } + + /** + * Generates an `` tag; automatically supports mailto: and tel: links + * + * @param string $href The URL for the `` tag + * @param string|array|null $text The optional text; if `null`, the URL will be used as text + * @param array $attr Additional attributes for the tag + * @return string The generated HTML + */ + public static function a( + string $href, + $text = null, + array $attr = [] + ): string { + if (Str::startsWith($href, 'mailto:')) { + return static::email(substr($href, 7), $text, $attr); + } + + if (Str::startsWith($href, 'tel:')) { + return static::tel(substr($href, 4), $text, $attr); + } + + return static::link($href, $text, $attr); + } + + /** + * Generates a single attribute or a list of attributes + * + * @param string|array $name String: A single attribute with that name will be generated. + * Key-value array: A list of attributes will be generated. Don't pass a second argument in that case. + * @param mixed $value If used with a `$name` string, pass the value of the attribute here. + * If used with a `$name` array, this can be set to `false` to disable attribute sorting. + * @param string|null $before An optional string that will be prepended if the result is not empty + * @param string|null $after An optional string that will be appended if the result is not empty + * @return string|null The generated HTML attributes string + */ + public static function attr( + string|array $name, + $value = null, + string|null $before = null, + string|null $after = null + ): string|null { + // HTML supports boolean attributes without values + if (is_array($name) === false && is_bool($value) === true) { + return $value === true ? strtolower($name) : null; + } + + // HTML attribute names are case-insensitive + if (is_string($name) === true) { + $name = strtolower($name); + } + + // all other cases can share the XML variant + $attr = parent::attr($name, $value); + + if ($attr === null) { + return null; + } + + // HTML supports named entities + $entities = parent::entities(); + $html = array_keys($entities); + $xml = array_values($entities); + $attr = str_replace($xml, $html, $attr); + + if ($attr) { + return $before . $attr . $after; + } + + return null; + } + + /** + * Converts lines in a string into HTML breaks + */ + public static function breaks(string $string): string + { + return nl2br($string); + } + + /** + * Generates an `` tag with `mailto:` + * + * @param string $email The email address + * @param string|array|null $text The optional text; if `null`, the email address will be used as text + * @param array $attr Additional attributes for the tag + * @return string The generated HTML + */ + public static function email( + string $email, + string|array|null $text = null, + array $attr = [] + ): string { + if (empty($email) === true) { + return ''; + } + + if (empty($text) === true) { + // show only the email address without additional parameters + $address = match (Str::contains($email, '?')) { + true => Str::before($email, '?'), + false => $email + }; + + $text = [Str::encode($address)]; + } + + $attr = [ + 'href' => [ + 'value' => 'mailto:' . Str::encode($email), + 'escape' => false + ], + ...$attr + ]; + + // add rel=noopener to target blank links to improve security + $attr['rel'] = static::rel( + $attr['rel'] ?? null, + $attr['target'] ?? null + ); + + return static::tag('a', $text, $attr); + } + + /** + * Converts a string to an HTML-safe string + * + * @param bool $keepTags If true, existing tags won't be escaped + * @return string The HTML string + * + * @psalm-suppress ParamNameMismatch + */ + public static function encode( + string|null $string, + bool $keepTags = false + ): string { + if ($string === null) { + return ''; + } + + if ($keepTags === true) { + $list = static::entities(); + + unset( + $list['"'], + $list['<'], + $list['>'], + $list['&'] + ); + + $search = array_keys($list); + $values = array_values($list); + + return str_replace($search, $values, $string); + } + + return htmlentities($string, ENT_QUOTES, 'utf-8'); + } + + /** + * Returns the entity translation table + */ + public static function entities(): array + { + return self::$entities ??= get_html_translation_table(HTML_ENTITIES); + } + + /** + * Creates a `
    ` tag with optional caption + * + * @param string|array $content Contents of the `
    ` tag + * @param string|array $caption Optional `
    ` text to use + * @param array $attr Additional attributes for the `
    ` tag + * @return string The generated HTML + */ + public static function figure( + string|array $content, + string|array|null $caption = '', + array $attr = [] + ): string { + if ($caption) { + $figcaption = static::tag('figcaption', $caption); + + if (is_string($content) === true) { + $content = [static::encode($content, false)]; + } + + $content[] = $figcaption; + } + + return static::tag('figure', $content, $attr); + } + + /** + * Embeds a GitHub Gist + * + * @param string $url Gist URL + * @param string|null $file Optional specific file to embed + * @param array $attr Additional attributes for the ` + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + $js): ?> + + + + + + + + + diff --git a/public/kirby/views/php.php b/public/kirby/views/php.php new file mode 100644 index 0000000..46db0c6 --- /dev/null +++ b/public/kirby/views/php.php @@ -0,0 +1,11 @@ + + +

    + This page is currently offline. We are very sorry for the inconvenience and will fix it as soon as possible. +

    +

    + Advice for developers and administrators:
    + Change the PHP version to one
    supported by your version of Kirby +

    + + diff --git a/public/kirby/views/snippets/footer.php b/public/kirby/views/snippets/footer.php new file mode 100644 index 0000000..308b1d0 --- /dev/null +++ b/public/kirby/views/snippets/footer.php @@ -0,0 +1,2 @@ + + diff --git a/public/kirby/views/snippets/header.php b/public/kirby/views/snippets/header.php new file mode 100644 index 0000000..5592609 --- /dev/null +++ b/public/kirby/views/snippets/header.php @@ -0,0 +1,42 @@ + + + + + + + Error + + + + + diff --git a/public/media/index.html b/public/media/index.html new file mode 100644 index 0000000..e69de29 diff --git a/public/site/accounts/index.html b/public/site/accounts/index.html new file mode 100644 index 0000000..e69de29 diff --git a/public/site/blueprints/pages/default.yml b/public/site/blueprints/pages/default.yml new file mode 100644 index 0000000..0cb0129 --- /dev/null +++ b/public/site/blueprints/pages/default.yml @@ -0,0 +1,21 @@ +title: Default Page + +columns: + main: + width: 2/3 + sections: + fields: + type: fields + fields: + text: + type: textarea + size: huge + sidebar: + width: 1/3 + sections: + pages: + type: pages + template: default + files: + type: files + diff --git a/public/site/blueprints/site.yml b/public/site/blueprints/site.yml new file mode 100644 index 0000000..b7da661 --- /dev/null +++ b/public/site/blueprints/site.yml @@ -0,0 +1,5 @@ +title: Site + +sections: + pages: + type: pages diff --git a/public/site/cache/index.html b/public/site/cache/index.html new file mode 100644 index 0000000..e69de29 diff --git a/public/site/sessions/index.html b/public/site/sessions/index.html new file mode 100644 index 0000000..e69de29 diff --git a/public/site/snippets/footer.php b/public/site/snippets/footer.php new file mode 100644 index 0000000..a945f81 --- /dev/null +++ b/public/site/snippets/footer.php @@ -0,0 +1,5 @@ + +
    + + + \ No newline at end of file diff --git a/public/site/snippets/header.php b/public/site/snippets/header.php new file mode 100644 index 0000000..56d7c84 --- /dev/null +++ b/public/site/snippets/header.php @@ -0,0 +1,29 @@ + + + + + + + + <?= e($page->isHomePage() != true, $page->title() . ' - ') . $site->title() ?> + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/public/site/snippets/index.html b/public/site/snippets/index.html new file mode 100644 index 0000000..e69de29 diff --git a/public/site/templates/default.php b/public/site/templates/default.php new file mode 100644 index 0000000..74e38ae --- /dev/null +++ b/public/site/templates/default.php @@ -0,0 +1 @@ +

    title() ?>

    diff --git a/public/site/templates/home.php b/public/site/templates/home.php new file mode 100644 index 0000000..8e54641 --- /dev/null +++ b/public/site/templates/home.php @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/vendor/autoload.php b/public/vendor/autoload.php new file mode 100644 index 0000000..3e30699 --- /dev/null +++ b/public/vendor/autoload.php @@ -0,0 +1,25 @@ +realpath = realpath($opened_path) ?: $opened_path; + $opened_path = $this->realpath; + $this->handle = fopen($this->realpath, $mode); + $this->position = 0; + + return (bool) $this->handle; + } + + public function stream_read($count) + { + $data = fread($this->handle, $count); + + if ($this->position === 0) { + $data = preg_replace('{^#!.*\r?\n}', '', $data); + } + + $this->position += strlen($data); + + return $data; + } + + public function stream_cast($castAs) + { + return $this->handle; + } + + public function stream_close() + { + fclose($this->handle); + } + + public function stream_lock($operation) + { + return $operation ? flock($this->handle, $operation) : true; + } + + public function stream_seek($offset, $whence) + { + if (0 === fseek($this->handle, $offset, $whence)) { + $this->position = ftell($this->handle); + return true; + } + + return false; + } + + public function stream_tell() + { + return $this->position; + } + + public function stream_eof() + { + return feof($this->handle); + } + + public function stream_stat() + { + return array(); + } + + public function stream_set_option($option, $arg1, $arg2) + { + return true; + } + + public function url_stat($path, $flags) + { + $path = substr($path, 17); + if (file_exists($path)) { + return stat($path); + } + + return false; + } + } + } + + if ( + (function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true)) + || (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper')) + ) { + return include("phpvfscomposer://" . __DIR__ . '/..'.'/symfony/yaml/Resources/bin/yaml-lint'); + } +} + +return include __DIR__ . '/..'.'/symfony/yaml/Resources/bin/yaml-lint'; diff --git a/public/vendor/christian-riesen/base32/LICENSE b/public/vendor/christian-riesen/base32/LICENSE new file mode 100644 index 0000000..624fceb --- /dev/null +++ b/public/vendor/christian-riesen/base32/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2013-2014 Christian Riesen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/public/vendor/christian-riesen/base32/README.md b/public/vendor/christian-riesen/base32/README.md new file mode 100644 index 0000000..74343b8 --- /dev/null +++ b/public/vendor/christian-riesen/base32/README.md @@ -0,0 +1,64 @@ +base32 +====== + +Base32 Encoder/Decoder for PHP according to [RFC 4648](https://tools.ietf.org/html/rfc4648). + +![CI](https://github.com/ChristianRiesen/base32/workflows/CI/badge.svg) + +[![Latest Stable Version](https://poser.pugx.org/christian-riesen/base32/v/stable.png)](https://packagist.org/packages/christian-riesen/base32) [![Total Downloads](https://poser.pugx.org/christian-riesen/base32/downloads.png)](https://packagist.org/packages/christian-riesen/base32) [![Latest Unstable Version](https://poser.pugx.org/christian-riesen/base32/v/unstable.png)](https://packagist.org/packages/christian-riesen/base32) [![License](https://poser.pugx.org/christian-riesen/base32/license.png)](https://packagist.org/packages/christian-riesen/base32) + + +Installation +----- + +Use composer: + +```bash +composer require christian-riesen/base32 +``` + +Usage +----- + +```php + http://christianriesen.com + +Acknowledgements +---------------- + +Base32 is mostly based on the work of https://github.com/NTICompass/PHP-Base32 diff --git a/public/vendor/christian-riesen/base32/src/Base32.php b/public/vendor/christian-riesen/base32/src/Base32.php new file mode 100644 index 0000000..d71f135 --- /dev/null +++ b/public/vendor/christian-riesen/base32/src/Base32.php @@ -0,0 +1,168 @@ + + * @author Sam Williams + * + * @see http://christianriesen.com + * + * @license MIT License see LICENSE file + */ +class Base32 +{ + /** + * Alphabet for encoding and decoding base32. + * + * @var string + */ + protected const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567='; + + protected const BASE32HEX_PATTERN = '/[^A-Z2-7]/'; + + /** + * Maps the Base32 character to its corresponding bit value. + */ + protected const MAPPING = [ + '=' => 0b00000, + 'A' => 0b00000, + 'B' => 0b00001, + 'C' => 0b00010, + 'D' => 0b00011, + 'E' => 0b00100, + 'F' => 0b00101, + 'G' => 0b00110, + 'H' => 0b00111, + 'I' => 0b01000, + 'J' => 0b01001, + 'K' => 0b01010, + 'L' => 0b01011, + 'M' => 0b01100, + 'N' => 0b01101, + 'O' => 0b01110, + 'P' => 0b01111, + 'Q' => 0b10000, + 'R' => 0b10001, + 'S' => 0b10010, + 'T' => 0b10011, + 'U' => 0b10100, + 'V' => 0b10101, + 'W' => 0b10110, + 'X' => 0b10111, + 'Y' => 0b11000, + 'Z' => 0b11001, + '2' => 0b11010, + '3' => 0b11011, + '4' => 0b11100, + '5' => 0b11101, + '6' => 0b11110, + '7' => 0b11111, + ]; + + /** + * Encodes into base32. + * + * @param string $string Clear text string + * + * @return string Base32 encoded string + */ + public static function encode(string $string): string + { + // Empty string results in empty string + if ('' === $string) { + return ''; + } + + $encoded = ''; + + //Set the initial values + $n = $bitLen = $val = 0; + $len = \strlen($string); + + //Pad the end of the string - this ensures that there are enough zeros + $string .= \str_repeat(\chr(0), 4); + + //Explode string into integers + $chars = (array) \unpack('C*', $string, 0); + + while ($n < $len || 0 !== $bitLen) { + //If the bit length has fallen below 5, shift left 8 and add the next character. + if ($bitLen < 5) { + $val = $val << 8; + $bitLen += 8; + $n++; + $val += $chars[$n]; + } + $shift = $bitLen - 5; + $encoded .= ($n - (int)($bitLen > 8) > $len && 0 == $val) ? '=' : static::ALPHABET[$val >> $shift]; + $val = $val & ((1 << $shift) - 1); + $bitLen -= 5; + } + + return $encoded; + } + + /** + * Decodes base32. + * + * @param string $base32String Base32 encoded string + * + * @return string Clear text string + */ + public static function decode(string $base32String): string + { + // Only work in upper cases + $base32String = \strtoupper($base32String); + + // Remove anything that is not base32 alphabet + $base32String = \preg_replace(static::BASE32HEX_PATTERN, '', $base32String); + + // Empty string results in empty string + if ('' === $base32String || null === $base32String) { + return ''; + } + + $decoded = ''; + + //Set the initial values + $len = \strlen($base32String); + $n = 0; + $bitLen = 5; + $val = static::MAPPING[$base32String[0]]; + + while ($n < $len) { + //If the bit length has fallen below 8, shift left 5 and add the next pentet. + if ($bitLen < 8) { + $val = $val << 5; + $bitLen += 5; + $n++; + $pentet = $base32String[$n] ?? '='; + + //If the new pentet is padding, make this the last iteration. + if ('=' === $pentet) { + $n = $len; + } + $val += static::MAPPING[$pentet]; + continue; + } + $shift = $bitLen - 8; + + $decoded .= \chr($val >> $shift); + $val = $val & ((1 << $shift) - 1); + $bitLen -= 8; + } + + return $decoded; + } +} diff --git a/public/vendor/christian-riesen/base32/src/Base32Hex.php b/public/vendor/christian-riesen/base32/src/Base32Hex.php new file mode 100644 index 0000000..0830910 --- /dev/null +++ b/public/vendor/christian-riesen/base32/src/Base32Hex.php @@ -0,0 +1,68 @@ + + * + * @see http://christianriesen.com + * + * @license MIT License see LICENSE file + */ +class Base32Hex extends Base32 +{ + /** + * Alphabet for encoding and decoding base32 extended hex. + * + * @var string + */ + protected const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUV='; + + protected const BASE32HEX_PATTERN = '/[^0-9A-V]/'; + + /** + * Maps the Base32 character to its corresponding bit value. + */ + protected const MAPPING = [ + '=' => 0b00000, + '0' => 0b00000, + '1' => 0b00001, + '2' => 0b00010, + '3' => 0b00011, + '4' => 0b00100, + '5' => 0b00101, + '6' => 0b00110, + '7' => 0b00111, + '8' => 0b01000, + '9' => 0b01001, + 'A' => 0b01010, + 'B' => 0b01011, + 'C' => 0b01100, + 'D' => 0b01101, + 'E' => 0b01110, + 'F' => 0b01111, + 'G' => 0b10000, + 'H' => 0b10001, + 'I' => 0b10010, + 'J' => 0b10011, + 'K' => 0b10100, + 'L' => 0b10101, + 'M' => 0b10110, + 'N' => 0b10111, + 'O' => 0b11000, + 'P' => 0b11001, + 'Q' => 0b11010, + 'R' => 0b11011, + 'S' => 0b11100, + 'T' => 0b11101, + 'U' => 0b11110, + 'V' => 0b11111, + ]; +} diff --git a/public/vendor/claviska/simpleimage/.editorconfig b/public/vendor/claviska/simpleimage/.editorconfig new file mode 100644 index 0000000..3c44241 --- /dev/null +++ b/public/vendor/claviska/simpleimage/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/public/vendor/claviska/simpleimage/LICENSE.md b/public/vendor/claviska/simpleimage/LICENSE.md new file mode 100644 index 0000000..39ebd3b --- /dev/null +++ b/public/vendor/claviska/simpleimage/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2017 A Beautiful Site, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/public/vendor/claviska/simpleimage/README.md b/public/vendor/claviska/simpleimage/README.md new file mode 100644 index 0000000..791cdfd --- /dev/null +++ b/public/vendor/claviska/simpleimage/README.md @@ -0,0 +1,865 @@ +# SimpleImage + +A PHP class that makes working with images as simple as possible. + +Developed and maintained by [Cory LaViska](https://github.com/claviska). + +_If this project has you loving PHP image manipulation again, please consider [sponsoring me](https://github.com/sponsors/claviska) to support its development._ + +--- + +## Overview + +```php +fromFile('image.jpg') // load image.jpg + ->autoOrient() // adjust orientation based on exif data + ->resize(320, 200) // resize to 320x200 pixels + ->flip('x') // flip horizontally + ->colorize('DarkBlue') // tint dark blue + ->border('black', 10) // add a 10 pixel black border + ->overlay('watermark.png', 'bottom right') // add a watermark image + ->toFile('new-image.png', 'image/png') // convert to PNG and save a copy to new-image.png + ->toScreen(); // output to the screen + + // And much more! 💪 +} catch(Exception $err) { + // Handle errors + echo $err->getMessage(); +} +``` + +## Requirements + +- PHP 8.0+ +- [GD extension](http://php.net/manual/en/book.image.php) + +## Features + +- Supports reading, writing, and converting GIF, JPEG, PNG, WEBP, BMP, AVIF formats. +- Reads and writes files, data URIs, and image strings. +- Manipulation: crop, resize, overlay/watermark, adding TTF text +- Drawing: arc, border, dot, ellipse, line, polygon, rectangle, rounded rectangle +- Filters: blur, brighten, colorize, contrast, darken, desaturate, edge detect, emboss, invert, opacity, pixelate, sepia, sharpen, sketch +- Utilities: color adjustment, darken/lighten color, extract colors +- Properties: exif data, height/width, mime type, orientation +- Color arguments can be passed in as any CSS color (e.g. `LightBlue`), a hex color, or an RGB(A) array. +- Support for alpha-transparency (GIF, PNG, WEBP, AVIF) +- Chainable methods +- Uses exceptions +- Load with Composer or manually (just one file) +- [Semantic Versioning](http://semver.org/) + +## Installation + +Install with Composer: + +``` +composer require claviska/simpleimage +``` + +Or include the library manually: + +```php +toFile($file, 'image/avif', [ + // JPG, WEBP, AVIF (default 100) + 'quality' => 100, + + // AVIF (default -1 which is 6) + // range of slow and small file 0 to 10 fast but big file + 'speed' => -1, +]); +``` + +```php +$image->toFile($file, 'image/bmp', [ + // BMP: boolean (default true) + 'compression' => true, + + // BMP, JPG (default null, keep the same) + 'interlace' => null, +]); +``` + +```php +$image->toFile($file, 'image/gif', [ + // GIF, PNG (default true) + 'alpha' => true, +]); +``` + +```php +$image->toFile($file, 'image/jpeg', [ + // BMP, JPG (default null, keep the same) + 'interlace' => null, + + // JPG, WEBP, AVIF (default 100) + 'quality' => 100, +]); +``` + +```php +$image->toFile($file, 'image/png', [ + // GIF, PNG (default true) + 'alpha' => true, + + // PNG: 0-10, defaults to zlib (default 6) + 'compression' => -1, + + // PNG (default -1) + 'filters' => -1, + + // has no effect on PNG images, since the format is lossless + // 'quality' => 100, +]); +``` + +```php +$image->toFile($file, 'image/webp', [ + // JPG, WEBP, AVIF (default 100) + 'quality' => 100, +]); +``` + +### Utilities + +#### `getAspectRatio()` + +Gets the image's current aspect ratio. + +Returns the aspect ratio as a float. + +#### `getExif()` + +Gets the image's exif data. + +Returns an array of exif data or null if no data is available. + +#### `getHeight()` + +Gets the image's current height. + +Returns the height as an integer. + +#### `getMimeType()` + +Gets the mime type of the loaded image. + +Returns a mime type string. + +#### `getOrientation()` + +Gets the image's current orientation. + +Returns a string: 'landscape', 'portrait', or 'square' + +#### `getResolution()` + +Gets the image's current resolution in DPI. + +Returns an array of integers: [0 => 96, 1 => 96] + +#### `getWidth()` + +Gets the image's current width. + +Returns the width as an integer. + +#### `hasImage()` + +Checks if the SimpleImage object has loaded an image. + +Returns a boolean. + +#### `reset()` + +Destroys the image resource. + +Returns a SimpleImage object. + +### Manipulation + +#### `autoOrient()` + +Rotates an image so the orientation will be correct based on its exif data. It is safe to call this method on images that don't have exif data (no changes will be made). +Returns a SimpleImage object. + +#### `bestFit($maxWidth, $maxHeight)` + +Proportionally resize the image to fit inside a specific width and height. + +- `$maxWidth`* (int) - The maximum width the image can be. +- `$maxHeight`* (int) - The maximum height the image can be. + +Returns a SimpleImage object. + +#### `crop($x1, $y1, $x2, $y2)` + +Crop the image. + +- $x1 - Top left x coordinate. +- $y1 - Top left y coordinate. +- $x2 - Bottom right x coordinate. +- $y2 - Bottom right x coordinate. + +Returns a SimpleImage object. + +#### `fitToHeight($height)` (DEPRECATED) + +Proportionally resize the image to a specific height. + +_This method was deprecated in version 3.2.2 and will be removed in version 4.0. Please use `resize(null, $height)` instead._ + +- `$height`* (int) - The height to resize the image to. + +Returns a SimpleImage object. + +#### `fitToWidth($width)` (DEPRECATED) + +Proportionally resize the image to a specific width. + +_This method was deprecated in version 3.2.2 and will be removed in version 4.0. Please use `resize($width, null)` instead._ + +- `$width`* (int) - The width to resize the image to. + +Returns a SimpleImage object. + +#### `flip($direction)` + +Flip the image horizontally or vertically. + +- `$direction`* (string) - The direction to flip: x|y|both + +Returns a SimpleImage object. + +#### `maxColors($max, $dither)` + +Reduces the image to a maximum number of colors. + +- `$max`* (int) - The maximum number of colors to use. +- `$dither` (bool) - Whether or not to use a dithering effect (default true). + +Returns a SimpleImage object. + +#### `overlay($overlay, $anchor, $opacity, $xOffset, $yOffset)` + +Place an image on top of the current image. + +- `$overlay`* (string|SimpleImage) - The image to overlay. This can be a filename, a data URI, or a SimpleImage object. +- `$anchor` (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center') +- `$opacity` (float) - The opacity level of the overlay 0-1 (default 1). +- `$xOffset` (int) - Horizontal offset in pixels (default 0). +- `$yOffset` (int) - Vertical offset in pixels (default 0). +- `$calculateOffsetFromEdge` (bool) - Calculate Offset referring to the edges of the image. $xOffset and $yOffset have no effect in center anchor. (default false). + +Returns a SimpleImage object. + +#### `resize($width, $height)` + +Resize an image to the specified dimensions. If only one dimension is specified, the image will be resized proportionally. + +- `$width`* (int) - The new image width. +- `$height`* (int) - The new image height. + +Returns a SimpleImage object. + +#### `resolution($res_x, $res_y)` + +Changes the resolution (DPI) of an image. + +- `$res_x`* (int) - The horizontal resolution, in DPI. +- `$res_y` (int) - The vertical resolution, in DPI. + +Returns a SimpleImage object. + +#### `rotate($angle, $backgroundColor)` + +Rotates the image. + +- `$angle`* (int) - The angle of rotation (-360 - 360). +- `$backgroundColor` (string|array) - The background color to use for the uncovered zone area after rotation (default 'transparent'). + +Returns a SimpleImage object. + +#### `text($text, $options, &$boundary)` + +Adds text to the image. + +- `$text*` (string) - The desired text. +- `$options` (array) - An array of options. + - `fontFile`* (string) - The TrueType (or compatible) font file to use. + - `size` (int) - The size of the font in pixels (default 12). + - `color` (string|array) - The text color (default black). + - `anchor` (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', + 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). + - `xOffset` (int) - The horizontal offset in pixels (default 0). + - `yOffset` (int) - The vertical offset in pixels (default 0). + - `shadow` (array) - Text shadow params. + - `x`* (int) - Horizontal offset in pixels. + - `y`* (int) - Vertical offset in pixels. + - `color`* (string|array) - The text shadow color. + - `calculateOffsetFromEdge` (bool) - Calculate Offset referring to the edges of the image (default false). + - `baselineAlign` (bool) - Align the text font with the baseline. (default true). +- `$boundary` (array) - If passed, this variable will contain an array with coordinates that + surround the text: [x1, y1, x2, y2, width, height]. This can be used for calculating the + text's position after it gets added to the image. + +Returns a SimpleImage object. + +#### `thumbnail($width, $height, $anchor)` + +Creates a thumbnail image. This function attempts to get the image as close to the provided dimensions as possible, then crops the remaining overflow to force the desired size. Useful for generating thumbnail images. + +- `$width`* (int) - The thumbnail width. +- `$height`* (int) - The thumbnail height. +- `$anchor` (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). + +Returns a SimpleImage object. + +### Drawing + +#### `arc($x, $y, $width, $height, $start, $end, $color, $thickness)` + +Draws an arc. + +- `$x`* (int) - The x coordinate of the arc's center. +- `$y`* (int) - The y coordinate of the arc's center. +- `$width`* (int) - The width of the arc. +- `$height`* (int) - The height of the arc. +- `$start`* (int) - The start of the arc in degrees. +- `$end`* (int) - The end of the arc in degrees. +- `$color`* (string|array) - The arc color. +- `$thickness` (int|string) - Line thickness in pixels or 'filled' (default 1). + +Returns a SimpleImage object. + +#### `border($color, $thickness)` + +Draws a border around the image. + +- `$color`* (string|array) - The border color. +- `$thickness` (int) - The thickness of the border (default 1). + +Returns a SimpleImage object. + +#### `dot($x, $y, $color)` + +Draws a single pixel dot. + +- `$x`* (int) - The x coordinate of the dot. +- `$y`* (int) - The y coordinate of the dot. +- `$color`* (string|array) - The dot color. + +Returns a SimpleImage object. + +#### `ellipse($x, $y, $width, $height, $color, $thickness)` + +Draws an ellipse. + +- `$x`* (int) - The x coordinate of the center. +- `$y`* (int) - The y coordinate of the center. +- `$width`* (int) - The ellipse width. +- `$height`* (int) - The ellipse height. +- `$color`* (string|array) - The ellipse color. +- `$thickness` (int|string) - Line thickness in pixels or 'filled' (default 1). + +Returns a SimpleImage object. + +#### `fill($color)` + +Fills the image with a solid color. + +- `$color` (string|array) - The fill color. + +Returns a SimpleImage object. + +#### `line($x1, $y1, $x2, $y2, $color, $thickness)` + +Draws a line. + +- `$x1`* (int) - The x coordinate for the first point. +- `$y1`* (int) - The y coordinate for the first point. +- `$x2`* (int) - The x coordinate for the second point. +- `$y2`* (int) - The y coordinate for the second point. +- `$color` (string|array) - The line color. +- `$thickness` (int) - The line thickness (default 1). + +Returns a SimpleImage object. + +#### `polygon($vertices, $color, $thickness)` + +Draws a polygon. + +- `$vertices`* (array) - The polygon's vertices in an array of x/y arrays. Example: + ``` + [ + ['x' => x1, 'y' => y1], + ['x' => x2, 'y' => y2], + ['x' => xN, 'y' => yN] + ] + ``` +- `$color`* (string|array) - The polygon color. +- `$thickness` (int|string) - Line thickness in pixels or 'filled' (default 1). + +Returns a SimpleImage object. + +#### `rectangle($x1, $y1, $x2, $y2, $color, $thickness)` + +Draws a rectangle. + +- `$x1`* (int) - The upper left x coordinate. +- `$y1`* (int) - The upper left y coordinate. +- `$x2`* (int) - The bottom right x coordinate. +- `$y2`* (int) - The bottom right y coordinate. +- `$color`* (string|array) - The rectangle color. +- `$thickness` (int|string) - Line thickness in pixels or 'filled' (default 1). + +Returns a SimpleImage object. + +#### `roundedRectangle($x1, $y1, $x2, $y2, $radius, $color, $thickness)` + +Draws a rounded rectangle. + +- `$x1`* (int) - The upper left x coordinate. +- `$y1`* (int) - The upper left y coordinate. +- `$x2`* (int) - The bottom right x coordinate. +- `$y2`* (int) - The bottom right y coordinate. +- `$radius`* (int) - The border radius in pixels. +- `$color`* (string|array) - The rectangle color. +- `$thickness` (int|string) - Line thickness in pixels or 'filled' (default 1). + +Returns a SimpleImage object. + +### Filters + +#### `blur($type, $passes)` + +Applies the blur filter. + +- `$type` (string) - The blur algorithm to use: 'selective', 'gaussian' (default 'gaussian'). +- `$passes` (int) - The number of time to apply the filter, enhancing the effect (default 1). + +Returns a SimpleImage object. + +#### `brighten($percentage)` + +Applies the brightness filter to brighten the image. + +- `$percentage`* (int) - Percentage to brighten the image (0 - 100). + +Returns a SimpleImage object. + +#### `colorize($color)` + +Applies the colorize filter. + +- `$color`* (string|array) - The filter color. + +Returns a SimpleImage object. + +#### `contrast($percentage)` + +Applies the contrast filter. + +- `$percentage`* (int) - Percentage to adjust (-100 - 100). + +Returns a SimpleImage object. + +#### `darken($percentage)` + +Applies the brightness filter to darken the image. + +- `$percentage`* (int) - Percentage to darken the image (0 - 100). + +Returns a SimpleImage object. + +#### `desaturate()` + +Applies the desaturate (grayscale) filter. + +Returns a SimpleImage object. + +#### `duotone($lightColor, $darkColor)` + +Applies the duotone filter to the image. + +- `$lightColor`* (string|array) - The lightest color in the duotone. +- `$darkColor`* (string|array) - The darkest color in the duotone. + +Returns a SimpleImage object. + +#### `edgeDetect()` + +Applies the edge detect filter. + +Returns a SimpleImage object. + +#### `emboss()` + +Applies the emboss filter. + +Returns a SimpleImage object. + +#### `invert()` + +Inverts the image's colors. + +Returns a SimpleImage object. + +#### `opacity()` + +Changes the image's opacity level. + +- `$opacity`* (float) - The desired opacity level (0 - 1). + +Returns a SimpleImage object. + +#### `pixelate($size)` + +Applies the pixelate filter. + +- `$size` (int) - The size of the blocks in pixels (default 10). + +Returns a SimpleImage object. + +#### `sepia()` + +Simulates a sepia effect by desaturating the image and applying a sepia tone. + +Returns a SimpleImage object. + +#### `sharpen($amount)` + +Sharpens the image. + +- `$amount` (int) - Sharpening amount (1 - 100, default 50) + +Returns a SimpleImage object. + +#### `sketch()` + +Applies the mean remove filter to produce a sketch effect. + +Returns a SimpleImage object. + +### Color utilities + +#### `(static) adjustColor($color, $red, $green, $blue, $alpha)` + +Adjusts a color by increasing/decreasing red/green/blue/alpha values independently. + +- `$color`* (string|array) - The color to adjust. +- `$red`* (int) - Red adjustment (-255 - 255). +- `$green`* (int) - Green adjustment (-255 - 255). +- `$blue`* (int) - Blue adjustment (-255 - 255). +- `$alpha`* (float) - Alpha adjustment (-1 - 1). + +Returns an RGBA color array. + +#### `(static) darkenColor($color, $amount)` + +Darkens a color. + +- `$color`* (string|array) - The color to darken. +- `$amount`* (int) - Amount to darken (0 - 255). + +Returns an RGBA color array. + +#### `extractColors($count = 10, $backgroundColor = null)` + +Extracts colors from an image like a human would do.™ This method requires the third-party library \League\ColorExtractor. If you're using Composer, it will be installed for you automatically. + +- `$count` (int) - The max number of colors to extract (default 5). +- `$backgroundColor` (string|array) - By default any pixel with alpha value greater than zero will be discarded. This is because transparent colors are not perceived as is. For example, fully transparent black would be seen white on a white background. So if you want to take transparency into account, you have to specify a default background color. + +Returns an array of RGBA colors arrays. + +#### `getColorAt($x, $y)` + +Gets the RGBA value of a single pixel. + +- `$x`* (int) - The horizontal position of the pixel. +- `$y`* (int) - The vertical position of the pixel. + +Returns an RGBA color array or false if the x/y position is off the canvas. + +#### `(static) lightenColor($color, $amount)` + +Lightens a color. + +- `$color`* (string|array) - The color to lighten. +- `$amount`* (int) - Amount to darken (0 - 255). + +Returns an RGBA color array. + +#### `(static) normalizeColor($color)` + +Normalizes a hex or array color value to a well-formatted RGBA array. + +- `$color`* (string|array) - A CSS color name, hex string, or an array [red, green, blue, alpha]. + +You can pipe alpha transparency through hex strings and color names. For example: + + #fff|0.50 <-- 50% white + red|0.25 <-- 25% red + +Returns an array: [red, green, blue, alpha] + +### Exceptions + +SimpleImage throws standard exceptions when things go wrong. You should always use a try/catch block around your code to properly handle them. + +```php +getMessage(); +} +``` + +To check for specific errors, compare `$err->getCode()` to the defined error constants. + +```php +getCode() === $image::ERR_FILE_NOT_FOUND) { + echo 'File not found!'; + } else { + echo $err->getMessage(); + } +} +``` + +As a best practice, always use the defined constants instead of their integers values. The values will likely change in future versions, and WILL NOT be considered a breaking change. + +- `ERR_FILE_NOT_FOUND` - The specified file could not be found or loaded for some reason. +- `ERR_FONT_FILE` - The specified font file could not be loaded. +- `ERR_FREETYPE_NOT_ENABLED` - Freetype support is not enabled in your version of PHP. +- `ERR_GD_NOT_ENABLED` - The GD extension is not enabled in your version of PHP. +- `ERR_LIB_NOT_LOADED` - A required library has not been loaded. +- `ERR_INVALID_COLOR` - An invalid color value was passed as an argument. +- `ERR_INVALID_DATA_URI` - The specified data URI is not valid. +- `ERR_INVALID_IMAGE` - The specified image is not valid. +- `ERR_UNSUPPORTED_FORMAT` - The image format specified is not valid. +- `ERR_WEBP_NOT_ENABLED` - WEBP support is not enabled in your version of PHP. +- `ERR_WRITE` - Unable to write to the file system. +- `ERR_INVALID_FLAG` - The specified flag key does not exist. + +### Useful Things To Know + +- Color arguments can be a CSS color name (e.g. `LightBlue`), a hex color string (e.g. `#0099dd`), or an RGB(A) array (e.g. `['red' => 255, 'green' => 0, 'blue' => 0, 'alpha' => 1]`). + +- When `$thickness` > 1, GD draws lines of the desired thickness from the center origin. For example, a rectangle drawn at [10, 10, 20, 20] with a thickness of 3 will actually be draw at [9, 9, 21, 21]. This is true for all shapes and is not a bug in the SimpleImage library. + +### Instance flags + +Tweak the behavior of a SimpleImage instance by setting instance flag values with the `setFlag($key, $value)` method. + +```php +$image = new \claviska\SimpleImage('image.jpeg')->setFlag("foo", "bar"); +``` + +You can also pass an associative array to the SimpleImage constructor to set instance flags. + +```php +$image = new \claviska\SimpleImage('image.jpeg', ['foo' => 'bar']); +// .. or without an $image +$image = new \claviska\SimpleImage(flags: ['foo' => 'bar']); +``` + +*Note: `setFlag()` throws an `ERR_INVALID_FLAG` exception if the key does not exist (no default value).* + +#### `sslVerify` + +Setting `sslVerify` to `false` (defaults to `true`) will make all images loaded over HTTPS forgo certificate peer validation. This is especially usefull for self-signed certificates. + +```php +$image = new \claviska\SimpleImage('https://localhost/image.jpeg', ['sslVerify' => false]); +// Would normally throw an OpenSSL exception, but is ignored with the sslVerify flag set to false. +``` + +## Differences from SimpleImage 2.x + +- Normalized color arguments (colors can be a CSS color name, hex color, or RGB(A) array). +- Normalized alpha (opacity) arguments: 0 (transparent) - 1 (opaque) +- Added text shadow to `text` method. +- Added `fromString()` method to load images from strings. +- Added `toString()` method to generate image strings. +- Added `arc` method for drawing arcs. +- Added `border` method for drawing borders. +- Added `dot` method for drawing individual pixels. +- Added `ellipse` method for drawing ellipses and circles. +- Added `line` method for drawing lines. +- Added `polygon` method for drawing polygons. +- Added `rectangle` method for drawing rectangles. +- Added `roundedRectangle` method for drawing rounded rectangles. +- Added `adjustColor` method for modifying RGBA color channels to create relative color variations. +- Added `darkenColor` method to darken a color. +- Added `extractColors` method to get the most common colors from the image. +- Added `getColorAt` method to get the RGBA values of a specific pixel. +- Added `lightenColor` method to lighten a color. +- Added `toDownload` method to force the image to download on the client's machine. +- Added `duotone` filter to create duotone images. +- Added `sharpen` method to sharpen the image. +- Changed namespace from `abeautifulsite` to `claviska`. +- Changed `create` method to `fromNew`. +- Changed `load` method to `fromFile`. +- Changed `load_base64` method to `fromDataUri`. +- Changed `output` method to `toScreen`.x +- Changed `output_base64` method to `toDataUri`. +- Changed `save` method to `toFile`. +- Changed `text` method to accept an array of options instead of tons of arguments. +- Removed text stroke from `text` method because it produced dirty results and didn't support transparency. +- Removed `smooth` method because its arguments in the PHP manual aren't documented well. +- Removed deprecated method `adaptive_resize` (use `thumbnail` instead). +- Removed `get_meta_data` (use `getExif`, `getHeight`, `getMime`, `getOrientation`, and `getWidth` instead). +- Added [.editorconfig](http://editorconfig.org/) file. Please make sure your editor supports these settings before submitting contributions. +- Switched from four spaces to two for indentations (sorry PHP-FIG!). +- Switched from underscore_methods to camelCaseMethods. +- Organized methods into groups based on function +- Removed PHPDoc comments. At this time, I don't wish to incorporate them into the library. diff --git a/public/vendor/claviska/simpleimage/composer.json b/public/vendor/claviska/simpleimage/composer.json new file mode 100644 index 0000000..1a2a180 --- /dev/null +++ b/public/vendor/claviska/simpleimage/composer.json @@ -0,0 +1,26 @@ +{ + "name": "claviska/simpleimage", + "description": "A PHP class that makes working with images as simple as possible.", + "license": "MIT", + "require": { + "php": ">=8.0", + "ext-gd": "*", + "league/color-extractor": "0.4.*" + }, + "authors": [ + { + "name": "Cory LaViska", + "homepage": "http://www.abeautifulsite.net/", + "role": "Developer" + } + ], + "autoload": { + "psr-0": { + "claviska": "src/" + } + }, + "require-dev": { + "laravel/pint": "^1.5", + "phpstan/phpstan": "^1.10" + } +} diff --git a/public/vendor/claviska/simpleimage/composer.lock b/public/vendor/claviska/simpleimage/composer.lock new file mode 100644 index 0000000..d95667b --- /dev/null +++ b/public/vendor/claviska/simpleimage/composer.lock @@ -0,0 +1,209 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "eb94dc95686ec297093755af85d5e7dd", + "packages": [ + { + "name": "league/color-extractor", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/color-extractor.git", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/21fcac6249c5ef7d00eb83e128743ee6678fe505", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "php": "^7.3 || ^8.0" + }, + "replace": { + "matthecat/colorextractor": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\ColorExtractor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "description": "Extract colors from an image as a human would do.", + "homepage": "https://github.com/thephpleague/color-extractor", + "keywords": [ + "color", + "extract", + "human", + "image", + "palette" + ], + "support": { + "issues": "https://github.com/thephpleague/color-extractor/issues", + "source": "https://github.com/thephpleague/color-extractor/tree/0.4.0" + }, + "time": "2022-09-24T15:57:16+00:00" + } + ], + "packages-dev": [ + { + "name": "laravel/pint", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "e0a8cef58b74662f27355be9cdea0e726bbac362" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/e0a8cef58b74662f27355be9cdea0e726bbac362", + "reference": "e0a8cef58b74662f27355be9cdea0e726bbac362", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.14.4", + "illuminate/view": "^9.51.0", + "laravel-zero/framework": "^9.2.0", + "mockery/mockery": "^1.5.1", + "nunomaduro/larastan": "^2.4.0", + "nunomaduro/termwind": "^1.15.1", + "pestphp/pest": "^1.22.4" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2023-02-14T16:31:02+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.10.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "a2ffec7db373d8da4973d1d62add872db5cd22dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a2ffec7db373d8da4973d1d62add872db5cd22dd", + "reference": "a2ffec7db373d8da4973d1d62add872db5cd22dd", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan/issues", + "source": "https://github.com/phpstan/phpstan/tree/1.10.2" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2023-02-23T14:36:46+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.0", + "ext-gd": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/public/vendor/claviska/simpleimage/src/claviska/SimpleImage.php b/public/vendor/claviska/simpleimage/src/claviska/SimpleImage.php new file mode 100644 index 0000000..5c08c58 --- /dev/null +++ b/public/vendor/claviska/simpleimage/src/claviska/SimpleImage.php @@ -0,0 +1,2409 @@ +. +// +// Copyright A Beautiful Site, LLC. +// +// Source: https://github.com/claviska/SimpleImage +// +// Licensed under the MIT license +// + +namespace claviska; + +use Exception; +use GdImage; +use League\ColorExtractor\Color; +use League\ColorExtractor\ColorExtractor; +use League\ColorExtractor\Palette; + +/** + * A PHP class that makes working with images as simple as possible. + */ +class SimpleImage +{ + public const + ERR_FILE_NOT_FOUND = 1; + + public const + ERR_FONT_FILE = 2; + + public const + ERR_FREETYPE_NOT_ENABLED = 3; + + public const + ERR_GD_NOT_ENABLED = 4; + + public const + ERR_INVALID_COLOR = 5; + + public const + ERR_INVALID_DATA_URI = 6; + + public const + ERR_INVALID_IMAGE = 7; + + public const + ERR_LIB_NOT_LOADED = 8; + + public const + ERR_UNSUPPORTED_FORMAT = 9; + + public const + ERR_WEBP_NOT_ENABLED = 10; + + public const + ERR_WRITE = 11; + + public const + ERR_INVALID_FLAG = 12; + + protected array $flags; + + protected $image = null; + + protected string $mimeType; + + protected null|array|false $exif = null; + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Magic methods + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Creates a new SimpleImage object. + * + * @param string $image An image file or a data URI to load. + * @param array $flags Optional override of default flags. + * + * @throws Exception Thrown if the GD library is not found; file|URI or image data is invalid. + */ + public function __construct(string $image = '', array $flags = []) + { + // Check for the required GD extension + if (extension_loaded('gd')) { + // Ignore JPEG warnings that cause imagecreatefromjpeg() to fail + ini_set('gd.jpeg_ignore_warning', '1'); + } else { + throw new Exception('Required extension GD is not loaded.', self::ERR_GD_NOT_ENABLED); + } + + // Associative array of flags. + $this->flags = [ + 'sslVerify' => true, // Skip SSL peer validation + ]; + + // Override default flag values. + foreach ($flags as $flag => $value) { + $this->setFlag($flag, $value); + } + + // Load an image through the constructor + if (preg_match('/^data:(.*?);/', $image)) { + $this->fromDataUri($image); + } elseif ($image) { + $this->fromFile($image); + } + } + + /** + * Destroys the image resource. + */ + public function __destruct() + { + $this->reset(); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Helper functions + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Checks if the SimpleImage object has loaded an image. + */ + public function hasImage(): bool + { + return $this->image instanceof GdImage; + } + + /** + * Destroys the image resource. + */ + public function reset(): static + { + if ($this->hasImage()) { + imagedestroy($this->image); + } + + return $this; + } + + /** + * Set flag value. + * + * @param string $flag Name of the flag to set. + * @param bool $value State of the flag. + * + * @throws Exception Thrown if flag does not exist (no default value). + */ + public function setFlag(string $flag, bool $value): void + { + // Throw if flag does not exist + if (! in_array($flag, array_keys($this->flags))) { + throw new Exception('Invalid flag.', self::ERR_INVALID_FLAG); + } + + // Set flag value by name + $this->flags[$flag] = $value; + } + + /** + * Get flag value. + * + * @param string $flag Name of the flag to get. + */ + public function getFlag(string $flag): ?bool + { + return in_array($flag, array_keys($this->flags)) ? $this->flags[$flag] : null; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Loaders + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Loads an image from a data URI. + * + * @param string $uri A data URI. + * @return SimpleImage + * + * @throws Exception Thrown if URI or image data is invalid. + */ + public function fromDataUri(string $uri): static + { + // Basic formatting check + preg_match('/^data:(.*?);/', $uri, $matches); + if (! count($matches)) { + throw new Exception('Invalid data URI.', self::ERR_INVALID_DATA_URI); + } + + // Determine mime type + $this->mimeType = $matches[1]; + if (! preg_match('/^image\/(gif|jpeg|png)$/', $this->mimeType)) { + throw new Exception( + 'Unsupported format: '.$this->mimeType, + self::ERR_UNSUPPORTED_FORMAT + ); + } + + // Get image data + $uri = base64_decode(strval(preg_replace('/^data:(.*?);base64,/', '', $uri))); + $this->image = imagecreatefromstring($uri); + if (! $this->image) { + throw new Exception('Invalid image data.', self::ERR_INVALID_IMAGE); + } + + return $this; + } + + /** + * Loads an image from a file. + * + * @param string $file The image file to load. + * @return SimpleImage + * + * @throws Exception Thrown if file or image data is invalid. + */ + public function fromFile(string $file): static + { + // Set fopen options. + $sslVerify = $this->getFlag('sslVerify'); // Don't perform peer validation when true + $opts = [ + 'ssl' => [ + 'verify_peer' => $sslVerify, + 'verify_peer_name' => $sslVerify, + ], + ]; + + // Check if the file exists and is readable. + $file = @file_get_contents($file, false, stream_context_create($opts)); + if ($file === false) { + throw new Exception("File not found: $file", self::ERR_FILE_NOT_FOUND); + } + + // Create image object from string + $this->image = imagecreatefromstring($file); + + // Get image info + $info = @getimagesizefromstring($file); + if ($info === false) { + throw new Exception("Invalid image file: $file", self::ERR_INVALID_IMAGE); + } + $this->mimeType = $info['mime']; + + if (! $this->image) { + throw new Exception('Unsupported format: '.$this->mimeType, self::ERR_UNSUPPORTED_FORMAT); + } + + switch($this->mimeType) { + case 'image/gif': + // Copy the gif over to a true color image to preserve its transparency. This is a + // workaround to prevent imagepalettetotruecolor() from borking transparency. + $width = imagesx($this->image); + $height = imagesx($this->image); + + $gif = imagecreatetruecolor((int) $width, (int) $height); + $alpha = imagecolorallocatealpha($gif, 0, 0, 0, 127); + imagecolortransparent($gif, $alpha ?: null); + imagefill($gif, 0, 0, $alpha); + + imagecopy($this->image, $gif, 0, 0, 0, 0, $width, $height); + imagedestroy($gif); + break; + case 'image/jpeg': + // Load exif data from JPEG images + if (function_exists('exif_read_data')) { + $this->exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($file)); + } + break; + } + + // Convert pallete images to true color images + imagepalettetotruecolor($this->image); + + return $this; + } + + /** + * Creates a new image. + * + * @param int $width The width of the image. + * @param int $height The height of the image. + * @param string|array $color Optional fill color for the new image (default 'transparent'). + * @return SimpleImage + * + * @throws Exception + */ + public function fromNew(int $width, int $height, string|array $color = 'transparent'): static + { + $this->image = imagecreatetruecolor($width, $height); + + // Use PNG for dynamically created images because it's lossless and supports transparency + $this->mimeType = 'image/png'; + + // Fill the image with color + $this->fill($color); + + return $this; + } + + /** + * Creates a new image from a string. + * + * @param string $string The raw image data as a string. + * @return SimpleImage + * + * @throws Exception + * + * @example + * $string = file_get_contents('image.jpg'); + */ + public function fromString(string $string): SimpleImage|static + { + return $this->fromFile('data://;base64,'.base64_encode($string)); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Savers + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Generates an image. + * + * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). + * @param array|int $options Array or Image quality as a percentage (default 100). + * @return array Returns an array containing the image data and mime type ['data' => '', 'mimeType' => '']. + * + * @throws Exception Thrown when WEBP support is not enabled or unsupported format. + */ + public function generate(string|null $mimeType = null, array|int $options = 100): array + { + // Format defaults to the original mime type + $mimeType = $mimeType ?: $this->mimeType; + + $quality = null; + // allow $options to be an int for backwards compatibility to v3 + if (is_int($options)) { + $quality = $options; + $options = []; + } + + // get quality if passed as an option + if (is_array($options) && array_key_exists('quality', $options)) { + $quality = intval($options['quality']); + } + + // Ensure quality is a valid integer + if ($quality === null) { + $quality = 100; + } + $quality = (int) round(self::keepWithin((int) $quality, 0, 100)); + + $alpha = true; + // get alpha if passed as an option + if (is_array($options) && array_key_exists('alpha', $options)) { + $alpha = boolval($options['alpha']); + } + + $interlace = null; // keep the same + // get interlace if passed as an option + if (is_array($options) && array_key_exists('interlace', $options)) { + $interlace = boolval($options['interlace']); + } + + // get raw stream from image* functions in providing no path + $file = null; + + // Capture output + ob_start(); + + // Generate the image + switch($mimeType) { + case 'image/gif': + imagesavealpha($this->image, $alpha); + imagegif($this->image, $file); + break; + case 'image/jpeg': + imageinterlace($this->image, $interlace); + imagejpeg($this->image, $file, $quality); + break; + case 'image/png': + $filters = -1; // imagepng default + // get filters if passed as an option + if (is_array($options) && array_key_exists('filters', $options)) { + $filters = intval($options['filters']); + } + // compression param is called quality in imagepng but that would be + // misleading in context of SimpleImage + $compression = -1; // defaults to zlib default which is 6 + // get compression if passed as an option + if (is_array($options) && array_key_exists('compression', $options)) { + $compression = intval($options['compression']); + } + if ($compression !== -1) { + $compression = (int) round(self::keepWithin($compression, 0, 10)); + } + imagesavealpha($this->image, $alpha); + imagepng($this->image, $file, $compression, $filters); + break; + case 'image/webp': + // Not all versions of PHP will have webp support enabled + if (! function_exists('imagewebp')) { + throw new Exception( + 'WEBP support is not enabled in your version of PHP.', + self::ERR_WEBP_NOT_ENABLED + ); + } + // useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php + imagesavealpha($this->image, $alpha); + imagewebp($this->image, $file, $quality); + break; + case 'image/bmp': + case 'image/x-ms-bmp': + case 'image/x-windows-bmp': + // Not all versions of PHP support bmp + if (! function_exists('imagebmp')) { + throw new Exception( + 'BMP support is not available in your version of PHP.', + self::ERR_UNSUPPORTED_FORMAT + ); + } + $compression = true; // imagebmp default + // get compression if passed as an option + if (is_array($options) && array_key_exists('compression', $options)) { + $compression = is_int($options['compression']) ? + $options['compression'] > 0 : boolval($options['compression']); + } + imageinterlace($this->image, $interlace); + imagebmp($this->image, $file, $compression); + break; + case 'image/avif': + // Not all versions of PHP support avif + if (! function_exists('imageavif')) { + throw new Exception( + 'AVIF support is not available in your version of PHP.', + self::ERR_UNSUPPORTED_FORMAT + ); + } + $speed = -1; // imageavif default + // get speed if passed as an option + if (is_array($options) && array_key_exists('speed', $options)) { + $speed = intval($options['speed']); + $speed = self::keepWithin($speed, 0, 10); + } + // useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php + imagesavealpha($this->image, $alpha); + imageavif($this->image, $file, $quality, $speed); + break; + default: + throw new Exception('Unsupported format: '.$mimeType, self::ERR_UNSUPPORTED_FORMAT); + } + + // Stop capturing + $data = ob_get_contents(); + ob_end_clean(); + + return [ + 'data' => $data, + 'mimeType' => $mimeType, + ]; + } + + /** + * Generates a data URI. + * + * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). + * @param array|int $options Array or Image quality as a percentage (default 100). + * @return string Returns a string containing a data URI. + * + * @throws Exception + */ + public function toDataUri(string|null $mimeType = null, array|int $options = 100): string + { + $image = $this->generate($mimeType, $options); + + return 'data:'.$image['mimeType'].';base64,'.base64_encode($image['data']); + } + + /** + * Forces the image to be downloaded to the clients machine. Must be called before any output is sent to the screen. + * + * @param string $filename The filename (without path) to send to the client (e.g. 'image.jpeg'). + * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). + * @param array|int $options Array or Image quality as a percentage (default 100). + * @return SimpleImage + * + * @throws Exception + */ + public function toDownload(string $filename, string|null $mimeType = null, array|int $options = 100): static + { + $image = $this->generate($mimeType, $options); + + // Set download headers + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Content-Description: File Transfer'); + header('Content-Length: '.strlen($image['data'])); + header('Content-Transfer-Encoding: Binary'); + header('Content-Type: application/octet-stream'); + header("Content-Disposition: attachment; filename=\"$filename\""); + + echo $image['data']; + + return $this; + } + + /** + * Writes the image to a file. + * + * @param string $file The image format to output as a mime type (defaults to the original mime type). + * @param string|null $mimeType Image quality as a percentage (default 100). + * @param array|int $options Array or Image quality as a percentage (default 100). + * @return SimpleImage + * + * @throws Exception Thrown if failed write to file. + */ + public function toFile(string $file, string|null $mimeType = null, array|int $options = 100): static + { + $image = $this->generate($mimeType, $options); + + // Save the image to file + if (! file_put_contents($file, $image['data'])) { + throw new Exception("Failed to write image to file: $file", self::ERR_WRITE); + } + + return $this; + } + + /** + * Outputs the image to the screen. Must be called before any output is sent to the screen. + * + * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). + * @param array|int $options Array or Image quality as a percentage (default 100). + * @return SimpleImage + * + * @throws Exception + */ + public function toScreen(string|null $mimeType = null, array|int $options = 100): static + { + $image = $this->generate($mimeType, $options); + + // Output the image to stdout + header('Content-Type: '.$image['mimeType']); + echo $image['data']; + + return $this; + } + + /** + * Generates an image string. + * + * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type). + * @param array|int $options Array or Image quality as a percentage (default 100). + * + * @throws Exception + */ + public function toString(string|null $mimeType = null, array|int $options = 100): string + { + return $this->generate($mimeType, $options)['data']; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Utilities + ////////////////////////////////////////////////////////////////////////////////////////////////// + /** + * Ensures a numeric value is always within the min and max range. + * + * @param int|float $value A numeric value to test. + * @param int|float $min The minimum allowed value. + * @param int|float $max The maximum allowed value. + */ + protected static function keepWithin(int|float $value, int|float $min, int|float $max): int|float + { + if ($value < $min) { + return $min; + } + if ($value > $max) { + return $max; + } + + return $value; + } + + /** + * Gets the image's current aspect ratio. + * + * @return float|int Returns the aspect ratio as a float. + */ + public function getAspectRatio(): float|int + { + return $this->getWidth() / $this->getHeight(); + } + + /** + * Gets the image's exif data. + * + * @return array|null Returns an array of exif data or null if no data is available. + */ + public function getExif(): ?array + { + // returns null if exif value is falsy: null, false or empty array. + return $this->exif ?: null; + } + + /** + * Gets the image's current height. + */ + public function getHeight(): int + { + return (int) imagesy($this->image); + } + + /** + * Gets the mime type of the loaded image. + */ + public function getMimeType(): string + { + return $this->mimeType; + } + + /** + * Gets the image's current orientation. + * + * @return string One of the values: 'landscape', 'portrait', or 'square' + */ + public function getOrientation(): string + { + $width = $this->getWidth(); + $height = $this->getHeight(); + + if ($width > $height) { + return 'landscape'; + } + if ($width < $height) { + return 'portrait'; + } + + return 'square'; + } + + /** + * Gets the resolution of the image + * + * @return array|bool The resolution as an array of integers: [96, 96] + */ + public function getResolution(): bool|array + { + return imageresolution($this->image); + } + + /** + * Gets the image's current width. + */ + public function getWidth(): int + { + return (int) imagesx($this->image); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Manipulation + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Same as PHP's imagecopymerge, but works with transparent images. Used internally for overlay. + * + * @param GdImage $dstIm Destination image. + * @param GdImage $srcIm Source image. + * @param int $dstX x-coordinate of destination point. + * @param int $dstY y-coordinate of destination point. + * @param int $srcX x-coordinate of source point. + * @param int $srcY y-coordinate of source point. + * @param int $srcW Source width. + * @param int $srcH Source height. + * @return bool true if success. + */ + protected static function imageCopyMergeAlpha(GdImage $dstIm, GdImage $srcIm, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct): bool + { + // Are we merging with transparency? + if ($pct < 100) { + // Disable alpha blending and "colorize" the image using a transparent color + imagealphablending($srcIm, false); + imagefilter($srcIm, IMG_FILTER_COLORIZE, 0, 0, 0, round(127 * ((100 - $pct) / 100))); + } + + imagecopy($dstIm, $srcIm, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH); + + return true; + } + + /** + * Rotates an image so the orientation will be correct based on its exif data. It is safe to call + * this method on images that don't have exif data (no changes will be made). + * + * @return SimpleImage + * + * @throws Exception + */ + public function autoOrient(): static + { + $exif = $this->getExif(); + + if (! $exif || ! isset($exif['Orientation'])) { + return $this; + } + + switch($exif['Orientation']) { + case 1: // Do nothing! + break; + case 2: // Flip horizontally + $this->flip('x'); + break; + case 3: // Rotate 180 degrees + $this->rotate(180); + break; + case 4: // Flip vertically + $this->flip('y'); + break; + case 5: // Rotate 90 degrees clockwise and flip vertically + $this->flip('y')->rotate(90); + break; + case 6: // Rotate 90 clockwise + $this->rotate(90); + break; + case 7: // Rotate 90 clockwise and flip horizontally + $this->flip('x')->rotate(90); + break; + case 8: // Rotate 90 counterclockwise + $this->rotate(-90); + break; + } + + return $this; + } + + /** + * Proportionally resize the image to fit inside a specific width and height. + * + * @param int $maxWidth The maximum width the image can be. + * @param int $maxHeight The maximum height the image can be. + * @return SimpleImage + */ + public function bestFit(int $maxWidth, int $maxHeight): static + { + // If the image already fits, there's nothing to do + if ($this->getWidth() <= $maxWidth && $this->getHeight() <= $maxHeight) { + return $this; + } + + // Calculate max width or height based on orientation + if ($this->getOrientation() === 'portrait') { + $height = $maxHeight; + $width = (int) round($maxHeight * $this->getAspectRatio()); + } else { + $width = $maxWidth; + $height = (int) round($maxWidth / $this->getAspectRatio()); + } + + // Reduce to max width + if ($width > $maxWidth) { + $width = $maxWidth; + $height = (int) round($width / $this->getAspectRatio()); + } + + // Reduce to max height + if ($height > $maxHeight) { + $height = $maxHeight; + $width = (int) round($height * $this->getAspectRatio()); + } + + return $this->resize($width, $height); + } + + /** + * Crop the image. + * + * @param int|float $x1 Top left x coordinate. + * @param int|float $y1 Top left y coordinate. + * @param int|float $x2 Bottom right x coordinate. + * @param int|float $y2 Bottom right x coordinate. + * @return SimpleImage + */ + public function crop(int|float $x1, int|float $y1, int|float $x2, int|float $y2): static + { + // Keep crop within image dimensions + $x1 = self::keepWithin($x1, 0, $this->getWidth()); + $x2 = self::keepWithin($x2, 0, $this->getWidth()); + $y1 = self::keepWithin($y1, 0, $this->getHeight()); + $y2 = self::keepWithin($y2, 0, $this->getHeight()); + + // Avoid using native imagecrop() because of a bug with PNG transparency + $dstW = abs($x2 - $x1); + $dstH = abs($y2 - $y1); + $newImage = imagecreatetruecolor((int) $dstW, (int) $dstH); + $transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127); + imagecolortransparent($newImage, $transparentColor ?: null); + imagefill($newImage, 0, 0, $transparentColor); + + // Crop it + imagecopyresampled( + $newImage, + $this->image, + 0, + 0, + (int) round(min($x1, $x2)), + (int) round(min($y1, $y2)), + (int) $dstW, + (int) $dstH, + (int) $dstW, + (int) $dstH + ); + + // Swap out the new image + $this->image = $newImage; + + return $this; + } + + /** + * Applies a duotone filter to the image. + * + * @param string|array $lightColor The lightest color in the duotone. + * @param string|array $darkColor The darkest color in the duotone. + * @return SimpleImage + * + * @throws Exception + */ + public function duotone(string|array $lightColor, string|array $darkColor): static + { + $lightColor = self::normalizeColor($lightColor); + $darkColor = self::normalizeColor($darkColor); + + // Calculate averages between light and dark colors + $redAvg = $lightColor['red'] - $darkColor['red']; + $greenAvg = $lightColor['green'] - $darkColor['green']; + $blueAvg = $lightColor['blue'] - $darkColor['blue']; + + // Create a matrix of all possible duotone colors based on gray values + $pixels = []; + for ($i = 0; $i <= 255; $i++) { + $grayAvg = $i / 255; + $pixels['red'][$i] = $darkColor['red'] + $grayAvg * $redAvg; + $pixels['green'][$i] = $darkColor['green'] + $grayAvg * $greenAvg; + $pixels['blue'][$i] = $darkColor['blue'] + $grayAvg * $blueAvg; + } + + // Apply the filter pixel by pixel + for ($x = 0; $x < $this->getWidth(); $x++) { + for ($y = 0; $y < $this->getHeight(); $y++) { + $rgb = $this->getColorAt($x, $y); + $gray = min(255, round(0.299 * $rgb['red'] + 0.114 * $rgb['blue'] + 0.587 * $rgb['green'])); + $this->dot($x, $y, [ + 'red' => $pixels['red'][$gray], + 'green' => $pixels['green'][$gray], + 'blue' => $pixels['blue'][$gray], + ]); + } + } + + return $this; + } + + /** + * Proportionally resize the image to a specific width. + * + * @param int $width The width to resize the image to. + * @return SimpleImage + * + *@deprecated + * This method was deprecated in version 3.2.2 and will be removed in version 4.0. + * Please use `resize(null, $height)` instead. + */ + public function fitToWidth(int $width): static + { + return $this->resize($width); + } + + /** + * Flip the image horizontally or vertically. + * + * @param string $direction The direction to flip: x|y|both. + * @return SimpleImage + */ + public function flip(string $direction): static + { + match ($direction) { + 'x' => imageflip($this->image, IMG_FLIP_HORIZONTAL), + 'y' => imageflip($this->image, IMG_FLIP_VERTICAL), + 'both' => imageflip($this->image, IMG_FLIP_BOTH), + default => $this, + }; + + return $this; + } + + /** + * Reduces the image to a maximum number of colors. + * + * @param int $max The maximum number of colors to use. + * @param bool $dither Whether or not to use a dithering effect (default true). + * @return SimpleImage + */ + public function maxColors(int $max, bool $dither = true): static + { + imagetruecolortopalette($this->image, $dither, max(1, $max)); + + return $this; + } + + /** + * Place an image on top of the current image. + * + * @param string|SimpleImage $overlay The image to overlay. This can be a filename, a data URI, or a SimpleImage object. + * @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). + * @param float|int $opacity The opacity level of the overlay 0-1 (default 1). + * @param int $xOffset Horizontal offset in pixels (default 0). + * @param int $yOffset Vertical offset in pixels (default 0). + * @param bool $calculateOffsetFromEdge Calculate Offset referring to the edges of the image (default false). + * @return SimpleImage + * + * @throws Exception + */ + public function overlay(string|SimpleImage $overlay, string $anchor = 'center', float|int $opacity = 1, int $xOffset = 0, int $yOffset = 0, bool $calculateOffsetFromEdge = false): static + { + // Load overlay image + if (! ($overlay instanceof SimpleImage)) { + $overlay = new SimpleImage($overlay); + } + + // Convert opacity + $opacity = (int) round(self::keepWithin($opacity, 0, 1) * 100); + + // Get available space + $spaceX = $this->getWidth() - $overlay->getWidth(); + $spaceY = $this->getHeight() - $overlay->getHeight(); + + // Set default center + $x = (int) round(($spaceX / 2) + ($calculateOffsetFromEdge ? 0 : $xOffset)); + $y = (int) round(($spaceY / 2) + ($calculateOffsetFromEdge ? 0 : $yOffset)); + + // Determine if top|bottom + if (str_contains($anchor, 'top')) { + $y = $yOffset; + } elseif (str_contains($anchor, 'bottom')) { + $y = $spaceY + ($calculateOffsetFromEdge ? -$yOffset : $yOffset); + } + + // Determine if left|right + if (str_contains($anchor, 'left')) { + $x = $xOffset; + } elseif (str_contains($anchor, 'right')) { + $x = $spaceX + ($calculateOffsetFromEdge ? -$xOffset : $xOffset); + } + + // Perform the overlay + self::imageCopyMergeAlpha( + $this->image, + $overlay->image, + $x, $y, + 0, 0, + $overlay->getWidth(), + $overlay->getHeight(), + $opacity + ); + + return $this; + } + + /** + * Resize an image to the specified dimensions. If only one dimension is specified, the image will be resized proportionally. + * + * @param int|null $width The new image width. + * @param int|null $height The new image height. + * @return SimpleImage + */ + public function resize(int|null $width = null, int|null $height = null): static + { + // No dimensions specified + if (! $width && ! $height) { + return $this; + } + + // Resize to width + if ($width && ! $height) { + $height = (int) round($width / $this->getAspectRatio()); + } + + // Resize to height + if (! $width && $height) { + $width = (int) round($height * $this->getAspectRatio()); + } + + // If the dimensions are the same, there's no need to resize + if ($this->getWidth() === $width && $this->getHeight() === $height) { + return $this; + } + + // We can't use imagescale because it doesn't seem to preserve transparency properly. The + // workaround is to create a new truecolor image, allocate a transparent color, and copy the + // image over to it using imagecopyresampled. + $newImage = imagecreatetruecolor($width, $height); + $transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127); + imagecolortransparent($newImage, $transparentColor); + imagefill($newImage, 0, 0, $transparentColor); + imagecopyresampled( + $newImage, + $this->image, + 0, 0, 0, 0, + $width, + $height, + $this->getWidth(), + $this->getHeight() + ); + + // Swap out the new image + $this->image = $newImage; + + return $this; + } + + /** + * Sets an image's resolution, as per https://www.php.net/manual/en/function.imageresolution.php + * + * @param int $res_x The horizontal resolution in DPI. + * @param int|null $res_y The vertical resolution in DPI + * @return SimpleImage + */ + public function resolution(int $res_x, int|null $res_y = null): static + { + if (is_null($res_y)) { + imageresolution($this->image, $res_x); + } else { + imageresolution($this->image, $res_x, $res_y); + } + + return $this; + } + + /** + * Rotates the image. + * + * @param int $angle The angle of rotation (-360 - 360). + * @param string|array $backgroundColor The background color to use for the uncovered zone area after rotation (default 'transparent'). + * @return SimpleImage + * + * @throws Exception + */ + public function rotate(int $angle, string|array $backgroundColor = 'transparent'): static + { + // Rotate the image on a canvas with the desired background color + $backgroundColor = $this->allocateColor($backgroundColor); + + $this->image = imagerotate( + $this->image, + -(self::keepWithin($angle, -360, 360)), + $backgroundColor + ); + imagecolortransparent($this->image, imagecolorallocatealpha($this->image, 0, 0, 0, 127)); + + return $this; + } + + /** + * Adds text to the image. + * + * @param string $text The desired text. + * @param array $options + * An array of options. + * - fontFile* (string) - The TrueType (or compatible) font file to use. + * - size (integer) - The size of the font in pixels (default 12). + * - color (string|array) - The text color (default black). + * - anchor (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). + * - xOffset (integer) - The horizontal offset in pixels (default 0). + * - yOffset (integer) - The vertical offset in pixels (default 0). + * - shadow (array) - Text shadow params. + * - x* (integer) - Horizontal offset in pixels. + * - y* (integer) - Vertical offset in pixels. + * - color* (string|array) - The text shadow color. + * - $calculateOffsetFromEdge (bool) - Calculate offsets from the edge of the image (default false). + * - $baselineAlign (bool) - Align the text font with the baseline. (default true). + * @param array|null $boundary + * If passed, this variable will contain an array with coordinates that surround the text: [x1, y1, x2, y2, width, height]. + * This can be used for calculating the text's position after it gets added to the image. + * @return SimpleImage + * + * @throws Exception + */ + public function text(string $text, array $options, array|null &$boundary = null): static + { + // Check for freetype support + if (! function_exists('imagettftext')) { + throw new Exception( + 'Freetype support is not enabled in your version of PHP.', + self::ERR_FREETYPE_NOT_ENABLED + ); + } + + // Default options + $options = array_merge([ + 'fontFile' => null, + 'size' => 12, + 'color' => 'black', + 'anchor' => 'center', + 'xOffset' => 0, + 'yOffset' => 0, + 'shadow' => null, + 'calculateOffsetFromEdge' => false, + 'baselineAlign' => true, + ], $options); + + // Extract and normalize options + $fontFile = $options['fontFile']; + $size = ($options['size'] / 96) * 72; // Convert px to pt (72pt per inch, 96px per inch) + $color = $this->allocateColor($options['color']); + $anchor = $options['anchor']; + $xOffset = $options['xOffset']; + $yOffset = $options['yOffset']; + $calculateOffsetFromEdge = $options['calculateOffsetFromEdge']; + $baselineAlign = $options['baselineAlign']; + $angle = 0; + + // Calculate the bounding box dimensions + // + // Since imagettfbox() returns a bounding box from the text's baseline, we can end up with + // different heights for different strings of the same font size. For example, 'type' will often + // be taller than 'text' because the former has a descending letter. + // + // To compensate for this, we created a temporary bounding box to measure the maximum height + // that the font used can occupy. Based on this, we can adjust the text vertically so that it + // appears inside the box with a good consistency. + // + // See: https://github.com/claviska/SimpleImage/issues/165 + // + + $boxText = imagettfbbox($size, $angle, $fontFile, $text); + if (! $boxText) { + throw new Exception("Unable to load font file: $fontFile", self::ERR_FONT_FILE); + } + + $boxWidth = abs($boxText[4] - $boxText[0]); + $boxHeight = abs($boxText[5] - $boxText[1]); + + // Calculate Offset referring to the edges of the image. + // Just invert the value for bottom|right; + if ($calculateOffsetFromEdge) { + if (str_contains($anchor, 'bottom')) { + $yOffset *= -1; + } + if (str_contains($anchor, 'right')) { + $xOffset *= -1; + } + } + + // Align the text font with the baseline. + // I use $yOffset to inject the vertical alignment correction value. + if ($baselineAlign) { + // Create a temporary box to obtain the maximum height that this font can use. + $boxFull = imagettfbbox($size, $angle, $fontFile, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'); + // Based on the maximum height, the text is aligned. + if (str_contains($anchor, 'bottom')) { + $yOffset -= $boxFull[1]; + } elseif (str_contains($anchor, 'top')) { + $yOffset += abs($boxFull[5]) - $boxHeight; + } else { // center + $boxFullHeight = abs($boxFull[1]) + abs($boxFull[5]); + $yOffset += ($boxFullHeight / 2) - ($boxHeight / 2) - abs($boxFull[1]); + } + } else { + // Prevents fonts rendered outside the box boundary from being cut. + // Example: 'Scriptina' font, some letters invade the space of the previous or subsequent letter. + $yOffset -= $boxText[1]; + } + + // Prevents fonts rendered outside the box boundary from being cut. + // Example: 'Scriptina' font, some letters invade the space of the previous or subsequent letter. + $xOffset -= $boxText[0]; + + // Determine position + switch($anchor) { + case 'top left': + $x = $xOffset; + $y = $yOffset + $boxHeight; + break; + case 'top right': + $x = $this->getWidth() - $boxWidth + $xOffset; + $y = $yOffset + $boxHeight; + break; + case 'top': + $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset; + $y = $yOffset + $boxHeight; + break; + case 'bottom left': + $x = $xOffset; + $y = $this->getHeight() + $yOffset; + break; + case 'bottom right': + $x = $this->getWidth() - $boxWidth + $xOffset; + $y = $this->getHeight() + $yOffset; + break; + case 'bottom': + $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset; + $y = $this->getHeight() + $yOffset; + break; + case 'left': + $x = $xOffset; + $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset; + break; + case 'right': + $x = $this->getWidth() - $boxWidth + $xOffset; + $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset; + break; + default: // center + $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset; + $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset; + break; + } + $x = (int) round($x); + $y = (int) round($y); + + // Pass the boundary back by reference + $boundary = [ + 'x1' => $x + $boxText[0], + 'y1' => $y + $boxText[1] - $boxHeight, // $y is the baseline, not the top! + 'x2' => $x + $boxWidth + $boxText[0], + 'y2' => $y + $boxText[1], + 'width' => $boxWidth, + 'height' => $boxHeight, + ]; + + // Text shadow + if (is_array($options['shadow'])) { + imagettftext( + $this->image, + $size, + $angle, + $x + $options['shadow']['x'], + $y + $options['shadow']['y'], + $this->allocateColor($options['shadow']['color']), + $fontFile, + $text + ); + } + + // Draw the text + imagettftext($this->image, $size, $angle, $x, $y, $color, $fontFile, $text); + + return $this; + } + + /** + * Adds text with a line break to the image. + * + * @param string $text The desired text. + * @param array $options + * An array of options. + * - fontFile* (string) - The TrueType (or compatible) font file to use. + * - size (integer) - The size of the font in pixels (default 12). + * - color (string|array) - The text color (default black). + * - anchor (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). + * - xOffset (integer) - The horizontal offset in pixels (default 0). Has no effect when anchor is 'center'. + * - yOffset (integer) - The vertical offset in pixels (default 0). Has no effect when anchor is 'center'. + * - shadow (array) - Text shadow params. + * - x* (integer) - Horizontal offset in pixels. + * - y* (integer) - Vertical offset in pixels. + * - color* (string|array) - The text shadow color. + * - $calculateOffsetFromEdge (bool) - Calculate offsets from the edge of the image (default false). + * - width (int) - Width of text box (default image width). + * - align (string) - How to align text: 'left', 'right', 'center', 'justify' (default 'left'). + * - leading (float) - Increase/decrease spacing between lines of text (default 0). + * - opacity (float) - The opacity level of the text 0-1 (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function textBox(string $text, array $options): static + { + // default width of image + $maxWidth = $this->getWidth(); + // Default options + $options = array_merge([ + 'fontFile' => null, + 'size' => 12, + 'color' => 'black', + 'anchor' => 'center', + 'xOffset' => 0, + 'yOffset' => 0, + 'shadow' => null, + 'calculateOffsetFromEdge' => false, + 'width' => $maxWidth, + 'align' => 'left', + 'leading' => 0, + 'opacity' => 1, + ], $options); + + // Extract and normalize options + $fontFile = $options['fontFile']; + $fontSize = $fontSizePx = $options['size']; + $fontSize = ($fontSize / 96) * 72; // Convert px to pt (72pt per inch, 96px per inch) + $color = $options['color']; + $anchor = $options['anchor']; + $xOffset = $options['xOffset']; + $yOffset = $options['yOffset']; + $shadow = $options['shadow']; + $calculateOffsetFromEdge = $options['calculateOffsetFromEdge']; + $maxWidth = intval($options['width']); + $leading = $options['leading']; + $leading = self::keepWithin($leading, ($fontSizePx * -1), $leading); + $opacity = $options['opacity']; + + $align = $options['align']; + if ($align == 'right') { + $align = 'top right'; + } elseif ($align == 'center') { + $align = 'top'; + } elseif ($align == 'justify') { + $align = 'justify'; + } else { + $align = 'top left'; + } + + [$lines, $isLastLine, $lastLineHeight] = self::textSeparateLines($text, $fontFile, $fontSize, $maxWidth); + + $maxHeight = (int) round(((is_countable($lines) ? count($lines) : 0) - 1) * ($fontSizePx * 1.2 + $leading) + $lastLineHeight); + + $imageText = new SimpleImage(); + $imageText->fromNew($maxWidth, $maxHeight); + + // Align left/center/right + if ($align != 'justify') { + foreach ($lines as $key => $line) { + if ($align == 'top') { + $line = trim($line); + } // If is justify = 'center' + $imageText->text($line, ['fontFile' => $fontFile, 'size' => $fontSizePx, 'color' => $color, 'anchor' => $align, 'xOffset' => 0, 'yOffset' => $key * ($fontSizePx * 1.2 + $leading), 'shadow' => $shadow, 'calculateOffsetFromEdge' => true]); + } + + // Justify + } else { + foreach ($lines as $keyLine => $line) { + // Check if there are spaces at the beginning of the sentence + $spaces = 0; + if (preg_match("/^\s+/", $line, $match)) { + // Count spaces + $spaces = strlen($match[0]); + $line = ltrim($line); + } + + // Separate words + $words = preg_split("/\s+/", $line); + // Include spaces with the first word + $words[0] = str_repeat(' ', $spaces).$words[0]; + + // Calculates the space occupied by all words + $wordsSize = []; + foreach ($words as $key => $word) { + $wordBox = imagettfbbox($fontSize, 0, $fontFile, $word); + $wordWidth = abs($wordBox[4] - $wordBox[0]); + $wordsSize[$key] = $wordWidth; + } + $wordsSizeTotal = array_sum($wordsSize); + + // Calculates the required space between words + $countWords = count($words); + $wordSpacing = 0; + if ($countWords > 1) { + $wordSpacing = ($maxWidth - $wordsSizeTotal) / ($countWords - 1); + $wordSpacing = round($wordSpacing, 3); + } + + $xOffsetJustify = 0; + foreach ($words as $key => $word) { + if ($isLastLine[$keyLine]) { + if ($key < (count($words) - 1)) { + continue; + } + $word = $line; + } + $imageText->text($word, ['fontFile' => $fontFile, 'size' => $fontSizePx, 'color' => $color, 'anchor' => 'top left', 'xOffset' => $xOffsetJustify, 'yOffset' => $keyLine * ($fontSizePx * 1.2 + $leading), 'shadow' => $shadow, 'calculateOffsetFromEdge' => true] + ); + // Calculate offset for next word + $xOffsetJustify += $wordsSize[$key] + $wordSpacing; + } + } + } + + $this->overlay($imageText, $anchor, $opacity, $xOffset, $yOffset, $calculateOffsetFromEdge); + + return $this; + } + + /** + * Receives a text and breaks into LINES. + */ + private function textSeparateLines(string $text, string $fontFile, float $fontSize, int $maxWidth): array + { + $lines = []; + $words = self::textSeparateWords($text); + $countWords = count($words) - 1; + $lines[0] = ''; + $lineKey = 0; + $isLastLine = []; + for ($i = 0; $i < $countWords; $i++) { + $word = $words[$i]; + $isLastLine[$lineKey] = false; + if ($word === PHP_EOL) { + $isLastLine[$lineKey] = true; + $lineKey++; + $lines[$lineKey] = ''; + + continue; + } + $lineBox = imagettfbbox($fontSize, 0, $fontFile, $lines[$lineKey].$word); + if (abs($lineBox[4] - $lineBox[0]) < $maxWidth) { + $lines[$lineKey] .= $word.' '; + } else { + $lineKey++; + $lines[$lineKey] = $word.' '; + } + } + $isLastLine[$lineKey] = true; + // Exclude space of right + $lines = array_map('rtrim', $lines); + // Calculate height of last line + $boxFull = imagettfbbox($fontSize, 0, $fontFile, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'); + $lineBox = imagettfbbox($fontSize, 0, $fontFile, $lines[$lineKey]); + // Height of last line = ascender of $boxFull + descender of $lineBox + $lastLineHeight = abs($lineBox[1]) + abs($boxFull[5]); + + return [$lines, $isLastLine, $lastLineHeight]; + } + + /** + * Receives a text and breaks into WORD / SPACE / NEW LINE. + */ + private function textSeparateWords(string $text): array + { + // Normalizes line break + $text = strval(preg_replace('/(\r\n|\n|\r)/', PHP_EOL, $text)); + $text = explode(PHP_EOL, $text); + $newText = []; + foreach ($text as $line) { + $newText = array_merge($newText, explode(' ', $line), [PHP_EOL]); + } + + return $newText; + } + + /** + * Creates a thumbnail image. This function attempts to get the image as close to the provided + * dimensions as possible, then crops the remaining overflow to force the desired size. Useful + * for generating thumbnail images. + * + * @param int $width The thumbnail width. + * @param int $height The thumbnail height. + * @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center'). + * @return SimpleImage + */ + public function thumbnail(int $width, int $height, string $anchor = 'center'): SimpleImage|static + { + // Determine aspect ratios + $currentRatio = $this->getHeight() / $this->getWidth(); + $targetRatio = $height / $width; + + // Fit to height/width + if ($targetRatio > $currentRatio) { + $this->resize(null, $height); + } else { + $this->resize($width); + } + + switch($anchor) { + case 'top': + $x1 = floor(($this->getWidth() / 2) - ($width / 2)); + $x2 = $width + $x1; + $y1 = 0; + $y2 = $height; + break; + case 'bottom': + $x1 = floor(($this->getWidth() / 2) - ($width / 2)); + $x2 = $width + $x1; + $y1 = $this->getHeight() - $height; + $y2 = $this->getHeight(); + break; + case 'left': + $x1 = 0; + $x2 = $width; + $y1 = floor(($this->getHeight() / 2) - ($height / 2)); + $y2 = $height + $y1; + break; + case 'right': + $x1 = $this->getWidth() - $width; + $x2 = $this->getWidth(); + $y1 = floor(($this->getHeight() / 2) - ($height / 2)); + $y2 = $height + $y1; + break; + case 'top left': + $x1 = 0; + $x2 = $width; + $y1 = 0; + $y2 = $height; + break; + case 'top right': + $x1 = $this->getWidth() - $width; + $x2 = $this->getWidth(); + $y1 = 0; + $y2 = $height; + break; + case 'bottom left': + $x1 = 0; + $x2 = $width; + $y1 = $this->getHeight() - $height; + $y2 = $this->getHeight(); + break; + case 'bottom right': + $x1 = $this->getWidth() - $width; + $x2 = $this->getWidth(); + $y1 = $this->getHeight() - $height; + $y2 = $this->getHeight(); + break; + default: + $x1 = floor(($this->getWidth() / 2) - ($width / 2)); + $x2 = $width + $x1; + $y1 = floor(($this->getHeight() / 2) - ($height / 2)); + $y2 = $height + $y1; + break; + } + + // Return the cropped thumbnail image + return $this->crop($x1, $y1, $x2, $y2); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Drawing + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Draws an arc. + * + * @param int $x The x coordinate of the arc's center. + * @param int $y The y coordinate of the arc's center. + * @param int $width The width of the arc. + * @param int $height The height of the arc. + * @param int $start The start of the arc in degrees. + * @param int $end The end of the arc in degrees. + * @param string|array $color The arc color. + * @param int|string $thickness Line thickness in pixels or 'filled' (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function arc(int $x, int $y, int $width, int $height, int $start, int $end, string|array $color, int|string $thickness = 1): static + { + // Allocate the color + $tempColor = $this->allocateColor($color); + imagesetthickness($this->image, 1); + + // Draw an arc + if ($thickness === 'filled') { + imagefilledarc($this->image, $x, $y, $width, $height, $start, $end, $tempColor, IMG_ARC_PIE); + } elseif ($thickness === 1) { + imagearc($this->image, $x, $y, $width, $height, $start, $end, $tempColor); + } else { + // New temp image + $tempImage = new SimpleImage(); + $tempImage->fromNew($this->getWidth(), $this->getHeight()); + + // Draw a large ellipse filled with $color (+$thickness pixels) + $tempColor = $tempImage->allocateColor($color); + imagefilledarc($tempImage->image, $x, $y, $width + $thickness, $height + $thickness, $start, $end, $tempColor, IMG_ARC_PIE); + + // Draw a smaller ellipse filled with red|blue (-$thickness pixels) + $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red'; + $tempColor = $tempImage->allocateColor($tempColor); + imagefilledarc($tempImage->image, $x, $y, $width - $thickness, $height - $thickness, $start, $end, $tempColor, IMG_ARC_PIE); + + // Replace the color of the smaller ellipse with 'transparent' + $tempImage->excludeInsideColor($x, $y, $color); + + // Apply the temp image + $this->overlay($tempImage); + } + + return $this; + } + + /** + * Draws a border around the image. + * + * @param string|array $color The border color. + * @param int $thickness The thickness of the border (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function border(string|array $color, int $thickness = 1): static + { + $x1 = -1; + $y1 = 0; + $x2 = $this->getWidth(); + $y2 = $this->getHeight() - 1; + + $color = $this->allocateColor($color); + imagesetthickness($this->image, $thickness * 2); + imagerectangle($this->image, $x1, $y1, $x2, $y2, $color); + + return $this; + } + + /** + * Draws a single pixel dot. + * + * @param int $x The x coordinate of the dot. + * @param int $y The y coordinate of the dot. + * @param string|array $color The dot color. + * @return SimpleImage + * + * @throws Exception + */ + public function dot(int $x, int $y, string|array $color): static + { + $color = $this->allocateColor($color); + imagesetpixel($this->image, $x, $y, $color); + + return $this; + } + + /** + * Draws an ellipse. + * + * @param int $x The x coordinate of the center. + * @param int $y The y coordinate of the center. + * @param int $width The ellipse width. + * @param int $height The ellipse height. + * @param string|array $color The ellipse color. + * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function ellipse(int $x, int $y, int $width, int $height, string|array $color, string|int|array $thickness = 1): static + { + // Allocate the color + $tempColor = $this->allocateColor($color); + imagesetthickness($this->image, 1); + + // Draw an ellipse + if ($thickness == 'filled') { + imagefilledellipse($this->image, $x, $y, $width, $height, $tempColor); + } elseif ($thickness === 1) { + imageellipse($this->image, $x, $y, $width, $height, $tempColor); + } else { + // New temp image + $tempImage = new SimpleImage(); + $tempImage->fromNew($this->getWidth(), $this->getHeight()); + + // Draw a large ellipse filled with $color (+$thickness pixels) + $tempColor = $tempImage->allocateColor($color); + imagefilledellipse($tempImage->image, $x, $y, $width + $thickness, $height + $thickness, $tempColor); + + // Draw a smaller ellipse filled with red|blue (-$thickness pixels) + $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red'; + $tempColor = $tempImage->allocateColor($tempColor); + imagefilledellipse($tempImage->image, $x, $y, $width - $thickness, $height - $thickness, $tempColor); + + // Replace the color of the smaller ellipse with 'transparent' + $tempImage->excludeInsideColor($x, $y, $color); + + // Apply the temp image + $this->overlay($tempImage); + } + + return $this; + } + + /** + * Fills the image with a solid color. + * + * @param string|array $color The fill color. + * @return SimpleImage + * + * @throws Exception + */ + public function fill(string|array $color): static + { + // Draw a filled rectangle over the entire image + $this->rectangle(0, 0, $this->getWidth(), $this->getHeight(), 'white', 'filled'); + + // Now flood it with the appropriate color + $color = $this->allocateColor($color); + imagefill($this->image, 0, 0, $color); + + return $this; + } + + /** + * Draws a line. + * + * @param int $x1 The x coordinate for the first point. + * @param int $y1 The y coordinate for the first point. + * @param int $x2 The x coordinate for the second point. + * @param int $y2 The y coordinate for the second point. + * @param string|array $color The line color. + * @param int $thickness The line thickness (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function line(int $x1, int $y1, int $x2, int $y2, string|array $color, int $thickness = 1): static + { + // Allocate the color + $color = $this->allocateColor($color); + + // Draw a line + imagesetthickness($this->image, $thickness); + imageline($this->image, $x1, $y1, $x2, $y2, $color); + + return $this; + } + + /** + * Draws a polygon. + * + * @param array $vertices + * The polygon's vertices in an array of x/y arrays. + * Example: + * [ + * ['x' => x1, 'y' => y1], + * ['x' => x2, 'y' => y2], + * ['x' => xN, 'y' => yN] + * ] + * @param string|array $color The polygon color. + * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function polygon(array $vertices, string|array $color, string|int|array $thickness = 1): static + { + // Allocate the color + $color = $this->allocateColor($color); + + // Convert [['x' => x1, 'y' => x1], ['x' => x1, 'y' => y2], ...] to [x1, y1, x2, y2, ...] + $points = []; + foreach ($vertices as $vals) { + $points[] = $vals['x']; + $points[] = $vals['y']; + } + + // Draw a polygon + if ($thickness == 'filled') { + imagesetthickness($this->image, 1); + imagefilledpolygon($this->image, $points, count($vertices), $color); + } else { + imagesetthickness($this->image, $thickness); + imagepolygon($this->image, $points, count($vertices), $color); + } + + return $this; + } + + /** + * Draws a rectangle. + * + * @param int $x1 The upper left x coordinate. + * @param int $y1 The upper left y coordinate. + * @param int $x2 The bottom right x coordinate. + * @param int $y2 The bottom right y coordinate. + * @param string|array $color The rectangle color. + * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function rectangle(int $x1, int $y1, int $x2, int $y2, string|array $color, string|int|array $thickness = 1): static + { + // Allocate the color + $color = $this->allocateColor($color); + + // Draw a rectangle + if ($thickness == 'filled') { + imagesetthickness($this->image, 1); + imagefilledrectangle($this->image, $x1, $y1, $x2, $y2, $color); + } else { + imagesetthickness($this->image, $thickness); + imagerectangle($this->image, $x1, $y1, $x2, $y2, $color); + } + + return $this; + } + + /** + * Draws a rounded rectangle. + * + * @param int $x1 The upper left x coordinate. + * @param int $y1 The upper left y coordinate. + * @param int $x2 The bottom right x coordinate. + * @param int $y2 The bottom right y coordinate. + * @param int $radius The border radius in pixels. + * @param string|array $color The rectangle color. + * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1). + * @return SimpleImage + * + * @throws Exception + */ + public function roundedRectangle(int $x1, int $y1, int $x2, int $y2, int $radius, string|array $color, string|int|array $thickness = 1): static + { + if ($thickness == 'filled') { + // Draw the filled rectangle without edges + $this->rectangle($x1 + $radius + 1, $y1, $x2 - $radius - 1, $y2, $color, 'filled'); + $this->rectangle($x1, $y1 + $radius + 1, $x1 + $radius, $y2 - $radius - 1, $color, 'filled'); + $this->rectangle($x2 - $radius, $y1 + $radius + 1, $x2, $y2 - $radius - 1, $color, 'filled'); + + // Fill in the edges with arcs + $this->arc($x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, 180, 270, $color, 'filled'); + $this->arc($x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, 270, 360, $color, 'filled'); + $this->arc($x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, 90, 180, $color, 'filled'); + $this->arc($x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, 360, 90, $color, 'filled'); + } else { + $offset = $thickness / 2; + $x1 -= $offset; + $x2 += $offset; + $y1 -= $offset; + $y2 += $offset; + $radius = self::keepWithin($radius, 0, min(($x2 - $x1) / 2, ($y2 - $y1) / 2) - 1); + $radius = (int) floor($radius); + $thickness = self::keepWithin($thickness, 1, min(($x2 - $x1) / 2, ($y2 - $y1) / 2)); + + // New temp image + $tempImage = new SimpleImage(); + $tempImage->fromNew($this->getWidth(), $this->getHeight()); + + // Draw a large rectangle filled with $color + $tempImage->roundedRectangle($x1, $y1, $x2, $y2, $radius, $color, 'filled'); + + // Draw a smaller rectangle filled with red|blue (-$thickness pixels on each side) + $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red'; + $radius = $radius - $thickness; + $radius = self::keepWithin($radius, 0, $radius); + $tempImage->roundedRectangle( + $x1 + $thickness, + $y1 + $thickness, + $x2 - $thickness, + $y2 - $thickness, + $radius, + $tempColor, + 'filled' + ); + + // Replace the color of the smaller rectangle with 'transparent' + $tempImage->excludeInsideColor(($x2 + $x1) / 2, ($y2 + $y1) / 2, $color); + + // Apply the temp image + $this->overlay($tempImage); + } + + return $this; + } + + /** + * Exclude inside color. + * Used for roundedRectangle(), ellipse() and arc() + * + * @param int $x certer x of rectangle. + * @param int $y certer y of rectangle. + * @param string|array $borderColor The color of border. + * + * @throws Exception + */ + private function excludeInsideColor(int $x, int $y, string|array $borderColor): static + { + $borderColor = $this->allocateColor($borderColor); + $transparent = $this->allocateColor('transparent'); + imagefilltoborder($this->image, $x, $y, $borderColor, $transparent); + + return $this; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Filters + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Applies the blur filter. + * + * @param string $type The blur algorithm to use: 'selective', 'gaussian' (default 'gaussian'). + * @param int $passes The number of time to apply the filter, enhancing the effect (default 1). + * @return SimpleImage + */ + public function blur(string $type = 'selective', int $passes = 1): static + { + $filter = $type === 'gaussian' ? IMG_FILTER_GAUSSIAN_BLUR : IMG_FILTER_SELECTIVE_BLUR; + + for ($i = 0; $i < $passes; $i++) { + imagefilter($this->image, $filter); + } + + return $this; + } + + /** + * Applies the brightness filter to brighten the image. + * + * @param int $percentage Percentage to brighten the image (0 - 100). + * @return SimpleImage + */ + public function brighten(int $percentage): static + { + $percentage = self::keepWithin(255 * $percentage / 100, 0, 255); + + imagefilter($this->image, IMG_FILTER_BRIGHTNESS, $percentage); + + return $this; + } + + /** + * Applies the colorize filter. + * + * @param string|array $color The filter color. + * @return SimpleImage + * + * @throws Exception + */ + public function colorize(string|array $color): static + { + $color = self::normalizeColor($color); + + imagefilter( + $this->image, + IMG_FILTER_COLORIZE, + $color['red'], + $color['green'], + $color['blue'], + 127 - ($color['alpha'] * 127) + ); + + return $this; + } + + /** + * Applies the contrast filter. + * + * @param int $percentage Percentage to adjust (-100 - 100). + * @return SimpleImage + */ + public function contrast(int $percentage): static + { + imagefilter($this->image, IMG_FILTER_CONTRAST, self::keepWithin($percentage, -100, 100)); + + return $this; + } + + /** + * Applies the brightness filter to darken the image. + * + * @param int $percentage Percentage to darken the image (0 - 100). + * @return SimpleImage + */ + public function darken(int $percentage): static + { + $percentage = self::keepWithin(255 * $percentage / 100, 0, 255); + + imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -$percentage); + + return $this; + } + + /** + * Applies the desaturate (grayscale) filter. + * + * @return SimpleImage + */ + public function desaturate(): static + { + imagefilter($this->image, IMG_FILTER_GRAYSCALE); + + return $this; + } + + /** + * Applies the edge detect filter. + * + * @return SimpleImage + */ + public function edgeDetect(): static + { + imagefilter($this->image, IMG_FILTER_EDGEDETECT); + + return $this; + } + + /** + * Applies the emboss filter. + * + * @return SimpleImage + */ + public function emboss(): static + { + imagefilter($this->image, IMG_FILTER_EMBOSS); + + return $this; + } + + /** + * Inverts the image's colors. + * + * @return SimpleImage + */ + public function invert(): static + { + imagefilter($this->image, IMG_FILTER_NEGATE); + + return $this; + } + + /** + * Changes the image's opacity level. + * + * @param float $opacity The desired opacity level (0 - 1). + * @return SimpleImage + * + * @throws Exception + */ + public function opacity(float $opacity): static + { + // Create a transparent image + $newImage = new SimpleImage(); + $newImage->fromNew($this->getWidth(), $this->getHeight()); + + // Copy the current image (with opacity) onto the transparent image + self::imageCopyMergeAlpha( + $newImage->image, + $this->image, + 0, 0, + 0, 0, + $this->getWidth(), + $this->getHeight(), + (int) round(self::keepWithin($opacity, 0, 1) * 100) + ); + + return $this; + } + + /** + * Applies the pixelate filter. + * + * @param int $size The size of the blocks in pixels (default 10). + * @return SimpleImage + */ + public function pixelate(int $size = 10): static + { + imagefilter($this->image, IMG_FILTER_PIXELATE, $size, true); + + return $this; + } + + /** + * Simulates a sepia effect by desaturating the image and applying a sepia tone. + * + * @return SimpleImage + */ + public function sepia(): static + { + imagefilter($this->image, IMG_FILTER_GRAYSCALE); + imagefilter($this->image, IMG_FILTER_COLORIZE, 70, 35, 0); + + return $this; + } + + /** + * Sharpens the image. + * + * @param int $amount Sharpening amount (default 50). + * @return SimpleImage + */ + public function sharpen(int $amount = 50): static + { + // Normalize amount + $amount = max(1, min(100, $amount)) / 100; + + $sharpen = [ + [-1, -1, -1], + [-1, 8 / $amount, -1], + [-1, -1, -1], + ]; + $divisor = array_sum(array_map('array_sum', $sharpen)); + + imageconvolution($this->image, $sharpen, $divisor, 0); + + return $this; + } + + /** + * Applies the mean remove filter to produce a sketch effect. + * + * @return SimpleImage + */ + public function sketch(): static + { + imagefilter($this->image, IMG_FILTER_MEAN_REMOVAL); + + return $this; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////// + // Color utilities + ////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Converts a "friendly color" into a color identifier for use with GD's image functions. + * + * @param string|array $color The color to allocate. + * + * @throws Exception + */ + protected function allocateColor(string|array $color): int + { + $color = self::normalizeColor($color); + + // Was this color already allocated? + $index = imagecolorexactalpha( + $this->image, + $color['red'], + $color['green'], + $color['blue'], + (int) (127 - ($color['alpha'] * 127)) + ); + if ($index > -1) { + // Yes, return this color index + return $index; + } + + // Allocate a new color index + return imagecolorallocatealpha( + $this->image, + $color['red'], + $color['green'], + $color['blue'], + 127 - ($color['alpha'] * 127) + ); + } + + /** + * Adjusts a color by increasing/decreasing red/green/blue/alpha values independently. + * + * @param string|array $color The color to adjust. + * @param int $red Red adjustment (-255 - 255). + * @param int $green Green adjustment (-255 - 255). + * @param int $blue Blue adjustment (-255 - 255). + * @param int $alpha Alpha adjustment (-1 - 1). + * @return int[] An RGBA color array. + * + * @throws Exception + */ + public static function adjustColor(string|array $color, int $red, int $green, int $blue, int $alpha): array + { + // Normalize to RGBA + $color = self::normalizeColor($color); + + // Adjust each channel + return self::normalizeColor([ + 'red' => $color['red'] + $red, + 'green' => $color['green'] + $green, + 'blue' => $color['blue'] + $blue, + 'alpha' => $color['alpha'] + $alpha, + ]); + } + + /** + * Darkens a color. + * + * @param string|array $color The color to darken. + * @param int $amount Amount to darken (0 - 255). + * @return int[] An RGBA color array. + * + * @throws Exception + */ + public static function darkenColor(string|array $color, int $amount): array + { + return self::adjustColor($color, -$amount, -$amount, -$amount, 0); + } + + /** + * Extracts colors from an image like a human would do.™ This method requires the third-party + * library \League\ColorExtractor. If you're using Composer, it will be installed for you + * automatically. + * + * @param int $count The max number of colors to extract (default 5). + * @param string|array|null $backgroundColor + * By default any pixel with alpha value greater than zero will + * be discarded. This is because transparent colors are not perceived as is. For example, fully + * transparent black would be seen white on a white background. So if you want to take + * transparency into account, you have to specify a default background color. + * @return int[] An array of RGBA colors arrays. + * + * @throws Exception Thrown if library \League\ColorExtractor is missing. + */ + public function extractColors(int $count = 5, string|array|null $backgroundColor = null): array + { + // Check for required library + if (! class_exists('\\'.ColorExtractor::class)) { + throw new Exception( + 'Required library \League\ColorExtractor is missing.', + self::ERR_LIB_NOT_LOADED + ); + } + + // Convert background color to an integer value + if ($backgroundColor) { + $backgroundColor = self::normalizeColor($backgroundColor); + $backgroundColor = Color::fromRgbToInt([ + 'r' => $backgroundColor['red'], + 'g' => $backgroundColor['green'], + 'b' => $backgroundColor['blue'], + ]); + } + + // Extract colors from the image + $palette = Palette::fromGD($this->image, $backgroundColor); + $extractor = new ColorExtractor($palette); + $colors = $extractor->extract($count); + + // Convert colors to an RGBA color array + foreach ($colors as $key => $value) { + $colors[$key] = self::normalizeColor(Color::fromIntToHex($value)); + } + + return $colors; + } + + /** + * Gets the RGBA value of a single pixel. + * + * @param int $x The horizontal position of the pixel. + * @param int $y The vertical position of the pixel. + * @return bool|int[] An RGBA color array or false if the x/y position is off the canvas. + */ + public function getColorAt(int $x, int $y): array|bool + { + // Coordinates must be on the canvas + if ($x < 0 || $x > $this->getWidth() || $y < 0 || $y > $this->getHeight()) { + return false; + } + + // Get the color of this pixel and convert it to RGBA + $color = imagecolorat($this->image, $x, $y); + $rgba = imagecolorsforindex($this->image, $color); + $rgba['alpha'] = 127 - ($color >> 24) & 0xFF; + + return $rgba; + } + + /** + * Lightens a color. + * + * @param string|array $color The color to lighten. + * @param int $amount Amount to lighten (0 - 255). + * @return int[] An RGBA color array. + * + * @throws Exception + */ + public static function lightenColor(string|array $color, int $amount): array + { + return self::adjustColor($color, $amount, $amount, $amount, 0); + } + + /** + * Normalizes a hex or array color value to a well-formatted RGBA array. + * + * @param string|array $color + * A CSS color name, hex string, or an array [red, green, blue, alpha]. + * You can pipe alpha transparency through hex strings and color names. For example: + * #fff|0.50 <-- 50% white + * red|0.25 <-- 25% red + * @return array [red, green, blue, alpha]. + * + * @throws Exception Thrown if color value is invalid. + */ + public static function normalizeColor(string|array $color): array + { + // 140 CSS color names and hex values + $cssColors = [ + 'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff', + 'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc', 'bisque' => '#ffe4c4', + 'black' => '#000000', 'blanchedalmond' => '#ffebcd', 'blue' => '#0000ff', + 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a', 'burlywood' => '#deb887', + 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00', 'chocolate' => '#d2691e', + 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed', 'cornsilk' => '#fff8dc', + 'crimson' => '#dc143c', 'cyan' => '#00ffff', 'darkblue' => '#00008b', 'darkcyan' => '#008b8b', + 'darkgoldenrod' => '#b8860b', 'darkgray' => '#a9a9a9', 'darkgrey' => '#a9a9a9', + 'darkgreen' => '#006400', 'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', + 'darkolivegreen' => '#556b2f', 'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', + 'darkred' => '#8b0000', 'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', + 'darkslateblue' => '#483d8b', 'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', + 'darkturquoise' => '#00ced1', 'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', + 'deepskyblue' => '#00bfff', 'dimgray' => '#696969', 'dimgrey' => '#696969', + 'dodgerblue' => '#1e90ff', 'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', + 'forestgreen' => '#228b22', 'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', + 'ghostwhite' => '#f8f8ff', 'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080', + 'grey' => '#808080', 'green' => '#008000', 'greenyellow' => '#adff2f', + 'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred ' => '#cd5c5c', + 'indigo ' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c', 'lavender' => '#e6e6fa', + 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00', 'lemonchiffon' => '#fffacd', + 'lightblue' => '#add8e6', 'lightcoral' => '#f08080', 'lightcyan' => '#e0ffff', + 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3', 'lightgrey' => '#d3d3d3', + 'lightgreen' => '#90ee90', 'lightpink' => '#ffb6c1', 'lightsalmon' => '#ffa07a', + 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa', 'lightslategray' => '#778899', + 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de', 'lightyellow' => '#ffffe0', + 'lime' => '#00ff00', 'limegreen' => '#32cd32', 'linen' => '#faf0e6', 'magenta' => '#ff00ff', + 'maroon' => '#800000', 'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', + 'mediumorchid' => '#ba55d3', 'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', + 'mediumslateblue' => '#7b68ee', 'mediumspringgreen' => '#00fa9a', + 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585', 'midnightblue' => '#191970', + 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1', 'moccasin' => '#ffe4b5', + 'navajowhite' => '#ffdead', 'navy' => '#000080', 'oldlace' => '#fdf5e6', 'olive' => '#808000', + 'olivedrab' => '#6b8e23', 'orange' => '#ffa500', 'orangered' => '#ff4500', + 'orchid' => '#da70d6', 'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', + 'paleturquoise' => '#afeeee', 'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', + 'peachpuff' => '#ffdab9', 'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd', + 'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399', + 'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1', + 'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460', + 'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d', + 'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd', + 'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa', + 'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c', 'teal' => '#008080', + 'thistle' => '#d8bfd8', 'tomato' => '#ff6347', 'turquoise' => '#40e0d0', + 'violet' => '#ee82ee', 'wheat' => '#f5deb3', 'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', + 'yellow' => '#ffff00', 'yellowgreen' => '#9acd32', + ]; + + // Parse alpha from '#fff|.5' and 'white|.5' + if (is_string($color) && strstr($color, '|')) { + $color = explode('|', $color); + $alpha = (float) $color[1]; + $color = trim($color[0]); + } else { + $alpha = 1; + } + + // Translate CSS color names to hex values + if (is_string($color) && array_key_exists(strtolower($color), $cssColors)) { + $color = $cssColors[strtolower($color)]; + } + + // Translate transparent keyword to a transparent color + if ($color === 'transparent') { + $color = ['red' => 0, 'green' => 0, 'blue' => 0, 'alpha' => 0]; + } + + // Convert hex values to RGBA + if (is_string($color)) { + // Remove # + $hex = strval(preg_replace('/^#/', '', $color)); + + // Support short and standard hex codes + if (strlen($hex) === 3 || strlen($hex) === 4) { + [$red, $green, $blue] = [ + $hex[0].$hex[0], + $hex[1].$hex[1], + $hex[2].$hex[2], + ]; + if (strlen($hex) === 4) { + $alpha = hexdec($hex[3]) / 255; + } + } elseif (strlen($hex) === 6 || strlen($hex) === 8) { + [$red, $green, $blue] = [ + $hex[0].$hex[1], + $hex[2].$hex[3], + $hex[4].$hex[5], + ]; + if (strlen($hex) === 8) { + $alpha = hexdec($hex[6].$hex[7]) / 255; + } + } else { + throw new Exception("Invalid color value: $color", self::ERR_INVALID_COLOR); + } + + // Turn color into an array + $color = [ + 'red' => hexdec($red), + 'green' => hexdec($green), + 'blue' => hexdec($blue), + 'alpha' => $alpha, + ]; + } + + // Enforce color value ranges + if (is_array($color)) { + // RGB default to 0 + $color['red'] ??= 0; + $color['green'] ??= 0; + $color['blue'] ??= 0; + + // Alpha defaults to 1 + $color['alpha'] ??= 1; + + return [ + 'red' => (int) self::keepWithin((int) $color['red'], 0, 255), + 'green' => (int) self::keepWithin((int) $color['green'], 0, 255), + 'blue' => (int) self::keepWithin((int) $color['blue'], 0, 255), + 'alpha' => self::keepWithin($color['alpha'], 0, 1), + ]; + } + + throw new Exception("Invalid color value: $color", self::ERR_INVALID_COLOR); + } +} diff --git a/public/vendor/composer/ClassLoader.php b/public/vendor/composer/ClassLoader.php new file mode 100644 index 0000000..7824d8f --- /dev/null +++ b/public/vendor/composer/ClassLoader.php @@ -0,0 +1,579 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/public/vendor/composer/InstalledVersions.php b/public/vendor/composer/InstalledVersions.php new file mode 100644 index 0000000..51e734a --- /dev/null +++ b/public/vendor/composer/InstalledVersions.php @@ -0,0 +1,359 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + + if (self::$canGetVendors) { + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + $installed[] = self::$installedByVendor[$vendorDir] = $required; + if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { + self::$installed = $installed[count($installed) - 1]; + } + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array()) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/public/vendor/composer/LICENSE b/public/vendor/composer/LICENSE new file mode 100644 index 0000000..f27399a --- /dev/null +++ b/public/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/public/vendor/composer/autoload_classmap.php b/public/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..ae9b08e --- /dev/null +++ b/public/vendor/composer/autoload_classmap.php @@ -0,0 +1,499 @@ + $vendorDir . '/christian-riesen/base32/src/Base32.php', + 'Base32\\Base32Hex' => $vendorDir . '/christian-riesen/base32/src/Base32Hex.php', + 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', + 'Composer\\Semver\\Comparator' => $vendorDir . '/composer/semver/src/Comparator.php', + 'Composer\\Semver\\CompilingMatcher' => $vendorDir . '/composer/semver/src/CompilingMatcher.php', + 'Composer\\Semver\\Constraint\\Bound' => $vendorDir . '/composer/semver/src/Constraint/Bound.php', + 'Composer\\Semver\\Constraint\\Constraint' => $vendorDir . '/composer/semver/src/Constraint/Constraint.php', + 'Composer\\Semver\\Constraint\\ConstraintInterface' => $vendorDir . '/composer/semver/src/Constraint/ConstraintInterface.php', + 'Composer\\Semver\\Constraint\\MatchAllConstraint' => $vendorDir . '/composer/semver/src/Constraint/MatchAllConstraint.php', + 'Composer\\Semver\\Constraint\\MatchNoneConstraint' => $vendorDir . '/composer/semver/src/Constraint/MatchNoneConstraint.php', + 'Composer\\Semver\\Constraint\\MultiConstraint' => $vendorDir . '/composer/semver/src/Constraint/MultiConstraint.php', + 'Composer\\Semver\\Interval' => $vendorDir . '/composer/semver/src/Interval.php', + 'Composer\\Semver\\Intervals' => $vendorDir . '/composer/semver/src/Intervals.php', + 'Composer\\Semver\\Semver' => $vendorDir . '/composer/semver/src/Semver.php', + 'Composer\\Semver\\VersionParser' => $vendorDir . '/composer/semver/src/VersionParser.php', + 'Kirby\\Api\\Api' => $baseDir . '/kirby/src/Api/Api.php', + 'Kirby\\Api\\Collection' => $baseDir . '/kirby/src/Api/Collection.php', + 'Kirby\\Api\\Controller\\Changes' => $baseDir . '/kirby/src/Api/Controller/Changes.php', + 'Kirby\\Api\\Model' => $baseDir . '/kirby/src/Api/Model.php', + 'Kirby\\Api\\Upload' => $baseDir . '/kirby/src/Api/Upload.php', + 'Kirby\\Cache\\ApcuCache' => $baseDir . '/kirby/src/Cache/ApcuCache.php', + 'Kirby\\Cache\\Cache' => $baseDir . '/kirby/src/Cache/Cache.php', + 'Kirby\\Cache\\FileCache' => $baseDir . '/kirby/src/Cache/FileCache.php', + 'Kirby\\Cache\\MemCached' => $baseDir . '/kirby/src/Cache/MemCached.php', + 'Kirby\\Cache\\MemoryCache' => $baseDir . '/kirby/src/Cache/MemoryCache.php', + 'Kirby\\Cache\\NullCache' => $baseDir . '/kirby/src/Cache/NullCache.php', + 'Kirby\\Cache\\RedisCache' => $baseDir . '/kirby/src/Cache/RedisCache.php', + 'Kirby\\Cache\\Value' => $baseDir . '/kirby/src/Cache/Value.php', + 'Kirby\\Cms\\Api' => $baseDir . '/kirby/src/Cms/Api.php', + 'Kirby\\Cms\\App' => $baseDir . '/kirby/src/Cms/App.php', + 'Kirby\\Cms\\AppCaches' => $baseDir . '/kirby/src/Cms/AppCaches.php', + 'Kirby\\Cms\\AppErrors' => $baseDir . '/kirby/src/Cms/AppErrors.php', + 'Kirby\\Cms\\AppPlugins' => $baseDir . '/kirby/src/Cms/AppPlugins.php', + 'Kirby\\Cms\\AppTranslations' => $baseDir . '/kirby/src/Cms/AppTranslations.php', + 'Kirby\\Cms\\AppUsers' => $baseDir . '/kirby/src/Cms/AppUsers.php', + 'Kirby\\Cms\\Auth' => $baseDir . '/kirby/src/Cms/Auth.php', + 'Kirby\\Cms\\Auth\\Challenge' => $baseDir . '/kirby/src/Cms/Auth/Challenge.php', + 'Kirby\\Cms\\Auth\\EmailChallenge' => $baseDir . '/kirby/src/Cms/Auth/EmailChallenge.php', + 'Kirby\\Cms\\Auth\\Status' => $baseDir . '/kirby/src/Cms/Auth/Status.php', + 'Kirby\\Cms\\Auth\\TotpChallenge' => $baseDir . '/kirby/src/Cms/Auth/TotpChallenge.php', + 'Kirby\\Cms\\Block' => $baseDir . '/kirby/src/Cms/Block.php', + 'Kirby\\Cms\\BlockConverter' => $baseDir . '/kirby/src/Cms/BlockConverter.php', + 'Kirby\\Cms\\Blocks' => $baseDir . '/kirby/src/Cms/Blocks.php', + 'Kirby\\Cms\\Blueprint' => $baseDir . '/kirby/src/Cms/Blueprint.php', + 'Kirby\\Cms\\Collection' => $baseDir . '/kirby/src/Cms/Collection.php', + 'Kirby\\Cms\\Collections' => $baseDir . '/kirby/src/Cms/Collections.php', + 'Kirby\\Cms\\Core' => $baseDir . '/kirby/src/Cms/Core.php', + 'Kirby\\Cms\\Email' => $baseDir . '/kirby/src/Cms/Email.php', + 'Kirby\\Cms\\Event' => $baseDir . '/kirby/src/Cms/Event.php', + 'Kirby\\Cms\\Events' => $baseDir . '/kirby/src/Cms/Events.php', + 'Kirby\\Cms\\Fieldset' => $baseDir . '/kirby/src/Cms/Fieldset.php', + 'Kirby\\Cms\\Fieldsets' => $baseDir . '/kirby/src/Cms/Fieldsets.php', + 'Kirby\\Cms\\File' => $baseDir . '/kirby/src/Cms/File.php', + 'Kirby\\Cms\\FileActions' => $baseDir . '/kirby/src/Cms/FileActions.php', + 'Kirby\\Cms\\FileBlueprint' => $baseDir . '/kirby/src/Cms/FileBlueprint.php', + 'Kirby\\Cms\\FileModifications' => $baseDir . '/kirby/src/Cms/FileModifications.php', + 'Kirby\\Cms\\FilePermissions' => $baseDir . '/kirby/src/Cms/FilePermissions.php', + 'Kirby\\Cms\\FilePicker' => $baseDir . '/kirby/src/Cms/FilePicker.php', + 'Kirby\\Cms\\FileRules' => $baseDir . '/kirby/src/Cms/FileRules.php', + 'Kirby\\Cms\\FileVersion' => $baseDir . '/kirby/src/Cms/FileVersion.php', + 'Kirby\\Cms\\Files' => $baseDir . '/kirby/src/Cms/Files.php', + 'Kirby\\Cms\\Find' => $baseDir . '/kirby/src/Cms/Find.php', + 'Kirby\\Cms\\HasChildren' => $baseDir . '/kirby/src/Cms/HasChildren.php', + 'Kirby\\Cms\\HasFiles' => $baseDir . '/kirby/src/Cms/HasFiles.php', + 'Kirby\\Cms\\HasMethods' => $baseDir . '/kirby/src/Cms/HasMethods.php', + 'Kirby\\Cms\\HasModels' => $baseDir . '/kirby/src/Cms/HasModels.php', + 'Kirby\\Cms\\HasSiblings' => $baseDir . '/kirby/src/Cms/HasSiblings.php', + 'Kirby\\Cms\\Helpers' => $baseDir . '/kirby/src/Cms/Helpers.php', + 'Kirby\\Cms\\Html' => $baseDir . '/kirby/src/Cms/Html.php', + 'Kirby\\Cms\\Ingredients' => $baseDir . '/kirby/src/Cms/Ingredients.php', + 'Kirby\\Cms\\Item' => $baseDir . '/kirby/src/Cms/Item.php', + 'Kirby\\Cms\\Items' => $baseDir . '/kirby/src/Cms/Items.php', + 'Kirby\\Cms\\Language' => $baseDir . '/kirby/src/Cms/Language.php', + 'Kirby\\Cms\\LanguagePermissions' => $baseDir . '/kirby/src/Cms/LanguagePermissions.php', + 'Kirby\\Cms\\LanguageRouter' => $baseDir . '/kirby/src/Cms/LanguageRouter.php', + 'Kirby\\Cms\\LanguageRoutes' => $baseDir . '/kirby/src/Cms/LanguageRoutes.php', + 'Kirby\\Cms\\LanguageRules' => $baseDir . '/kirby/src/Cms/LanguageRules.php', + 'Kirby\\Cms\\LanguageVariable' => $baseDir . '/kirby/src/Cms/LanguageVariable.php', + 'Kirby\\Cms\\Languages' => $baseDir . '/kirby/src/Cms/Languages.php', + 'Kirby\\Cms\\Layout' => $baseDir . '/kirby/src/Cms/Layout.php', + 'Kirby\\Cms\\LayoutColumn' => $baseDir . '/kirby/src/Cms/LayoutColumn.php', + 'Kirby\\Cms\\LayoutColumns' => $baseDir . '/kirby/src/Cms/LayoutColumns.php', + 'Kirby\\Cms\\Layouts' => $baseDir . '/kirby/src/Cms/Layouts.php', + 'Kirby\\Cms\\License' => $baseDir . '/kirby/src/Cms/License.php', + 'Kirby\\Cms\\LicenseStatus' => $baseDir . '/kirby/src/Cms/LicenseStatus.php', + 'Kirby\\Cms\\LicenseType' => $baseDir . '/kirby/src/Cms/LicenseType.php', + 'Kirby\\Cms\\Loader' => $baseDir . '/kirby/src/Cms/Loader.php', + 'Kirby\\Cms\\Media' => $baseDir . '/kirby/src/Cms/Media.php', + 'Kirby\\Cms\\ModelCommit' => $baseDir . '/kirby/src/Cms/ModelCommit.php', + 'Kirby\\Cms\\ModelPermissions' => $baseDir . '/kirby/src/Cms/ModelPermissions.php', + 'Kirby\\Cms\\ModelState' => $baseDir . '/kirby/src/Cms/ModelState.php', + 'Kirby\\Cms\\ModelWithContent' => $baseDir . '/kirby/src/Cms/ModelWithContent.php', + 'Kirby\\Cms\\Nest' => $baseDir . '/kirby/src/Cms/Nest.php', + 'Kirby\\Cms\\NestCollection' => $baseDir . '/kirby/src/Cms/NestCollection.php', + 'Kirby\\Cms\\NestObject' => $baseDir . '/kirby/src/Cms/NestObject.php', + 'Kirby\\Cms\\Page' => $baseDir . '/kirby/src/Cms/Page.php', + 'Kirby\\Cms\\PageActions' => $baseDir . '/kirby/src/Cms/PageActions.php', + 'Kirby\\Cms\\PageBlueprint' => $baseDir . '/kirby/src/Cms/PageBlueprint.php', + 'Kirby\\Cms\\PageCopy' => $baseDir . '/kirby/src/Cms/PageCopy.php', + 'Kirby\\Cms\\PagePermissions' => $baseDir . '/kirby/src/Cms/PagePermissions.php', + 'Kirby\\Cms\\PagePicker' => $baseDir . '/kirby/src/Cms/PagePicker.php', + 'Kirby\\Cms\\PageRules' => $baseDir . '/kirby/src/Cms/PageRules.php', + 'Kirby\\Cms\\PageSiblings' => $baseDir . '/kirby/src/Cms/PageSiblings.php', + 'Kirby\\Cms\\Pages' => $baseDir . '/kirby/src/Cms/Pages.php', + 'Kirby\\Cms\\Pagination' => $baseDir . '/kirby/src/Cms/Pagination.php', + 'Kirby\\Cms\\Permissions' => $baseDir . '/kirby/src/Cms/Permissions.php', + 'Kirby\\Cms\\Picker' => $baseDir . '/kirby/src/Cms/Picker.php', + 'Kirby\\Cms\\R' => $baseDir . '/kirby/src/Cms/R.php', + 'Kirby\\Cms\\Responder' => $baseDir . '/kirby/src/Cms/Responder.php', + 'Kirby\\Cms\\Response' => $baseDir . '/kirby/src/Cms/Response.php', + 'Kirby\\Cms\\Role' => $baseDir . '/kirby/src/Cms/Role.php', + 'Kirby\\Cms\\Roles' => $baseDir . '/kirby/src/Cms/Roles.php', + 'Kirby\\Cms\\S' => $baseDir . '/kirby/src/Cms/S.php', + 'Kirby\\Cms\\Search' => $baseDir . '/kirby/src/Cms/Search.php', + 'Kirby\\Cms\\Section' => $baseDir . '/kirby/src/Cms/Section.php', + 'Kirby\\Cms\\Site' => $baseDir . '/kirby/src/Cms/Site.php', + 'Kirby\\Cms\\SiteActions' => $baseDir . '/kirby/src/Cms/SiteActions.php', + 'Kirby\\Cms\\SiteBlueprint' => $baseDir . '/kirby/src/Cms/SiteBlueprint.php', + 'Kirby\\Cms\\SitePermissions' => $baseDir . '/kirby/src/Cms/SitePermissions.php', + 'Kirby\\Cms\\SiteRules' => $baseDir . '/kirby/src/Cms/SiteRules.php', + 'Kirby\\Cms\\Structure' => $baseDir . '/kirby/src/Cms/Structure.php', + 'Kirby\\Cms\\StructureObject' => $baseDir . '/kirby/src/Cms/StructureObject.php', + 'Kirby\\Cms\\System' => $baseDir . '/kirby/src/Cms/System.php', + 'Kirby\\Cms\\System\\UpdateStatus' => $baseDir . '/kirby/src/Cms/System/UpdateStatus.php', + 'Kirby\\Cms\\Translation' => $baseDir . '/kirby/src/Cms/Translation.php', + 'Kirby\\Cms\\Translations' => $baseDir . '/kirby/src/Cms/Translations.php', + 'Kirby\\Cms\\Url' => $baseDir . '/kirby/src/Cms/Url.php', + 'Kirby\\Cms\\User' => $baseDir . '/kirby/src/Cms/User.php', + 'Kirby\\Cms\\UserActions' => $baseDir . '/kirby/src/Cms/UserActions.php', + 'Kirby\\Cms\\UserBlueprint' => $baseDir . '/kirby/src/Cms/UserBlueprint.php', + 'Kirby\\Cms\\UserPermissions' => $baseDir . '/kirby/src/Cms/UserPermissions.php', + 'Kirby\\Cms\\UserPicker' => $baseDir . '/kirby/src/Cms/UserPicker.php', + 'Kirby\\Cms\\UserRules' => $baseDir . '/kirby/src/Cms/UserRules.php', + 'Kirby\\Cms\\Users' => $baseDir . '/kirby/src/Cms/Users.php', + 'Kirby\\Cms\\Visitor' => $baseDir . '/kirby/src/Cms/Visitor.php', + 'Kirby\\ComposerInstaller\\CmsInstaller' => $vendorDir . '/getkirby/composer-installer/src/ComposerInstaller/CmsInstaller.php', + 'Kirby\\ComposerInstaller\\Installer' => $vendorDir . '/getkirby/composer-installer/src/ComposerInstaller/Installer.php', + 'Kirby\\ComposerInstaller\\Plugin' => $vendorDir . '/getkirby/composer-installer/src/ComposerInstaller/Plugin.php', + 'Kirby\\ComposerInstaller\\PluginInstaller' => $vendorDir . '/getkirby/composer-installer/src/ComposerInstaller/PluginInstaller.php', + 'Kirby\\Content\\Changes' => $baseDir . '/kirby/src/Content/Changes.php', + 'Kirby\\Content\\Content' => $baseDir . '/kirby/src/Content/Content.php', + 'Kirby\\Content\\Field' => $baseDir . '/kirby/src/Content/Field.php', + 'Kirby\\Content\\ImmutableMemoryStorage' => $baseDir . '/kirby/src/Content/ImmutableMemoryStorage.php', + 'Kirby\\Content\\Lock' => $baseDir . '/kirby/src/Content/Lock.php', + 'Kirby\\Content\\LockedContentException' => $baseDir . '/kirby/src/Content/LockedContentException.php', + 'Kirby\\Content\\MemoryStorage' => $baseDir . '/kirby/src/Content/MemoryStorage.php', + 'Kirby\\Content\\PlainTextStorage' => $baseDir . '/kirby/src/Content/PlainTextStorage.php', + 'Kirby\\Content\\Storage' => $baseDir . '/kirby/src/Content/Storage.php', + 'Kirby\\Content\\Translation' => $baseDir . '/kirby/src/Content/Translation.php', + 'Kirby\\Content\\Translations' => $baseDir . '/kirby/src/Content/Translations.php', + 'Kirby\\Content\\Version' => $baseDir . '/kirby/src/Content/Version.php', + 'Kirby\\Content\\VersionCache' => $baseDir . '/kirby/src/Content/VersionCache.php', + 'Kirby\\Content\\VersionId' => $baseDir . '/kirby/src/Content/VersionId.php', + 'Kirby\\Content\\VersionRules' => $baseDir . '/kirby/src/Content/VersionRules.php', + 'Kirby\\Content\\Versions' => $baseDir . '/kirby/src/Content/Versions.php', + 'Kirby\\Data\\Data' => $baseDir . '/kirby/src/Data/Data.php', + 'Kirby\\Data\\Handler' => $baseDir . '/kirby/src/Data/Handler.php', + 'Kirby\\Data\\Json' => $baseDir . '/kirby/src/Data/Json.php', + 'Kirby\\Data\\PHP' => $baseDir . '/kirby/src/Data/PHP.php', + 'Kirby\\Data\\Txt' => $baseDir . '/kirby/src/Data/Txt.php', + 'Kirby\\Data\\Xml' => $baseDir . '/kirby/src/Data/Xml.php', + 'Kirby\\Data\\Yaml' => $baseDir . '/kirby/src/Data/Yaml.php', + 'Kirby\\Data\\YamlSpyc' => $baseDir . '/kirby/src/Data/YamlSpyc.php', + 'Kirby\\Data\\YamlSymfony' => $baseDir . '/kirby/src/Data/YamlSymfony.php', + 'Kirby\\Database\\Database' => $baseDir . '/kirby/src/Database/Database.php', + 'Kirby\\Database\\Db' => $baseDir . '/kirby/src/Database/Db.php', + 'Kirby\\Database\\Query' => $baseDir . '/kirby/src/Database/Query.php', + 'Kirby\\Database\\Sql' => $baseDir . '/kirby/src/Database/Sql.php', + 'Kirby\\Database\\Sql\\Mysql' => $baseDir . '/kirby/src/Database/Sql/Mysql.php', + 'Kirby\\Database\\Sql\\Sqlite' => $baseDir . '/kirby/src/Database/Sql/Sqlite.php', + 'Kirby\\Email\\Body' => $baseDir . '/kirby/src/Email/Body.php', + 'Kirby\\Email\\Email' => $baseDir . '/kirby/src/Email/Email.php', + 'Kirby\\Email\\PHPMailer' => $baseDir . '/kirby/src/Email/PHPMailer.php', + 'Kirby\\Exception\\AuthException' => $baseDir . '/kirby/src/Exception/AuthException.php', + 'Kirby\\Exception\\BadMethodCallException' => $baseDir . '/kirby/src/Exception/BadMethodCallException.php', + 'Kirby\\Exception\\DuplicateException' => $baseDir . '/kirby/src/Exception/DuplicateException.php', + 'Kirby\\Exception\\ErrorPageException' => $baseDir . '/kirby/src/Exception/ErrorPageException.php', + 'Kirby\\Exception\\Exception' => $baseDir . '/kirby/src/Exception/Exception.php', + 'Kirby\\Exception\\InvalidArgumentException' => $baseDir . '/kirby/src/Exception/InvalidArgumentException.php', + 'Kirby\\Exception\\LogicException' => $baseDir . '/kirby/src/Exception/LogicException.php', + 'Kirby\\Exception\\NotFoundException' => $baseDir . '/kirby/src/Exception/NotFoundException.php', + 'Kirby\\Exception\\PermissionException' => $baseDir . '/kirby/src/Exception/PermissionException.php', + 'Kirby\\Field\\FieldOptions' => $baseDir . '/kirby/src/Field/FieldOptions.php', + 'Kirby\\Filesystem\\Asset' => $baseDir . '/kirby/src/Filesystem/Asset.php', + 'Kirby\\Filesystem\\Dir' => $baseDir . '/kirby/src/Filesystem/Dir.php', + 'Kirby\\Filesystem\\F' => $baseDir . '/kirby/src/Filesystem/F.php', + 'Kirby\\Filesystem\\File' => $baseDir . '/kirby/src/Filesystem/File.php', + 'Kirby\\Filesystem\\Filename' => $baseDir . '/kirby/src/Filesystem/Filename.php', + 'Kirby\\Filesystem\\IsFile' => $baseDir . '/kirby/src/Filesystem/IsFile.php', + 'Kirby\\Filesystem\\Mime' => $baseDir . '/kirby/src/Filesystem/Mime.php', + 'Kirby\\Form\\Field' => $baseDir . '/kirby/src/Form/Field.php', + 'Kirby\\Form\\FieldClass' => $baseDir . '/kirby/src/Form/FieldClass.php', + 'Kirby\\Form\\Field\\BlocksField' => $baseDir . '/kirby/src/Form/Field/BlocksField.php', + 'Kirby\\Form\\Field\\EntriesField' => $baseDir . '/kirby/src/Form/Field/EntriesField.php', + 'Kirby\\Form\\Field\\LayoutField' => $baseDir . '/kirby/src/Form/Field/LayoutField.php', + 'Kirby\\Form\\Field\\StatsField' => $baseDir . '/kirby/src/Form/Field/StatsField.php', + 'Kirby\\Form\\Fields' => $baseDir . '/kirby/src/Form/Fields.php', + 'Kirby\\Form\\Form' => $baseDir . '/kirby/src/Form/Form.php', + 'Kirby\\Form\\Mixin\\After' => $baseDir . '/kirby/src/Form/Mixin/After.php', + 'Kirby\\Form\\Mixin\\Api' => $baseDir . '/kirby/src/Form/Mixin/Api.php', + 'Kirby\\Form\\Mixin\\Autofocus' => $baseDir . '/kirby/src/Form/Mixin/Autofocus.php', + 'Kirby\\Form\\Mixin\\Before' => $baseDir . '/kirby/src/Form/Mixin/Before.php', + 'Kirby\\Form\\Mixin\\EmptyState' => $baseDir . '/kirby/src/Form/Mixin/EmptyState.php', + 'Kirby\\Form\\Mixin\\Help' => $baseDir . '/kirby/src/Form/Mixin/Help.php', + 'Kirby\\Form\\Mixin\\Icon' => $baseDir . '/kirby/src/Form/Mixin/Icon.php', + 'Kirby\\Form\\Mixin\\Label' => $baseDir . '/kirby/src/Form/Mixin/Label.php', + 'Kirby\\Form\\Mixin\\Max' => $baseDir . '/kirby/src/Form/Mixin/Max.php', + 'Kirby\\Form\\Mixin\\Min' => $baseDir . '/kirby/src/Form/Mixin/Min.php', + 'Kirby\\Form\\Mixin\\Model' => $baseDir . '/kirby/src/Form/Mixin/Model.php', + 'Kirby\\Form\\Mixin\\Placeholder' => $baseDir . '/kirby/src/Form/Mixin/Placeholder.php', + 'Kirby\\Form\\Mixin\\Translatable' => $baseDir . '/kirby/src/Form/Mixin/Translatable.php', + 'Kirby\\Form\\Mixin\\Validation' => $baseDir . '/kirby/src/Form/Mixin/Validation.php', + 'Kirby\\Form\\Mixin\\Value' => $baseDir . '/kirby/src/Form/Mixin/Value.php', + 'Kirby\\Form\\Mixin\\When' => $baseDir . '/kirby/src/Form/Mixin/When.php', + 'Kirby\\Form\\Mixin\\Width' => $baseDir . '/kirby/src/Form/Mixin/Width.php', + 'Kirby\\Form\\Validations' => $baseDir . '/kirby/src/Form/Validations.php', + 'Kirby\\Http\\Cookie' => $baseDir . '/kirby/src/Http/Cookie.php', + 'Kirby\\Http\\Environment' => $baseDir . '/kirby/src/Http/Environment.php', + 'Kirby\\Http\\Exceptions\\NextRouteException' => $baseDir . '/kirby/src/Http/Exceptions/NextRouteException.php', + 'Kirby\\Http\\Header' => $baseDir . '/kirby/src/Http/Header.php', + 'Kirby\\Http\\Idn' => $baseDir . '/kirby/src/Http/Idn.php', + 'Kirby\\Http\\Params' => $baseDir . '/kirby/src/Http/Params.php', + 'Kirby\\Http\\Path' => $baseDir . '/kirby/src/Http/Path.php', + 'Kirby\\Http\\Query' => $baseDir . '/kirby/src/Http/Query.php', + 'Kirby\\Http\\Remote' => $baseDir . '/kirby/src/Http/Remote.php', + 'Kirby\\Http\\Request' => $baseDir . '/kirby/src/Http/Request.php', + 'Kirby\\Http\\Request\\Auth' => $baseDir . '/kirby/src/Http/Request/Auth.php', + 'Kirby\\Http\\Request\\Auth\\BasicAuth' => $baseDir . '/kirby/src/Http/Request/Auth/BasicAuth.php', + 'Kirby\\Http\\Request\\Auth\\BearerAuth' => $baseDir . '/kirby/src/Http/Request/Auth/BearerAuth.php', + 'Kirby\\Http\\Request\\Auth\\SessionAuth' => $baseDir . '/kirby/src/Http/Request/Auth/SessionAuth.php', + 'Kirby\\Http\\Request\\Body' => $baseDir . '/kirby/src/Http/Request/Body.php', + 'Kirby\\Http\\Request\\Data' => $baseDir . '/kirby/src/Http/Request/Data.php', + 'Kirby\\Http\\Request\\Files' => $baseDir . '/kirby/src/Http/Request/Files.php', + 'Kirby\\Http\\Request\\Query' => $baseDir . '/kirby/src/Http/Request/Query.php', + 'Kirby\\Http\\Response' => $baseDir . '/kirby/src/Http/Response.php', + 'Kirby\\Http\\Route' => $baseDir . '/kirby/src/Http/Route.php', + 'Kirby\\Http\\Router' => $baseDir . '/kirby/src/Http/Router.php', + 'Kirby\\Http\\Uri' => $baseDir . '/kirby/src/Http/Uri.php', + 'Kirby\\Http\\Url' => $baseDir . '/kirby/src/Http/Url.php', + 'Kirby\\Http\\Visitor' => $baseDir . '/kirby/src/Http/Visitor.php', + 'Kirby\\Image\\Camera' => $baseDir . '/kirby/src/Image/Camera.php', + 'Kirby\\Image\\Darkroom' => $baseDir . '/kirby/src/Image/Darkroom.php', + 'Kirby\\Image\\Darkroom\\GdLib' => $baseDir . '/kirby/src/Image/Darkroom/GdLib.php', + 'Kirby\\Image\\Darkroom\\ImageMagick' => $baseDir . '/kirby/src/Image/Darkroom/ImageMagick.php', + 'Kirby\\Image\\Darkroom\\Imagick' => $baseDir . '/kirby/src/Image/Darkroom/Imagick.php', + 'Kirby\\Image\\Dimensions' => $baseDir . '/kirby/src/Image/Dimensions.php', + 'Kirby\\Image\\Exif' => $baseDir . '/kirby/src/Image/Exif.php', + 'Kirby\\Image\\Focus' => $baseDir . '/kirby/src/Image/Focus.php', + 'Kirby\\Image\\Image' => $baseDir . '/kirby/src/Image/Image.php', + 'Kirby\\Image\\Location' => $baseDir . '/kirby/src/Image/Location.php', + 'Kirby\\Image\\QrCode' => $baseDir . '/kirby/src/Image/QrCode.php', + 'Kirby\\Option\\Option' => $baseDir . '/kirby/src/Option/Option.php', + 'Kirby\\Option\\Options' => $baseDir . '/kirby/src/Option/Options.php', + 'Kirby\\Option\\OptionsApi' => $baseDir . '/kirby/src/Option/OptionsApi.php', + 'Kirby\\Option\\OptionsProvider' => $baseDir . '/kirby/src/Option/OptionsProvider.php', + 'Kirby\\Option\\OptionsQuery' => $baseDir . '/kirby/src/Option/OptionsQuery.php', + 'Kirby\\Panel\\Assets' => $baseDir . '/kirby/src/Panel/Assets.php', + 'Kirby\\Panel\\ChangesDialog' => $baseDir . '/kirby/src/Panel/ChangesDialog.php', + 'Kirby\\Panel\\Collector\\FilesCollector' => $baseDir . '/kirby/src/Panel/Collector/FilesCollector.php', + 'Kirby\\Panel\\Collector\\ModelsCollector' => $baseDir . '/kirby/src/Panel/Collector/ModelsCollector.php', + 'Kirby\\Panel\\Collector\\PagesCollector' => $baseDir . '/kirby/src/Panel/Collector/PagesCollector.php', + 'Kirby\\Panel\\Collector\\UsersCollector' => $baseDir . '/kirby/src/Panel/Collector/UsersCollector.php', + 'Kirby\\Panel\\Controller\\PageTree' => $baseDir . '/kirby/src/Panel/Controller/PageTree.php', + 'Kirby\\Panel\\Controller\\Search' => $baseDir . '/kirby/src/Panel/Controller/Search.php', + 'Kirby\\Panel\\Dialog' => $baseDir . '/kirby/src/Panel/Dialog.php', + 'Kirby\\Panel\\Document' => $baseDir . '/kirby/src/Panel/Document.php', + 'Kirby\\Panel\\Drawer' => $baseDir . '/kirby/src/Panel/Drawer.php', + 'Kirby\\Panel\\Dropdown' => $baseDir . '/kirby/src/Panel/Dropdown.php', + 'Kirby\\Panel\\Field' => $baseDir . '/kirby/src/Panel/Field.php', + 'Kirby\\Panel\\File' => $baseDir . '/kirby/src/Panel/File.php', + 'Kirby\\Panel\\Home' => $baseDir . '/kirby/src/Panel/Home.php', + 'Kirby\\Panel\\Json' => $baseDir . '/kirby/src/Panel/Json.php', + 'Kirby\\Panel\\Lab\\Category' => $baseDir . '/kirby/src/Panel/Lab/Category.php', + 'Kirby\\Panel\\Lab\\Doc' => $baseDir . '/kirby/src/Panel/Lab/Doc.php', + 'Kirby\\Panel\\Lab\\Doc\\Argument' => $baseDir . '/kirby/src/Panel/Lab/Doc/Argument.php', + 'Kirby\\Panel\\Lab\\Doc\\Event' => $baseDir . '/kirby/src/Panel/Lab/Doc/Event.php', + 'Kirby\\Panel\\Lab\\Doc\\Method' => $baseDir . '/kirby/src/Panel/Lab/Doc/Method.php', + 'Kirby\\Panel\\Lab\\Doc\\Prop' => $baseDir . '/kirby/src/Panel/Lab/Doc/Prop.php', + 'Kirby\\Panel\\Lab\\Doc\\Slot' => $baseDir . '/kirby/src/Panel/Lab/Doc/Slot.php', + 'Kirby\\Panel\\Lab\\Docs' => $baseDir . '/kirby/src/Panel/Lab/Docs.php', + 'Kirby\\Panel\\Lab\\Example' => $baseDir . '/kirby/src/Panel/Lab/Example.php', + 'Kirby\\Panel\\Lab\\Snippet' => $baseDir . '/kirby/src/Panel/Lab/Snippet.php', + 'Kirby\\Panel\\Lab\\Template' => $baseDir . '/kirby/src/Panel/Lab/Template.php', + 'Kirby\\Panel\\Menu' => $baseDir . '/kirby/src/Panel/Menu.php', + 'Kirby\\Panel\\Model' => $baseDir . '/kirby/src/Panel/Model.php', + 'Kirby\\Panel\\Page' => $baseDir . '/kirby/src/Panel/Page.php', + 'Kirby\\Panel\\PageCreateDialog' => $baseDir . '/kirby/src/Panel/PageCreateDialog.php', + 'Kirby\\Panel\\Panel' => $baseDir . '/kirby/src/Panel/Panel.php', + 'Kirby\\Panel\\Plugins' => $baseDir . '/kirby/src/Panel/Plugins.php', + 'Kirby\\Panel\\Redirect' => $baseDir . '/kirby/src/Panel/Redirect.php', + 'Kirby\\Panel\\Request' => $baseDir . '/kirby/src/Panel/Request.php', + 'Kirby\\Panel\\Search' => $baseDir . '/kirby/src/Panel/Search.php', + 'Kirby\\Panel\\Site' => $baseDir . '/kirby/src/Panel/Site.php', + 'Kirby\\Panel\\Ui\\Button' => $baseDir . '/kirby/src/Panel/Ui/Button.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguageCreateButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguageDeleteButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguageSettingsButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguagesDropdown' => $baseDir . '/kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php', + 'Kirby\\Panel\\Ui\\Buttons\\OpenButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/OpenButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\PageStatusButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/PageStatusButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\PreviewButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/PreviewButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\SettingsButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/SettingsButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\VersionsButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/VersionsButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\ViewButton' => $baseDir . '/kirby/src/Panel/Ui/Buttons/ViewButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\ViewButtons' => $baseDir . '/kirby/src/Panel/Ui/Buttons/ViewButtons.php', + 'Kirby\\Panel\\Ui\\Component' => $baseDir . '/kirby/src/Panel/Ui/Component.php', + 'Kirby\\Panel\\Ui\\FilePreview' => $baseDir . '/kirby/src/Panel/Ui/FilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\AudioFilePreview' => $baseDir . '/kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\DefaultFilePreview' => $baseDir . '/kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\ImageFilePreview' => $baseDir . '/kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\PdfFilePreview' => $baseDir . '/kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\VideoFilePreview' => $baseDir . '/kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php', + 'Kirby\\Panel\\Ui\\Item\\FileItem' => $baseDir . '/kirby/src/Panel/Ui/Item/FileItem.php', + 'Kirby\\Panel\\Ui\\Item\\ModelItem' => $baseDir . '/kirby/src/Panel/Ui/Item/ModelItem.php', + 'Kirby\\Panel\\Ui\\Item\\PageItem' => $baseDir . '/kirby/src/Panel/Ui/Item/PageItem.php', + 'Kirby\\Panel\\Ui\\Item\\UserItem' => $baseDir . '/kirby/src/Panel/Ui/Item/UserItem.php', + 'Kirby\\Panel\\Ui\\Stat' => $baseDir . '/kirby/src/Panel/Ui/Stat.php', + 'Kirby\\Panel\\Ui\\Stats' => $baseDir . '/kirby/src/Panel/Ui/Stats.php', + 'Kirby\\Panel\\Ui\\Upload' => $baseDir . '/kirby/src/Panel/Ui/Upload.php', + 'Kirby\\Panel\\User' => $baseDir . '/kirby/src/Panel/User.php', + 'Kirby\\Panel\\UserTotpDisableDialog' => $baseDir . '/kirby/src/Panel/UserTotpDisableDialog.php', + 'Kirby\\Panel\\UserTotpEnableDialog' => $baseDir . '/kirby/src/Panel/UserTotpEnableDialog.php', + 'Kirby\\Panel\\View' => $baseDir . '/kirby/src/Panel/View.php', + 'Kirby\\Parsley\\Element' => $baseDir . '/kirby/src/Parsley/Element.php', + 'Kirby\\Parsley\\Inline' => $baseDir . '/kirby/src/Parsley/Inline.php', + 'Kirby\\Parsley\\Parsley' => $baseDir . '/kirby/src/Parsley/Parsley.php', + 'Kirby\\Parsley\\Schema' => $baseDir . '/kirby/src/Parsley/Schema.php', + 'Kirby\\Parsley\\Schema\\Blocks' => $baseDir . '/kirby/src/Parsley/Schema/Blocks.php', + 'Kirby\\Parsley\\Schema\\Plain' => $baseDir . '/kirby/src/Parsley/Schema/Plain.php', + 'Kirby\\Plugin\\Asset' => $baseDir . '/kirby/src/Plugin/Asset.php', + 'Kirby\\Plugin\\Assets' => $baseDir . '/kirby/src/Plugin/Assets.php', + 'Kirby\\Plugin\\License' => $baseDir . '/kirby/src/Plugin/License.php', + 'Kirby\\Plugin\\LicenseStatus' => $baseDir . '/kirby/src/Plugin/LicenseStatus.php', + 'Kirby\\Plugin\\Plugin' => $baseDir . '/kirby/src/Plugin/Plugin.php', + 'Kirby\\Query\\AST\\ArgumentListNode' => $baseDir . '/kirby/src/Query/AST/ArgumentListNode.php', + 'Kirby\\Query\\AST\\ArithmeticNode' => $baseDir . '/kirby/src/Query/AST/ArithmeticNode.php', + 'Kirby\\Query\\AST\\ArrayListNode' => $baseDir . '/kirby/src/Query/AST/ArrayListNode.php', + 'Kirby\\Query\\AST\\ClosureNode' => $baseDir . '/kirby/src/Query/AST/ClosureNode.php', + 'Kirby\\Query\\AST\\CoalesceNode' => $baseDir . '/kirby/src/Query/AST/CoalesceNode.php', + 'Kirby\\Query\\AST\\ComparisonNode' => $baseDir . '/kirby/src/Query/AST/ComparisonNode.php', + 'Kirby\\Query\\AST\\GlobalFunctionNode' => $baseDir . '/kirby/src/Query/AST/GlobalFunctionNode.php', + 'Kirby\\Query\\AST\\LiteralNode' => $baseDir . '/kirby/src/Query/AST/LiteralNode.php', + 'Kirby\\Query\\AST\\LogicalNode' => $baseDir . '/kirby/src/Query/AST/LogicalNode.php', + 'Kirby\\Query\\AST\\MemberAccessNode' => $baseDir . '/kirby/src/Query/AST/MemberAccessNode.php', + 'Kirby\\Query\\AST\\Node' => $baseDir . '/kirby/src/Query/AST/Node.php', + 'Kirby\\Query\\AST\\TernaryNode' => $baseDir . '/kirby/src/Query/AST/TernaryNode.php', + 'Kirby\\Query\\AST\\VariableNode' => $baseDir . '/kirby/src/Query/AST/VariableNode.php', + 'Kirby\\Query\\Argument' => $baseDir . '/kirby/src/Query/Argument.php', + 'Kirby\\Query\\Arguments' => $baseDir . '/kirby/src/Query/Arguments.php', + 'Kirby\\Query\\Expression' => $baseDir . '/kirby/src/Query/Expression.php', + 'Kirby\\Query\\Parser\\Parser' => $baseDir . '/kirby/src/Query/Parser/Parser.php', + 'Kirby\\Query\\Parser\\Token' => $baseDir . '/kirby/src/Query/Parser/Token.php', + 'Kirby\\Query\\Parser\\TokenType' => $baseDir . '/kirby/src/Query/Parser/TokenType.php', + 'Kirby\\Query\\Parser\\Tokenizer' => $baseDir . '/kirby/src/Query/Parser/Tokenizer.php', + 'Kirby\\Query\\Query' => $baseDir . '/kirby/src/Query/Query.php', + 'Kirby\\Query\\Runners\\DefaultRunner' => $baseDir . '/kirby/src/Query/Runners/DefaultRunner.php', + 'Kirby\\Query\\Runners\\Runner' => $baseDir . '/kirby/src/Query/Runners/Runner.php', + 'Kirby\\Query\\Runners\\Scope' => $baseDir . '/kirby/src/Query/Runners/Scope.php', + 'Kirby\\Query\\Segment' => $baseDir . '/kirby/src/Query/Segment.php', + 'Kirby\\Query\\Segments' => $baseDir . '/kirby/src/Query/Segments.php', + 'Kirby\\Query\\Visitors\\DefaultVisitor' => $baseDir . '/kirby/src/Query/Visitors/DefaultVisitor.php', + 'Kirby\\Query\\Visitors\\Visitor' => $baseDir . '/kirby/src/Query/Visitors/Visitor.php', + 'Kirby\\Sane\\DomHandler' => $baseDir . '/kirby/src/Sane/DomHandler.php', + 'Kirby\\Sane\\Handler' => $baseDir . '/kirby/src/Sane/Handler.php', + 'Kirby\\Sane\\Html' => $baseDir . '/kirby/src/Sane/Html.php', + 'Kirby\\Sane\\Sane' => $baseDir . '/kirby/src/Sane/Sane.php', + 'Kirby\\Sane\\Svg' => $baseDir . '/kirby/src/Sane/Svg.php', + 'Kirby\\Sane\\Svgz' => $baseDir . '/kirby/src/Sane/Svgz.php', + 'Kirby\\Sane\\Xml' => $baseDir . '/kirby/src/Sane/Xml.php', + 'Kirby\\Session\\AutoSession' => $baseDir . '/kirby/src/Session/AutoSession.php', + 'Kirby\\Session\\FileSessionStore' => $baseDir . '/kirby/src/Session/FileSessionStore.php', + 'Kirby\\Session\\Session' => $baseDir . '/kirby/src/Session/Session.php', + 'Kirby\\Session\\SessionData' => $baseDir . '/kirby/src/Session/SessionData.php', + 'Kirby\\Session\\SessionStore' => $baseDir . '/kirby/src/Session/SessionStore.php', + 'Kirby\\Session\\Sessions' => $baseDir . '/kirby/src/Session/Sessions.php', + 'Kirby\\Template\\Slot' => $baseDir . '/kirby/src/Template/Slot.php', + 'Kirby\\Template\\Slots' => $baseDir . '/kirby/src/Template/Slots.php', + 'Kirby\\Template\\Snippet' => $baseDir . '/kirby/src/Template/Snippet.php', + 'Kirby\\Template\\Template' => $baseDir . '/kirby/src/Template/Template.php', + 'Kirby\\Text\\KirbyTag' => $baseDir . '/kirby/src/Text/KirbyTag.php', + 'Kirby\\Text\\KirbyTags' => $baseDir . '/kirby/src/Text/KirbyTags.php', + 'Kirby\\Text\\Markdown' => $baseDir . '/kirby/src/Text/Markdown.php', + 'Kirby\\Text\\SmartyPants' => $baseDir . '/kirby/src/Text/SmartyPants.php', + 'Kirby\\Toolkit\\A' => $baseDir . '/kirby/src/Toolkit/A.php', + 'Kirby\\Toolkit\\Collection' => $baseDir . '/kirby/src/Toolkit/Collection.php', + 'Kirby\\Toolkit\\Component' => $baseDir . '/kirby/src/Toolkit/Component.php', + 'Kirby\\Toolkit\\Config' => $baseDir . '/kirby/src/Toolkit/Config.php', + 'Kirby\\Toolkit\\Controller' => $baseDir . '/kirby/src/Toolkit/Controller.php', + 'Kirby\\Toolkit\\Date' => $baseDir . '/kirby/src/Toolkit/Date.php', + 'Kirby\\Toolkit\\Dom' => $baseDir . '/kirby/src/Toolkit/Dom.php', + 'Kirby\\Toolkit\\Escape' => $baseDir . '/kirby/src/Toolkit/Escape.php', + 'Kirby\\Toolkit\\Facade' => $baseDir . '/kirby/src/Toolkit/Facade.php', + 'Kirby\\Toolkit\\Html' => $baseDir . '/kirby/src/Toolkit/Html.php', + 'Kirby\\Toolkit\\I18n' => $baseDir . '/kirby/src/Toolkit/I18n.php', + 'Kirby\\Toolkit\\Iterator' => $baseDir . '/kirby/src/Toolkit/Iterator.php', + 'Kirby\\Toolkit\\LazyValue' => $baseDir . '/kirby/src/Toolkit/LazyValue.php', + 'Kirby\\Toolkit\\Locale' => $baseDir . '/kirby/src/Toolkit/Locale.php', + 'Kirby\\Toolkit\\Obj' => $baseDir . '/kirby/src/Toolkit/Obj.php', + 'Kirby\\Toolkit\\Pagination' => $baseDir . '/kirby/src/Toolkit/Pagination.php', + 'Kirby\\Toolkit\\Silo' => $baseDir . '/kirby/src/Toolkit/Silo.php', + 'Kirby\\Toolkit\\Str' => $baseDir . '/kirby/src/Toolkit/Str.php', + 'Kirby\\Toolkit\\SymmetricCrypto' => $baseDir . '/kirby/src/Toolkit/SymmetricCrypto.php', + 'Kirby\\Toolkit\\Totp' => $baseDir . '/kirby/src/Toolkit/Totp.php', + 'Kirby\\Toolkit\\Tpl' => $baseDir . '/kirby/src/Toolkit/Tpl.php', + 'Kirby\\Toolkit\\V' => $baseDir . '/kirby/src/Toolkit/V.php', + 'Kirby\\Toolkit\\View' => $baseDir . '/kirby/src/Toolkit/View.php', + 'Kirby\\Toolkit\\Xml' => $baseDir . '/kirby/src/Toolkit/Xml.php', + 'Kirby\\Uuid\\BlockUuid' => $baseDir . '/kirby/src/Uuid/BlockUuid.php', + 'Kirby\\Uuid\\FieldUuid' => $baseDir . '/kirby/src/Uuid/FieldUuid.php', + 'Kirby\\Uuid\\FileUuid' => $baseDir . '/kirby/src/Uuid/FileUuid.php', + 'Kirby\\Uuid\\HasUuids' => $baseDir . '/kirby/src/Uuid/HasUuids.php', + 'Kirby\\Uuid\\Identifiable' => $baseDir . '/kirby/src/Uuid/Identifiable.php', + 'Kirby\\Uuid\\ModelUuid' => $baseDir . '/kirby/src/Uuid/ModelUuid.php', + 'Kirby\\Uuid\\PageUuid' => $baseDir . '/kirby/src/Uuid/PageUuid.php', + 'Kirby\\Uuid\\SiteUuid' => $baseDir . '/kirby/src/Uuid/SiteUuid.php', + 'Kirby\\Uuid\\StructureUuid' => $baseDir . '/kirby/src/Uuid/StructureUuid.php', + 'Kirby\\Uuid\\Uri' => $baseDir . '/kirby/src/Uuid/Uri.php', + 'Kirby\\Uuid\\UserUuid' => $baseDir . '/kirby/src/Uuid/UserUuid.php', + 'Kirby\\Uuid\\Uuid' => $baseDir . '/kirby/src/Uuid/Uuid.php', + 'Kirby\\Uuid\\Uuids' => $baseDir . '/kirby/src/Uuid/Uuids.php', + 'Laminas\\Escaper\\Escaper' => $vendorDir . '/laminas/laminas-escaper/src/Escaper.php', + 'Laminas\\Escaper\\EscaperInterface' => $vendorDir . '/laminas/laminas-escaper/src/EscaperInterface.php', + 'Laminas\\Escaper\\Exception\\ExceptionInterface' => $vendorDir . '/laminas/laminas-escaper/src/Exception/ExceptionInterface.php', + 'Laminas\\Escaper\\Exception\\InvalidArgumentException' => $vendorDir . '/laminas/laminas-escaper/src/Exception/InvalidArgumentException.php', + 'Laminas\\Escaper\\Exception\\RuntimeException' => $vendorDir . '/laminas/laminas-escaper/src/Exception/RuntimeException.php', + 'League\\ColorExtractor\\Color' => $vendorDir . '/league/color-extractor/src/Color.php', + 'League\\ColorExtractor\\ColorExtractor' => $vendorDir . '/league/color-extractor/src/ColorExtractor.php', + 'League\\ColorExtractor\\Palette' => $vendorDir . '/league/color-extractor/src/Palette.php', + 'Michelf\\SmartyPants' => $vendorDir . '/michelf/php-smartypants/Michelf/SmartyPants.php', + 'Michelf\\SmartyPantsTypographer' => $vendorDir . '/michelf/php-smartypants/Michelf/SmartyPantsTypographer.php', + 'Normalizer' => $vendorDir . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php', + 'PHPMailer\\PHPMailer\\DSNConfigurator' => $vendorDir . '/phpmailer/phpmailer/src/DSNConfigurator.php', + 'PHPMailer\\PHPMailer\\Exception' => $vendorDir . '/phpmailer/phpmailer/src/Exception.php', + 'PHPMailer\\PHPMailer\\OAuth' => $vendorDir . '/phpmailer/phpmailer/src/OAuth.php', + 'PHPMailer\\PHPMailer\\OAuthTokenProvider' => $vendorDir . '/phpmailer/phpmailer/src/OAuthTokenProvider.php', + 'PHPMailer\\PHPMailer\\PHPMailer' => $vendorDir . '/phpmailer/phpmailer/src/PHPMailer.php', + 'PHPMailer\\PHPMailer\\POP3' => $vendorDir . '/phpmailer/phpmailer/src/POP3.php', + 'PHPMailer\\PHPMailer\\SMTP' => $vendorDir . '/phpmailer/phpmailer/src/SMTP.php', + 'Parsedown' => $baseDir . '/kirby/dependencies/parsedown/Parsedown.php', + 'ParsedownExtra' => $baseDir . '/kirby/dependencies/parsedown-extra/ParsedownExtra.php', + 'Psr\\Log\\AbstractLogger' => $vendorDir . '/psr/log/src/AbstractLogger.php', + 'Psr\\Log\\InvalidArgumentException' => $vendorDir . '/psr/log/src/InvalidArgumentException.php', + 'Psr\\Log\\LogLevel' => $vendorDir . '/psr/log/src/LogLevel.php', + 'Psr\\Log\\LoggerAwareInterface' => $vendorDir . '/psr/log/src/LoggerAwareInterface.php', + 'Psr\\Log\\LoggerAwareTrait' => $vendorDir . '/psr/log/src/LoggerAwareTrait.php', + 'Psr\\Log\\LoggerInterface' => $vendorDir . '/psr/log/src/LoggerInterface.php', + 'Psr\\Log\\LoggerTrait' => $vendorDir . '/psr/log/src/LoggerTrait.php', + 'Psr\\Log\\NullLogger' => $vendorDir . '/psr/log/src/NullLogger.php', + 'Spyc' => $baseDir . '/kirby/dependencies/spyc/Spyc.php', + 'Symfony\\Component\\Yaml\\Command\\LintCommand' => $vendorDir . '/symfony/yaml/Command/LintCommand.php', + 'Symfony\\Component\\Yaml\\Dumper' => $vendorDir . '/symfony/yaml/Dumper.php', + 'Symfony\\Component\\Yaml\\Escaper' => $vendorDir . '/symfony/yaml/Escaper.php', + 'Symfony\\Component\\Yaml\\Exception\\DumpException' => $vendorDir . '/symfony/yaml/Exception/DumpException.php', + 'Symfony\\Component\\Yaml\\Exception\\ExceptionInterface' => $vendorDir . '/symfony/yaml/Exception/ExceptionInterface.php', + 'Symfony\\Component\\Yaml\\Exception\\ParseException' => $vendorDir . '/symfony/yaml/Exception/ParseException.php', + 'Symfony\\Component\\Yaml\\Exception\\RuntimeException' => $vendorDir . '/symfony/yaml/Exception/RuntimeException.php', + 'Symfony\\Component\\Yaml\\Inline' => $vendorDir . '/symfony/yaml/Inline.php', + 'Symfony\\Component\\Yaml\\Parser' => $vendorDir . '/symfony/yaml/Parser.php', + 'Symfony\\Component\\Yaml\\Tag\\TaggedValue' => $vendorDir . '/symfony/yaml/Tag/TaggedValue.php', + 'Symfony\\Component\\Yaml\\Unescaper' => $vendorDir . '/symfony/yaml/Unescaper.php', + 'Symfony\\Component\\Yaml\\Yaml' => $vendorDir . '/symfony/yaml/Yaml.php', + 'Symfony\\Polyfill\\Ctype\\Ctype' => $vendorDir . '/symfony/polyfill-ctype/Ctype.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Idn' => $vendorDir . '/symfony/polyfill-intl-idn/Idn.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Info' => $vendorDir . '/symfony/polyfill-intl-idn/Info.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Resources\\unidata\\DisallowedRanges' => $vendorDir . '/symfony/polyfill-intl-idn/Resources/unidata/DisallowedRanges.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Resources\\unidata\\Regex' => $vendorDir . '/symfony/polyfill-intl-idn/Resources/unidata/Regex.php', + 'Symfony\\Polyfill\\Intl\\Normalizer\\Normalizer' => $vendorDir . '/symfony/polyfill-intl-normalizer/Normalizer.php', + 'Symfony\\Polyfill\\Mbstring\\Mbstring' => $vendorDir . '/symfony/polyfill-mbstring/Mbstring.php', + 'Whoops\\Exception\\ErrorException' => $vendorDir . '/filp/whoops/src/Whoops/Exception/ErrorException.php', + 'Whoops\\Exception\\Formatter' => $vendorDir . '/filp/whoops/src/Whoops/Exception/Formatter.php', + 'Whoops\\Exception\\Frame' => $vendorDir . '/filp/whoops/src/Whoops/Exception/Frame.php', + 'Whoops\\Exception\\FrameCollection' => $vendorDir . '/filp/whoops/src/Whoops/Exception/FrameCollection.php', + 'Whoops\\Exception\\Inspector' => $vendorDir . '/filp/whoops/src/Whoops/Exception/Inspector.php', + 'Whoops\\Handler\\CallbackHandler' => $vendorDir . '/filp/whoops/src/Whoops/Handler/CallbackHandler.php', + 'Whoops\\Handler\\Handler' => $vendorDir . '/filp/whoops/src/Whoops/Handler/Handler.php', + 'Whoops\\Handler\\HandlerInterface' => $vendorDir . '/filp/whoops/src/Whoops/Handler/HandlerInterface.php', + 'Whoops\\Handler\\JsonResponseHandler' => $vendorDir . '/filp/whoops/src/Whoops/Handler/JsonResponseHandler.php', + 'Whoops\\Handler\\PlainTextHandler' => $vendorDir . '/filp/whoops/src/Whoops/Handler/PlainTextHandler.php', + 'Whoops\\Handler\\PrettyPageHandler' => $vendorDir . '/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php', + 'Whoops\\Handler\\XmlResponseHandler' => $vendorDir . '/filp/whoops/src/Whoops/Handler/XmlResponseHandler.php', + 'Whoops\\Inspector\\InspectorFactory' => $vendorDir . '/filp/whoops/src/Whoops/Inspector/InspectorFactory.php', + 'Whoops\\Inspector\\InspectorFactoryInterface' => $vendorDir . '/filp/whoops/src/Whoops/Inspector/InspectorFactoryInterface.php', + 'Whoops\\Inspector\\InspectorInterface' => $vendorDir . '/filp/whoops/src/Whoops/Inspector/InspectorInterface.php', + 'Whoops\\Run' => $vendorDir . '/filp/whoops/src/Whoops/Run.php', + 'Whoops\\RunInterface' => $vendorDir . '/filp/whoops/src/Whoops/RunInterface.php', + 'Whoops\\Util\\HtmlDumperOutput' => $vendorDir . '/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php', + 'Whoops\\Util\\Misc' => $vendorDir . '/filp/whoops/src/Whoops/Util/Misc.php', + 'Whoops\\Util\\SystemFacade' => $vendorDir . '/filp/whoops/src/Whoops/Util/SystemFacade.php', + 'Whoops\\Util\\TemplateHelper' => $vendorDir . '/filp/whoops/src/Whoops/Util/TemplateHelper.php', + 'claviska\\SimpleImage' => $vendorDir . '/claviska/simpleimage/src/claviska/SimpleImage.php', +); diff --git a/public/vendor/composer/autoload_files.php b/public/vendor/composer/autoload_files.php new file mode 100644 index 0000000..1b9a0ed --- /dev/null +++ b/public/vendor/composer/autoload_files.php @@ -0,0 +1,16 @@ + $vendorDir . '/symfony/deprecation-contracts/function.php', + '320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php', + 'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php', + 'f598d06aa772fa33d905e87be6398fb1' => $vendorDir . '/symfony/polyfill-intl-idn/bootstrap.php', + '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php', + 'f864ae44e8154e5ff6f4eec32f46d37f' => $baseDir . '/kirby/config/setup.php', + '87988fc7b1c1f093da22a1a3de972f3a' => $baseDir . '/kirby/config/helpers.php', +); diff --git a/public/vendor/composer/autoload_namespaces.php b/public/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000..f67d4ab --- /dev/null +++ b/public/vendor/composer/autoload_namespaces.php @@ -0,0 +1,11 @@ + array($vendorDir . '/claviska/simpleimage/src'), + 'Michelf' => array($vendorDir . '/michelf/php-smartypants'), +); diff --git a/public/vendor/composer/autoload_psr4.php b/public/vendor/composer/autoload_psr4.php new file mode 100644 index 0000000..b44265d --- /dev/null +++ b/public/vendor/composer/autoload_psr4.php @@ -0,0 +1,22 @@ + array($vendorDir . '/filp/whoops/src/Whoops'), + 'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'), + 'Symfony\\Polyfill\\Intl\\Normalizer\\' => array($vendorDir . '/symfony/polyfill-intl-normalizer'), + 'Symfony\\Polyfill\\Intl\\Idn\\' => array($vendorDir . '/symfony/polyfill-intl-idn'), + 'Symfony\\Polyfill\\Ctype\\' => array($vendorDir . '/symfony/polyfill-ctype'), + 'Symfony\\Component\\Yaml\\' => array($vendorDir . '/symfony/yaml'), + 'Psr\\Log\\' => array($vendorDir . '/psr/log/src'), + 'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'), + 'League\\ColorExtractor\\' => array($vendorDir . '/league/color-extractor/src'), + 'Laminas\\Escaper\\' => array($vendorDir . '/laminas/laminas-escaper/src'), + 'Kirby\\' => array($vendorDir . '/getkirby/composer-installer/src', $baseDir . '/kirby/src'), + 'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'), + 'Base32\\' => array($vendorDir . '/christian-riesen/base32/src'), +); diff --git a/public/vendor/composer/autoload_real.php b/public/vendor/composer/autoload_real.php new file mode 100644 index 0000000..3b75ad1 --- /dev/null +++ b/public/vendor/composer/autoload_real.php @@ -0,0 +1,50 @@ +register(true); + + $filesToLoad = \Composer\Autoload\ComposerStaticInit0b7fb803e22a45eb87e24172337208aa::$files; + $requireFile = \Closure::bind(static function ($fileIdentifier, $file) { + if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { + $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; + + require $file; + } + }, null, null); + foreach ($filesToLoad as $fileIdentifier => $file) { + $requireFile($fileIdentifier, $file); + } + + return $loader; + } +} diff --git a/public/vendor/composer/autoload_static.php b/public/vendor/composer/autoload_static.php new file mode 100644 index 0000000..9ee9663 --- /dev/null +++ b/public/vendor/composer/autoload_static.php @@ -0,0 +1,632 @@ + __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php', + '320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php', + 'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php', + 'f598d06aa772fa33d905e87be6398fb1' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/bootstrap.php', + '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php', + 'f864ae44e8154e5ff6f4eec32f46d37f' => __DIR__ . '/../..' . '/kirby/config/setup.php', + '87988fc7b1c1f093da22a1a3de972f3a' => __DIR__ . '/../..' . '/kirby/config/helpers.php', + ); + + public static $prefixLengthsPsr4 = array ( + 'W' => + array ( + 'Whoops\\' => 7, + ), + 'S' => + array ( + 'Symfony\\Polyfill\\Mbstring\\' => 26, + 'Symfony\\Polyfill\\Intl\\Normalizer\\' => 33, + 'Symfony\\Polyfill\\Intl\\Idn\\' => 26, + 'Symfony\\Polyfill\\Ctype\\' => 23, + 'Symfony\\Component\\Yaml\\' => 23, + ), + 'P' => + array ( + 'Psr\\Log\\' => 8, + 'PHPMailer\\PHPMailer\\' => 20, + ), + 'L' => + array ( + 'League\\ColorExtractor\\' => 22, + 'Laminas\\Escaper\\' => 16, + ), + 'K' => + array ( + 'Kirby\\' => 6, + ), + 'C' => + array ( + 'Composer\\Semver\\' => 16, + ), + 'B' => + array ( + 'Base32\\' => 7, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'Whoops\\' => + array ( + 0 => __DIR__ . '/..' . '/filp/whoops/src/Whoops', + ), + 'Symfony\\Polyfill\\Mbstring\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring', + ), + 'Symfony\\Polyfill\\Intl\\Normalizer\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer', + ), + 'Symfony\\Polyfill\\Intl\\Idn\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/polyfill-intl-idn', + ), + 'Symfony\\Polyfill\\Ctype\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/polyfill-ctype', + ), + 'Symfony\\Component\\Yaml\\' => + array ( + 0 => __DIR__ . '/..' . '/symfony/yaml', + ), + 'Psr\\Log\\' => + array ( + 0 => __DIR__ . '/..' . '/psr/log/src', + ), + 'PHPMailer\\PHPMailer\\' => + array ( + 0 => __DIR__ . '/..' . '/phpmailer/phpmailer/src', + ), + 'League\\ColorExtractor\\' => + array ( + 0 => __DIR__ . '/..' . '/league/color-extractor/src', + ), + 'Laminas\\Escaper\\' => + array ( + 0 => __DIR__ . '/..' . '/laminas/laminas-escaper/src', + ), + 'Kirby\\' => + array ( + 0 => __DIR__ . '/..' . '/getkirby/composer-installer/src', + 1 => __DIR__ . '/../..' . '/kirby/src', + ), + 'Composer\\Semver\\' => + array ( + 0 => __DIR__ . '/..' . '/composer/semver/src', + ), + 'Base32\\' => + array ( + 0 => __DIR__ . '/..' . '/christian-riesen/base32/src', + ), + ); + + public static $prefixesPsr0 = array ( + 'c' => + array ( + 'claviska' => + array ( + 0 => __DIR__ . '/..' . '/claviska/simpleimage/src', + ), + ), + 'M' => + array ( + 'Michelf' => + array ( + 0 => __DIR__ . '/..' . '/michelf/php-smartypants', + ), + ), + ); + + public static $classMap = array ( + 'Base32\\Base32' => __DIR__ . '/..' . '/christian-riesen/base32/src/Base32.php', + 'Base32\\Base32Hex' => __DIR__ . '/..' . '/christian-riesen/base32/src/Base32Hex.php', + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'Composer\\Semver\\Comparator' => __DIR__ . '/..' . '/composer/semver/src/Comparator.php', + 'Composer\\Semver\\CompilingMatcher' => __DIR__ . '/..' . '/composer/semver/src/CompilingMatcher.php', + 'Composer\\Semver\\Constraint\\Bound' => __DIR__ . '/..' . '/composer/semver/src/Constraint/Bound.php', + 'Composer\\Semver\\Constraint\\Constraint' => __DIR__ . '/..' . '/composer/semver/src/Constraint/Constraint.php', + 'Composer\\Semver\\Constraint\\ConstraintInterface' => __DIR__ . '/..' . '/composer/semver/src/Constraint/ConstraintInterface.php', + 'Composer\\Semver\\Constraint\\MatchAllConstraint' => __DIR__ . '/..' . '/composer/semver/src/Constraint/MatchAllConstraint.php', + 'Composer\\Semver\\Constraint\\MatchNoneConstraint' => __DIR__ . '/..' . '/composer/semver/src/Constraint/MatchNoneConstraint.php', + 'Composer\\Semver\\Constraint\\MultiConstraint' => __DIR__ . '/..' . '/composer/semver/src/Constraint/MultiConstraint.php', + 'Composer\\Semver\\Interval' => __DIR__ . '/..' . '/composer/semver/src/Interval.php', + 'Composer\\Semver\\Intervals' => __DIR__ . '/..' . '/composer/semver/src/Intervals.php', + 'Composer\\Semver\\Semver' => __DIR__ . '/..' . '/composer/semver/src/Semver.php', + 'Composer\\Semver\\VersionParser' => __DIR__ . '/..' . '/composer/semver/src/VersionParser.php', + 'Kirby\\Api\\Api' => __DIR__ . '/../..' . '/kirby/src/Api/Api.php', + 'Kirby\\Api\\Collection' => __DIR__ . '/../..' . '/kirby/src/Api/Collection.php', + 'Kirby\\Api\\Controller\\Changes' => __DIR__ . '/../..' . '/kirby/src/Api/Controller/Changes.php', + 'Kirby\\Api\\Model' => __DIR__ . '/../..' . '/kirby/src/Api/Model.php', + 'Kirby\\Api\\Upload' => __DIR__ . '/../..' . '/kirby/src/Api/Upload.php', + 'Kirby\\Cache\\ApcuCache' => __DIR__ . '/../..' . '/kirby/src/Cache/ApcuCache.php', + 'Kirby\\Cache\\Cache' => __DIR__ . '/../..' . '/kirby/src/Cache/Cache.php', + 'Kirby\\Cache\\FileCache' => __DIR__ . '/../..' . '/kirby/src/Cache/FileCache.php', + 'Kirby\\Cache\\MemCached' => __DIR__ . '/../..' . '/kirby/src/Cache/MemCached.php', + 'Kirby\\Cache\\MemoryCache' => __DIR__ . '/../..' . '/kirby/src/Cache/MemoryCache.php', + 'Kirby\\Cache\\NullCache' => __DIR__ . '/../..' . '/kirby/src/Cache/NullCache.php', + 'Kirby\\Cache\\RedisCache' => __DIR__ . '/../..' . '/kirby/src/Cache/RedisCache.php', + 'Kirby\\Cache\\Value' => __DIR__ . '/../..' . '/kirby/src/Cache/Value.php', + 'Kirby\\Cms\\Api' => __DIR__ . '/../..' . '/kirby/src/Cms/Api.php', + 'Kirby\\Cms\\App' => __DIR__ . '/../..' . '/kirby/src/Cms/App.php', + 'Kirby\\Cms\\AppCaches' => __DIR__ . '/../..' . '/kirby/src/Cms/AppCaches.php', + 'Kirby\\Cms\\AppErrors' => __DIR__ . '/../..' . '/kirby/src/Cms/AppErrors.php', + 'Kirby\\Cms\\AppPlugins' => __DIR__ . '/../..' . '/kirby/src/Cms/AppPlugins.php', + 'Kirby\\Cms\\AppTranslations' => __DIR__ . '/../..' . '/kirby/src/Cms/AppTranslations.php', + 'Kirby\\Cms\\AppUsers' => __DIR__ . '/../..' . '/kirby/src/Cms/AppUsers.php', + 'Kirby\\Cms\\Auth' => __DIR__ . '/../..' . '/kirby/src/Cms/Auth.php', + 'Kirby\\Cms\\Auth\\Challenge' => __DIR__ . '/../..' . '/kirby/src/Cms/Auth/Challenge.php', + 'Kirby\\Cms\\Auth\\EmailChallenge' => __DIR__ . '/../..' . '/kirby/src/Cms/Auth/EmailChallenge.php', + 'Kirby\\Cms\\Auth\\Status' => __DIR__ . '/../..' . '/kirby/src/Cms/Auth/Status.php', + 'Kirby\\Cms\\Auth\\TotpChallenge' => __DIR__ . '/../..' . '/kirby/src/Cms/Auth/TotpChallenge.php', + 'Kirby\\Cms\\Block' => __DIR__ . '/../..' . '/kirby/src/Cms/Block.php', + 'Kirby\\Cms\\BlockConverter' => __DIR__ . '/../..' . '/kirby/src/Cms/BlockConverter.php', + 'Kirby\\Cms\\Blocks' => __DIR__ . '/../..' . '/kirby/src/Cms/Blocks.php', + 'Kirby\\Cms\\Blueprint' => __DIR__ . '/../..' . '/kirby/src/Cms/Blueprint.php', + 'Kirby\\Cms\\Collection' => __DIR__ . '/../..' . '/kirby/src/Cms/Collection.php', + 'Kirby\\Cms\\Collections' => __DIR__ . '/../..' . '/kirby/src/Cms/Collections.php', + 'Kirby\\Cms\\Core' => __DIR__ . '/../..' . '/kirby/src/Cms/Core.php', + 'Kirby\\Cms\\Email' => __DIR__ . '/../..' . '/kirby/src/Cms/Email.php', + 'Kirby\\Cms\\Event' => __DIR__ . '/../..' . '/kirby/src/Cms/Event.php', + 'Kirby\\Cms\\Events' => __DIR__ . '/../..' . '/kirby/src/Cms/Events.php', + 'Kirby\\Cms\\Fieldset' => __DIR__ . '/../..' . '/kirby/src/Cms/Fieldset.php', + 'Kirby\\Cms\\Fieldsets' => __DIR__ . '/../..' . '/kirby/src/Cms/Fieldsets.php', + 'Kirby\\Cms\\File' => __DIR__ . '/../..' . '/kirby/src/Cms/File.php', + 'Kirby\\Cms\\FileActions' => __DIR__ . '/../..' . '/kirby/src/Cms/FileActions.php', + 'Kirby\\Cms\\FileBlueprint' => __DIR__ . '/../..' . '/kirby/src/Cms/FileBlueprint.php', + 'Kirby\\Cms\\FileModifications' => __DIR__ . '/../..' . '/kirby/src/Cms/FileModifications.php', + 'Kirby\\Cms\\FilePermissions' => __DIR__ . '/../..' . '/kirby/src/Cms/FilePermissions.php', + 'Kirby\\Cms\\FilePicker' => __DIR__ . '/../..' . '/kirby/src/Cms/FilePicker.php', + 'Kirby\\Cms\\FileRules' => __DIR__ . '/../..' . '/kirby/src/Cms/FileRules.php', + 'Kirby\\Cms\\FileVersion' => __DIR__ . '/../..' . '/kirby/src/Cms/FileVersion.php', + 'Kirby\\Cms\\Files' => __DIR__ . '/../..' . '/kirby/src/Cms/Files.php', + 'Kirby\\Cms\\Find' => __DIR__ . '/../..' . '/kirby/src/Cms/Find.php', + 'Kirby\\Cms\\HasChildren' => __DIR__ . '/../..' . '/kirby/src/Cms/HasChildren.php', + 'Kirby\\Cms\\HasFiles' => __DIR__ . '/../..' . '/kirby/src/Cms/HasFiles.php', + 'Kirby\\Cms\\HasMethods' => __DIR__ . '/../..' . '/kirby/src/Cms/HasMethods.php', + 'Kirby\\Cms\\HasModels' => __DIR__ . '/../..' . '/kirby/src/Cms/HasModels.php', + 'Kirby\\Cms\\HasSiblings' => __DIR__ . '/../..' . '/kirby/src/Cms/HasSiblings.php', + 'Kirby\\Cms\\Helpers' => __DIR__ . '/../..' . '/kirby/src/Cms/Helpers.php', + 'Kirby\\Cms\\Html' => __DIR__ . '/../..' . '/kirby/src/Cms/Html.php', + 'Kirby\\Cms\\Ingredients' => __DIR__ . '/../..' . '/kirby/src/Cms/Ingredients.php', + 'Kirby\\Cms\\Item' => __DIR__ . '/../..' . '/kirby/src/Cms/Item.php', + 'Kirby\\Cms\\Items' => __DIR__ . '/../..' . '/kirby/src/Cms/Items.php', + 'Kirby\\Cms\\Language' => __DIR__ . '/../..' . '/kirby/src/Cms/Language.php', + 'Kirby\\Cms\\LanguagePermissions' => __DIR__ . '/../..' . '/kirby/src/Cms/LanguagePermissions.php', + 'Kirby\\Cms\\LanguageRouter' => __DIR__ . '/../..' . '/kirby/src/Cms/LanguageRouter.php', + 'Kirby\\Cms\\LanguageRoutes' => __DIR__ . '/../..' . '/kirby/src/Cms/LanguageRoutes.php', + 'Kirby\\Cms\\LanguageRules' => __DIR__ . '/../..' . '/kirby/src/Cms/LanguageRules.php', + 'Kirby\\Cms\\LanguageVariable' => __DIR__ . '/../..' . '/kirby/src/Cms/LanguageVariable.php', + 'Kirby\\Cms\\Languages' => __DIR__ . '/../..' . '/kirby/src/Cms/Languages.php', + 'Kirby\\Cms\\Layout' => __DIR__ . '/../..' . '/kirby/src/Cms/Layout.php', + 'Kirby\\Cms\\LayoutColumn' => __DIR__ . '/../..' . '/kirby/src/Cms/LayoutColumn.php', + 'Kirby\\Cms\\LayoutColumns' => __DIR__ . '/../..' . '/kirby/src/Cms/LayoutColumns.php', + 'Kirby\\Cms\\Layouts' => __DIR__ . '/../..' . '/kirby/src/Cms/Layouts.php', + 'Kirby\\Cms\\License' => __DIR__ . '/../..' . '/kirby/src/Cms/License.php', + 'Kirby\\Cms\\LicenseStatus' => __DIR__ . '/../..' . '/kirby/src/Cms/LicenseStatus.php', + 'Kirby\\Cms\\LicenseType' => __DIR__ . '/../..' . '/kirby/src/Cms/LicenseType.php', + 'Kirby\\Cms\\Loader' => __DIR__ . '/../..' . '/kirby/src/Cms/Loader.php', + 'Kirby\\Cms\\Media' => __DIR__ . '/../..' . '/kirby/src/Cms/Media.php', + 'Kirby\\Cms\\ModelCommit' => __DIR__ . '/../..' . '/kirby/src/Cms/ModelCommit.php', + 'Kirby\\Cms\\ModelPermissions' => __DIR__ . '/../..' . '/kirby/src/Cms/ModelPermissions.php', + 'Kirby\\Cms\\ModelState' => __DIR__ . '/../..' . '/kirby/src/Cms/ModelState.php', + 'Kirby\\Cms\\ModelWithContent' => __DIR__ . '/../..' . '/kirby/src/Cms/ModelWithContent.php', + 'Kirby\\Cms\\Nest' => __DIR__ . '/../..' . '/kirby/src/Cms/Nest.php', + 'Kirby\\Cms\\NestCollection' => __DIR__ . '/../..' . '/kirby/src/Cms/NestCollection.php', + 'Kirby\\Cms\\NestObject' => __DIR__ . '/../..' . '/kirby/src/Cms/NestObject.php', + 'Kirby\\Cms\\Page' => __DIR__ . '/../..' . '/kirby/src/Cms/Page.php', + 'Kirby\\Cms\\PageActions' => __DIR__ . '/../..' . '/kirby/src/Cms/PageActions.php', + 'Kirby\\Cms\\PageBlueprint' => __DIR__ . '/../..' . '/kirby/src/Cms/PageBlueprint.php', + 'Kirby\\Cms\\PageCopy' => __DIR__ . '/../..' . '/kirby/src/Cms/PageCopy.php', + 'Kirby\\Cms\\PagePermissions' => __DIR__ . '/../..' . '/kirby/src/Cms/PagePermissions.php', + 'Kirby\\Cms\\PagePicker' => __DIR__ . '/../..' . '/kirby/src/Cms/PagePicker.php', + 'Kirby\\Cms\\PageRules' => __DIR__ . '/../..' . '/kirby/src/Cms/PageRules.php', + 'Kirby\\Cms\\PageSiblings' => __DIR__ . '/../..' . '/kirby/src/Cms/PageSiblings.php', + 'Kirby\\Cms\\Pages' => __DIR__ . '/../..' . '/kirby/src/Cms/Pages.php', + 'Kirby\\Cms\\Pagination' => __DIR__ . '/../..' . '/kirby/src/Cms/Pagination.php', + 'Kirby\\Cms\\Permissions' => __DIR__ . '/../..' . '/kirby/src/Cms/Permissions.php', + 'Kirby\\Cms\\Picker' => __DIR__ . '/../..' . '/kirby/src/Cms/Picker.php', + 'Kirby\\Cms\\R' => __DIR__ . '/../..' . '/kirby/src/Cms/R.php', + 'Kirby\\Cms\\Responder' => __DIR__ . '/../..' . '/kirby/src/Cms/Responder.php', + 'Kirby\\Cms\\Response' => __DIR__ . '/../..' . '/kirby/src/Cms/Response.php', + 'Kirby\\Cms\\Role' => __DIR__ . '/../..' . '/kirby/src/Cms/Role.php', + 'Kirby\\Cms\\Roles' => __DIR__ . '/../..' . '/kirby/src/Cms/Roles.php', + 'Kirby\\Cms\\S' => __DIR__ . '/../..' . '/kirby/src/Cms/S.php', + 'Kirby\\Cms\\Search' => __DIR__ . '/../..' . '/kirby/src/Cms/Search.php', + 'Kirby\\Cms\\Section' => __DIR__ . '/../..' . '/kirby/src/Cms/Section.php', + 'Kirby\\Cms\\Site' => __DIR__ . '/../..' . '/kirby/src/Cms/Site.php', + 'Kirby\\Cms\\SiteActions' => __DIR__ . '/../..' . '/kirby/src/Cms/SiteActions.php', + 'Kirby\\Cms\\SiteBlueprint' => __DIR__ . '/../..' . '/kirby/src/Cms/SiteBlueprint.php', + 'Kirby\\Cms\\SitePermissions' => __DIR__ . '/../..' . '/kirby/src/Cms/SitePermissions.php', + 'Kirby\\Cms\\SiteRules' => __DIR__ . '/../..' . '/kirby/src/Cms/SiteRules.php', + 'Kirby\\Cms\\Structure' => __DIR__ . '/../..' . '/kirby/src/Cms/Structure.php', + 'Kirby\\Cms\\StructureObject' => __DIR__ . '/../..' . '/kirby/src/Cms/StructureObject.php', + 'Kirby\\Cms\\System' => __DIR__ . '/../..' . '/kirby/src/Cms/System.php', + 'Kirby\\Cms\\System\\UpdateStatus' => __DIR__ . '/../..' . '/kirby/src/Cms/System/UpdateStatus.php', + 'Kirby\\Cms\\Translation' => __DIR__ . '/../..' . '/kirby/src/Cms/Translation.php', + 'Kirby\\Cms\\Translations' => __DIR__ . '/../..' . '/kirby/src/Cms/Translations.php', + 'Kirby\\Cms\\Url' => __DIR__ . '/../..' . '/kirby/src/Cms/Url.php', + 'Kirby\\Cms\\User' => __DIR__ . '/../..' . '/kirby/src/Cms/User.php', + 'Kirby\\Cms\\UserActions' => __DIR__ . '/../..' . '/kirby/src/Cms/UserActions.php', + 'Kirby\\Cms\\UserBlueprint' => __DIR__ . '/../..' . '/kirby/src/Cms/UserBlueprint.php', + 'Kirby\\Cms\\UserPermissions' => __DIR__ . '/../..' . '/kirby/src/Cms/UserPermissions.php', + 'Kirby\\Cms\\UserPicker' => __DIR__ . '/../..' . '/kirby/src/Cms/UserPicker.php', + 'Kirby\\Cms\\UserRules' => __DIR__ . '/../..' . '/kirby/src/Cms/UserRules.php', + 'Kirby\\Cms\\Users' => __DIR__ . '/../..' . '/kirby/src/Cms/Users.php', + 'Kirby\\Cms\\Visitor' => __DIR__ . '/../..' . '/kirby/src/Cms/Visitor.php', + 'Kirby\\ComposerInstaller\\CmsInstaller' => __DIR__ . '/..' . '/getkirby/composer-installer/src/ComposerInstaller/CmsInstaller.php', + 'Kirby\\ComposerInstaller\\Installer' => __DIR__ . '/..' . '/getkirby/composer-installer/src/ComposerInstaller/Installer.php', + 'Kirby\\ComposerInstaller\\Plugin' => __DIR__ . '/..' . '/getkirby/composer-installer/src/ComposerInstaller/Plugin.php', + 'Kirby\\ComposerInstaller\\PluginInstaller' => __DIR__ . '/..' . '/getkirby/composer-installer/src/ComposerInstaller/PluginInstaller.php', + 'Kirby\\Content\\Changes' => __DIR__ . '/../..' . '/kirby/src/Content/Changes.php', + 'Kirby\\Content\\Content' => __DIR__ . '/../..' . '/kirby/src/Content/Content.php', + 'Kirby\\Content\\Field' => __DIR__ . '/../..' . '/kirby/src/Content/Field.php', + 'Kirby\\Content\\ImmutableMemoryStorage' => __DIR__ . '/../..' . '/kirby/src/Content/ImmutableMemoryStorage.php', + 'Kirby\\Content\\Lock' => __DIR__ . '/../..' . '/kirby/src/Content/Lock.php', + 'Kirby\\Content\\LockedContentException' => __DIR__ . '/../..' . '/kirby/src/Content/LockedContentException.php', + 'Kirby\\Content\\MemoryStorage' => __DIR__ . '/../..' . '/kirby/src/Content/MemoryStorage.php', + 'Kirby\\Content\\PlainTextStorage' => __DIR__ . '/../..' . '/kirby/src/Content/PlainTextStorage.php', + 'Kirby\\Content\\Storage' => __DIR__ . '/../..' . '/kirby/src/Content/Storage.php', + 'Kirby\\Content\\Translation' => __DIR__ . '/../..' . '/kirby/src/Content/Translation.php', + 'Kirby\\Content\\Translations' => __DIR__ . '/../..' . '/kirby/src/Content/Translations.php', + 'Kirby\\Content\\Version' => __DIR__ . '/../..' . '/kirby/src/Content/Version.php', + 'Kirby\\Content\\VersionCache' => __DIR__ . '/../..' . '/kirby/src/Content/VersionCache.php', + 'Kirby\\Content\\VersionId' => __DIR__ . '/../..' . '/kirby/src/Content/VersionId.php', + 'Kirby\\Content\\VersionRules' => __DIR__ . '/../..' . '/kirby/src/Content/VersionRules.php', + 'Kirby\\Content\\Versions' => __DIR__ . '/../..' . '/kirby/src/Content/Versions.php', + 'Kirby\\Data\\Data' => __DIR__ . '/../..' . '/kirby/src/Data/Data.php', + 'Kirby\\Data\\Handler' => __DIR__ . '/../..' . '/kirby/src/Data/Handler.php', + 'Kirby\\Data\\Json' => __DIR__ . '/../..' . '/kirby/src/Data/Json.php', + 'Kirby\\Data\\PHP' => __DIR__ . '/../..' . '/kirby/src/Data/PHP.php', + 'Kirby\\Data\\Txt' => __DIR__ . '/../..' . '/kirby/src/Data/Txt.php', + 'Kirby\\Data\\Xml' => __DIR__ . '/../..' . '/kirby/src/Data/Xml.php', + 'Kirby\\Data\\Yaml' => __DIR__ . '/../..' . '/kirby/src/Data/Yaml.php', + 'Kirby\\Data\\YamlSpyc' => __DIR__ . '/../..' . '/kirby/src/Data/YamlSpyc.php', + 'Kirby\\Data\\YamlSymfony' => __DIR__ . '/../..' . '/kirby/src/Data/YamlSymfony.php', + 'Kirby\\Database\\Database' => __DIR__ . '/../..' . '/kirby/src/Database/Database.php', + 'Kirby\\Database\\Db' => __DIR__ . '/../..' . '/kirby/src/Database/Db.php', + 'Kirby\\Database\\Query' => __DIR__ . '/../..' . '/kirby/src/Database/Query.php', + 'Kirby\\Database\\Sql' => __DIR__ . '/../..' . '/kirby/src/Database/Sql.php', + 'Kirby\\Database\\Sql\\Mysql' => __DIR__ . '/../..' . '/kirby/src/Database/Sql/Mysql.php', + 'Kirby\\Database\\Sql\\Sqlite' => __DIR__ . '/../..' . '/kirby/src/Database/Sql/Sqlite.php', + 'Kirby\\Email\\Body' => __DIR__ . '/../..' . '/kirby/src/Email/Body.php', + 'Kirby\\Email\\Email' => __DIR__ . '/../..' . '/kirby/src/Email/Email.php', + 'Kirby\\Email\\PHPMailer' => __DIR__ . '/../..' . '/kirby/src/Email/PHPMailer.php', + 'Kirby\\Exception\\AuthException' => __DIR__ . '/../..' . '/kirby/src/Exception/AuthException.php', + 'Kirby\\Exception\\BadMethodCallException' => __DIR__ . '/../..' . '/kirby/src/Exception/BadMethodCallException.php', + 'Kirby\\Exception\\DuplicateException' => __DIR__ . '/../..' . '/kirby/src/Exception/DuplicateException.php', + 'Kirby\\Exception\\ErrorPageException' => __DIR__ . '/../..' . '/kirby/src/Exception/ErrorPageException.php', + 'Kirby\\Exception\\Exception' => __DIR__ . '/../..' . '/kirby/src/Exception/Exception.php', + 'Kirby\\Exception\\InvalidArgumentException' => __DIR__ . '/../..' . '/kirby/src/Exception/InvalidArgumentException.php', + 'Kirby\\Exception\\LogicException' => __DIR__ . '/../..' . '/kirby/src/Exception/LogicException.php', + 'Kirby\\Exception\\NotFoundException' => __DIR__ . '/../..' . '/kirby/src/Exception/NotFoundException.php', + 'Kirby\\Exception\\PermissionException' => __DIR__ . '/../..' . '/kirby/src/Exception/PermissionException.php', + 'Kirby\\Field\\FieldOptions' => __DIR__ . '/../..' . '/kirby/src/Field/FieldOptions.php', + 'Kirby\\Filesystem\\Asset' => __DIR__ . '/../..' . '/kirby/src/Filesystem/Asset.php', + 'Kirby\\Filesystem\\Dir' => __DIR__ . '/../..' . '/kirby/src/Filesystem/Dir.php', + 'Kirby\\Filesystem\\F' => __DIR__ . '/../..' . '/kirby/src/Filesystem/F.php', + 'Kirby\\Filesystem\\File' => __DIR__ . '/../..' . '/kirby/src/Filesystem/File.php', + 'Kirby\\Filesystem\\Filename' => __DIR__ . '/../..' . '/kirby/src/Filesystem/Filename.php', + 'Kirby\\Filesystem\\IsFile' => __DIR__ . '/../..' . '/kirby/src/Filesystem/IsFile.php', + 'Kirby\\Filesystem\\Mime' => __DIR__ . '/../..' . '/kirby/src/Filesystem/Mime.php', + 'Kirby\\Form\\Field' => __DIR__ . '/../..' . '/kirby/src/Form/Field.php', + 'Kirby\\Form\\FieldClass' => __DIR__ . '/../..' . '/kirby/src/Form/FieldClass.php', + 'Kirby\\Form\\Field\\BlocksField' => __DIR__ . '/../..' . '/kirby/src/Form/Field/BlocksField.php', + 'Kirby\\Form\\Field\\EntriesField' => __DIR__ . '/../..' . '/kirby/src/Form/Field/EntriesField.php', + 'Kirby\\Form\\Field\\LayoutField' => __DIR__ . '/../..' . '/kirby/src/Form/Field/LayoutField.php', + 'Kirby\\Form\\Field\\StatsField' => __DIR__ . '/../..' . '/kirby/src/Form/Field/StatsField.php', + 'Kirby\\Form\\Fields' => __DIR__ . '/../..' . '/kirby/src/Form/Fields.php', + 'Kirby\\Form\\Form' => __DIR__ . '/../..' . '/kirby/src/Form/Form.php', + 'Kirby\\Form\\Mixin\\After' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/After.php', + 'Kirby\\Form\\Mixin\\Api' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Api.php', + 'Kirby\\Form\\Mixin\\Autofocus' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Autofocus.php', + 'Kirby\\Form\\Mixin\\Before' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Before.php', + 'Kirby\\Form\\Mixin\\EmptyState' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/EmptyState.php', + 'Kirby\\Form\\Mixin\\Help' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Help.php', + 'Kirby\\Form\\Mixin\\Icon' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Icon.php', + 'Kirby\\Form\\Mixin\\Label' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Label.php', + 'Kirby\\Form\\Mixin\\Max' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Max.php', + 'Kirby\\Form\\Mixin\\Min' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Min.php', + 'Kirby\\Form\\Mixin\\Model' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Model.php', + 'Kirby\\Form\\Mixin\\Placeholder' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Placeholder.php', + 'Kirby\\Form\\Mixin\\Translatable' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Translatable.php', + 'Kirby\\Form\\Mixin\\Validation' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Validation.php', + 'Kirby\\Form\\Mixin\\Value' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Value.php', + 'Kirby\\Form\\Mixin\\When' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/When.php', + 'Kirby\\Form\\Mixin\\Width' => __DIR__ . '/../..' . '/kirby/src/Form/Mixin/Width.php', + 'Kirby\\Form\\Validations' => __DIR__ . '/../..' . '/kirby/src/Form/Validations.php', + 'Kirby\\Http\\Cookie' => __DIR__ . '/../..' . '/kirby/src/Http/Cookie.php', + 'Kirby\\Http\\Environment' => __DIR__ . '/../..' . '/kirby/src/Http/Environment.php', + 'Kirby\\Http\\Exceptions\\NextRouteException' => __DIR__ . '/../..' . '/kirby/src/Http/Exceptions/NextRouteException.php', + 'Kirby\\Http\\Header' => __DIR__ . '/../..' . '/kirby/src/Http/Header.php', + 'Kirby\\Http\\Idn' => __DIR__ . '/../..' . '/kirby/src/Http/Idn.php', + 'Kirby\\Http\\Params' => __DIR__ . '/../..' . '/kirby/src/Http/Params.php', + 'Kirby\\Http\\Path' => __DIR__ . '/../..' . '/kirby/src/Http/Path.php', + 'Kirby\\Http\\Query' => __DIR__ . '/../..' . '/kirby/src/Http/Query.php', + 'Kirby\\Http\\Remote' => __DIR__ . '/../..' . '/kirby/src/Http/Remote.php', + 'Kirby\\Http\\Request' => __DIR__ . '/../..' . '/kirby/src/Http/Request.php', + 'Kirby\\Http\\Request\\Auth' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Auth.php', + 'Kirby\\Http\\Request\\Auth\\BasicAuth' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Auth/BasicAuth.php', + 'Kirby\\Http\\Request\\Auth\\BearerAuth' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Auth/BearerAuth.php', + 'Kirby\\Http\\Request\\Auth\\SessionAuth' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Auth/SessionAuth.php', + 'Kirby\\Http\\Request\\Body' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Body.php', + 'Kirby\\Http\\Request\\Data' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Data.php', + 'Kirby\\Http\\Request\\Files' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Files.php', + 'Kirby\\Http\\Request\\Query' => __DIR__ . '/../..' . '/kirby/src/Http/Request/Query.php', + 'Kirby\\Http\\Response' => __DIR__ . '/../..' . '/kirby/src/Http/Response.php', + 'Kirby\\Http\\Route' => __DIR__ . '/../..' . '/kirby/src/Http/Route.php', + 'Kirby\\Http\\Router' => __DIR__ . '/../..' . '/kirby/src/Http/Router.php', + 'Kirby\\Http\\Uri' => __DIR__ . '/../..' . '/kirby/src/Http/Uri.php', + 'Kirby\\Http\\Url' => __DIR__ . '/../..' . '/kirby/src/Http/Url.php', + 'Kirby\\Http\\Visitor' => __DIR__ . '/../..' . '/kirby/src/Http/Visitor.php', + 'Kirby\\Image\\Camera' => __DIR__ . '/../..' . '/kirby/src/Image/Camera.php', + 'Kirby\\Image\\Darkroom' => __DIR__ . '/../..' . '/kirby/src/Image/Darkroom.php', + 'Kirby\\Image\\Darkroom\\GdLib' => __DIR__ . '/../..' . '/kirby/src/Image/Darkroom/GdLib.php', + 'Kirby\\Image\\Darkroom\\ImageMagick' => __DIR__ . '/../..' . '/kirby/src/Image/Darkroom/ImageMagick.php', + 'Kirby\\Image\\Darkroom\\Imagick' => __DIR__ . '/../..' . '/kirby/src/Image/Darkroom/Imagick.php', + 'Kirby\\Image\\Dimensions' => __DIR__ . '/../..' . '/kirby/src/Image/Dimensions.php', + 'Kirby\\Image\\Exif' => __DIR__ . '/../..' . '/kirby/src/Image/Exif.php', + 'Kirby\\Image\\Focus' => __DIR__ . '/../..' . '/kirby/src/Image/Focus.php', + 'Kirby\\Image\\Image' => __DIR__ . '/../..' . '/kirby/src/Image/Image.php', + 'Kirby\\Image\\Location' => __DIR__ . '/../..' . '/kirby/src/Image/Location.php', + 'Kirby\\Image\\QrCode' => __DIR__ . '/../..' . '/kirby/src/Image/QrCode.php', + 'Kirby\\Option\\Option' => __DIR__ . '/../..' . '/kirby/src/Option/Option.php', + 'Kirby\\Option\\Options' => __DIR__ . '/../..' . '/kirby/src/Option/Options.php', + 'Kirby\\Option\\OptionsApi' => __DIR__ . '/../..' . '/kirby/src/Option/OptionsApi.php', + 'Kirby\\Option\\OptionsProvider' => __DIR__ . '/../..' . '/kirby/src/Option/OptionsProvider.php', + 'Kirby\\Option\\OptionsQuery' => __DIR__ . '/../..' . '/kirby/src/Option/OptionsQuery.php', + 'Kirby\\Panel\\Assets' => __DIR__ . '/../..' . '/kirby/src/Panel/Assets.php', + 'Kirby\\Panel\\ChangesDialog' => __DIR__ . '/../..' . '/kirby/src/Panel/ChangesDialog.php', + 'Kirby\\Panel\\Collector\\FilesCollector' => __DIR__ . '/../..' . '/kirby/src/Panel/Collector/FilesCollector.php', + 'Kirby\\Panel\\Collector\\ModelsCollector' => __DIR__ . '/../..' . '/kirby/src/Panel/Collector/ModelsCollector.php', + 'Kirby\\Panel\\Collector\\PagesCollector' => __DIR__ . '/../..' . '/kirby/src/Panel/Collector/PagesCollector.php', + 'Kirby\\Panel\\Collector\\UsersCollector' => __DIR__ . '/../..' . '/kirby/src/Panel/Collector/UsersCollector.php', + 'Kirby\\Panel\\Controller\\PageTree' => __DIR__ . '/../..' . '/kirby/src/Panel/Controller/PageTree.php', + 'Kirby\\Panel\\Controller\\Search' => __DIR__ . '/../..' . '/kirby/src/Panel/Controller/Search.php', + 'Kirby\\Panel\\Dialog' => __DIR__ . '/../..' . '/kirby/src/Panel/Dialog.php', + 'Kirby\\Panel\\Document' => __DIR__ . '/../..' . '/kirby/src/Panel/Document.php', + 'Kirby\\Panel\\Drawer' => __DIR__ . '/../..' . '/kirby/src/Panel/Drawer.php', + 'Kirby\\Panel\\Dropdown' => __DIR__ . '/../..' . '/kirby/src/Panel/Dropdown.php', + 'Kirby\\Panel\\Field' => __DIR__ . '/../..' . '/kirby/src/Panel/Field.php', + 'Kirby\\Panel\\File' => __DIR__ . '/../..' . '/kirby/src/Panel/File.php', + 'Kirby\\Panel\\Home' => __DIR__ . '/../..' . '/kirby/src/Panel/Home.php', + 'Kirby\\Panel\\Json' => __DIR__ . '/../..' . '/kirby/src/Panel/Json.php', + 'Kirby\\Panel\\Lab\\Category' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Category.php', + 'Kirby\\Panel\\Lab\\Doc' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Doc.php', + 'Kirby\\Panel\\Lab\\Doc\\Argument' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Doc/Argument.php', + 'Kirby\\Panel\\Lab\\Doc\\Event' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Doc/Event.php', + 'Kirby\\Panel\\Lab\\Doc\\Method' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Doc/Method.php', + 'Kirby\\Panel\\Lab\\Doc\\Prop' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Doc/Prop.php', + 'Kirby\\Panel\\Lab\\Doc\\Slot' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Doc/Slot.php', + 'Kirby\\Panel\\Lab\\Docs' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Docs.php', + 'Kirby\\Panel\\Lab\\Example' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Example.php', + 'Kirby\\Panel\\Lab\\Snippet' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Snippet.php', + 'Kirby\\Panel\\Lab\\Template' => __DIR__ . '/../..' . '/kirby/src/Panel/Lab/Template.php', + 'Kirby\\Panel\\Menu' => __DIR__ . '/../..' . '/kirby/src/Panel/Menu.php', + 'Kirby\\Panel\\Model' => __DIR__ . '/../..' . '/kirby/src/Panel/Model.php', + 'Kirby\\Panel\\Page' => __DIR__ . '/../..' . '/kirby/src/Panel/Page.php', + 'Kirby\\Panel\\PageCreateDialog' => __DIR__ . '/../..' . '/kirby/src/Panel/PageCreateDialog.php', + 'Kirby\\Panel\\Panel' => __DIR__ . '/../..' . '/kirby/src/Panel/Panel.php', + 'Kirby\\Panel\\Plugins' => __DIR__ . '/../..' . '/kirby/src/Panel/Plugins.php', + 'Kirby\\Panel\\Redirect' => __DIR__ . '/../..' . '/kirby/src/Panel/Redirect.php', + 'Kirby\\Panel\\Request' => __DIR__ . '/../..' . '/kirby/src/Panel/Request.php', + 'Kirby\\Panel\\Search' => __DIR__ . '/../..' . '/kirby/src/Panel/Search.php', + 'Kirby\\Panel\\Site' => __DIR__ . '/../..' . '/kirby/src/Panel/Site.php', + 'Kirby\\Panel\\Ui\\Button' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Button.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguageCreateButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguageDeleteButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguageSettingsButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\LanguagesDropdown' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php', + 'Kirby\\Panel\\Ui\\Buttons\\OpenButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/OpenButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\PageStatusButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/PageStatusButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\PreviewButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/PreviewButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\SettingsButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/SettingsButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\VersionsButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/VersionsButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\ViewButton' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/ViewButton.php', + 'Kirby\\Panel\\Ui\\Buttons\\ViewButtons' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Buttons/ViewButtons.php', + 'Kirby\\Panel\\Ui\\Component' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Component.php', + 'Kirby\\Panel\\Ui\\FilePreview' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/FilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\AudioFilePreview' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\DefaultFilePreview' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\ImageFilePreview' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\PdfFilePreview' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php', + 'Kirby\\Panel\\Ui\\FilePreviews\\VideoFilePreview' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php', + 'Kirby\\Panel\\Ui\\Item\\FileItem' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Item/FileItem.php', + 'Kirby\\Panel\\Ui\\Item\\ModelItem' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Item/ModelItem.php', + 'Kirby\\Panel\\Ui\\Item\\PageItem' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Item/PageItem.php', + 'Kirby\\Panel\\Ui\\Item\\UserItem' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Item/UserItem.php', + 'Kirby\\Panel\\Ui\\Stat' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Stat.php', + 'Kirby\\Panel\\Ui\\Stats' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Stats.php', + 'Kirby\\Panel\\Ui\\Upload' => __DIR__ . '/../..' . '/kirby/src/Panel/Ui/Upload.php', + 'Kirby\\Panel\\User' => __DIR__ . '/../..' . '/kirby/src/Panel/User.php', + 'Kirby\\Panel\\UserTotpDisableDialog' => __DIR__ . '/../..' . '/kirby/src/Panel/UserTotpDisableDialog.php', + 'Kirby\\Panel\\UserTotpEnableDialog' => __DIR__ . '/../..' . '/kirby/src/Panel/UserTotpEnableDialog.php', + 'Kirby\\Panel\\View' => __DIR__ . '/../..' . '/kirby/src/Panel/View.php', + 'Kirby\\Parsley\\Element' => __DIR__ . '/../..' . '/kirby/src/Parsley/Element.php', + 'Kirby\\Parsley\\Inline' => __DIR__ . '/../..' . '/kirby/src/Parsley/Inline.php', + 'Kirby\\Parsley\\Parsley' => __DIR__ . '/../..' . '/kirby/src/Parsley/Parsley.php', + 'Kirby\\Parsley\\Schema' => __DIR__ . '/../..' . '/kirby/src/Parsley/Schema.php', + 'Kirby\\Parsley\\Schema\\Blocks' => __DIR__ . '/../..' . '/kirby/src/Parsley/Schema/Blocks.php', + 'Kirby\\Parsley\\Schema\\Plain' => __DIR__ . '/../..' . '/kirby/src/Parsley/Schema/Plain.php', + 'Kirby\\Plugin\\Asset' => __DIR__ . '/../..' . '/kirby/src/Plugin/Asset.php', + 'Kirby\\Plugin\\Assets' => __DIR__ . '/../..' . '/kirby/src/Plugin/Assets.php', + 'Kirby\\Plugin\\License' => __DIR__ . '/../..' . '/kirby/src/Plugin/License.php', + 'Kirby\\Plugin\\LicenseStatus' => __DIR__ . '/../..' . '/kirby/src/Plugin/LicenseStatus.php', + 'Kirby\\Plugin\\Plugin' => __DIR__ . '/../..' . '/kirby/src/Plugin/Plugin.php', + 'Kirby\\Query\\AST\\ArgumentListNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/ArgumentListNode.php', + 'Kirby\\Query\\AST\\ArithmeticNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/ArithmeticNode.php', + 'Kirby\\Query\\AST\\ArrayListNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/ArrayListNode.php', + 'Kirby\\Query\\AST\\ClosureNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/ClosureNode.php', + 'Kirby\\Query\\AST\\CoalesceNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/CoalesceNode.php', + 'Kirby\\Query\\AST\\ComparisonNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/ComparisonNode.php', + 'Kirby\\Query\\AST\\GlobalFunctionNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/GlobalFunctionNode.php', + 'Kirby\\Query\\AST\\LiteralNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/LiteralNode.php', + 'Kirby\\Query\\AST\\LogicalNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/LogicalNode.php', + 'Kirby\\Query\\AST\\MemberAccessNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/MemberAccessNode.php', + 'Kirby\\Query\\AST\\Node' => __DIR__ . '/../..' . '/kirby/src/Query/AST/Node.php', + 'Kirby\\Query\\AST\\TernaryNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/TernaryNode.php', + 'Kirby\\Query\\AST\\VariableNode' => __DIR__ . '/../..' . '/kirby/src/Query/AST/VariableNode.php', + 'Kirby\\Query\\Argument' => __DIR__ . '/../..' . '/kirby/src/Query/Argument.php', + 'Kirby\\Query\\Arguments' => __DIR__ . '/../..' . '/kirby/src/Query/Arguments.php', + 'Kirby\\Query\\Expression' => __DIR__ . '/../..' . '/kirby/src/Query/Expression.php', + 'Kirby\\Query\\Parser\\Parser' => __DIR__ . '/../..' . '/kirby/src/Query/Parser/Parser.php', + 'Kirby\\Query\\Parser\\Token' => __DIR__ . '/../..' . '/kirby/src/Query/Parser/Token.php', + 'Kirby\\Query\\Parser\\TokenType' => __DIR__ . '/../..' . '/kirby/src/Query/Parser/TokenType.php', + 'Kirby\\Query\\Parser\\Tokenizer' => __DIR__ . '/../..' . '/kirby/src/Query/Parser/Tokenizer.php', + 'Kirby\\Query\\Query' => __DIR__ . '/../..' . '/kirby/src/Query/Query.php', + 'Kirby\\Query\\Runners\\DefaultRunner' => __DIR__ . '/../..' . '/kirby/src/Query/Runners/DefaultRunner.php', + 'Kirby\\Query\\Runners\\Runner' => __DIR__ . '/../..' . '/kirby/src/Query/Runners/Runner.php', + 'Kirby\\Query\\Runners\\Scope' => __DIR__ . '/../..' . '/kirby/src/Query/Runners/Scope.php', + 'Kirby\\Query\\Segment' => __DIR__ . '/../..' . '/kirby/src/Query/Segment.php', + 'Kirby\\Query\\Segments' => __DIR__ . '/../..' . '/kirby/src/Query/Segments.php', + 'Kirby\\Query\\Visitors\\DefaultVisitor' => __DIR__ . '/../..' . '/kirby/src/Query/Visitors/DefaultVisitor.php', + 'Kirby\\Query\\Visitors\\Visitor' => __DIR__ . '/../..' . '/kirby/src/Query/Visitors/Visitor.php', + 'Kirby\\Sane\\DomHandler' => __DIR__ . '/../..' . '/kirby/src/Sane/DomHandler.php', + 'Kirby\\Sane\\Handler' => __DIR__ . '/../..' . '/kirby/src/Sane/Handler.php', + 'Kirby\\Sane\\Html' => __DIR__ . '/../..' . '/kirby/src/Sane/Html.php', + 'Kirby\\Sane\\Sane' => __DIR__ . '/../..' . '/kirby/src/Sane/Sane.php', + 'Kirby\\Sane\\Svg' => __DIR__ . '/../..' . '/kirby/src/Sane/Svg.php', + 'Kirby\\Sane\\Svgz' => __DIR__ . '/../..' . '/kirby/src/Sane/Svgz.php', + 'Kirby\\Sane\\Xml' => __DIR__ . '/../..' . '/kirby/src/Sane/Xml.php', + 'Kirby\\Session\\AutoSession' => __DIR__ . '/../..' . '/kirby/src/Session/AutoSession.php', + 'Kirby\\Session\\FileSessionStore' => __DIR__ . '/../..' . '/kirby/src/Session/FileSessionStore.php', + 'Kirby\\Session\\Session' => __DIR__ . '/../..' . '/kirby/src/Session/Session.php', + 'Kirby\\Session\\SessionData' => __DIR__ . '/../..' . '/kirby/src/Session/SessionData.php', + 'Kirby\\Session\\SessionStore' => __DIR__ . '/../..' . '/kirby/src/Session/SessionStore.php', + 'Kirby\\Session\\Sessions' => __DIR__ . '/../..' . '/kirby/src/Session/Sessions.php', + 'Kirby\\Template\\Slot' => __DIR__ . '/../..' . '/kirby/src/Template/Slot.php', + 'Kirby\\Template\\Slots' => __DIR__ . '/../..' . '/kirby/src/Template/Slots.php', + 'Kirby\\Template\\Snippet' => __DIR__ . '/../..' . '/kirby/src/Template/Snippet.php', + 'Kirby\\Template\\Template' => __DIR__ . '/../..' . '/kirby/src/Template/Template.php', + 'Kirby\\Text\\KirbyTag' => __DIR__ . '/../..' . '/kirby/src/Text/KirbyTag.php', + 'Kirby\\Text\\KirbyTags' => __DIR__ . '/../..' . '/kirby/src/Text/KirbyTags.php', + 'Kirby\\Text\\Markdown' => __DIR__ . '/../..' . '/kirby/src/Text/Markdown.php', + 'Kirby\\Text\\SmartyPants' => __DIR__ . '/../..' . '/kirby/src/Text/SmartyPants.php', + 'Kirby\\Toolkit\\A' => __DIR__ . '/../..' . '/kirby/src/Toolkit/A.php', + 'Kirby\\Toolkit\\Collection' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Collection.php', + 'Kirby\\Toolkit\\Component' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Component.php', + 'Kirby\\Toolkit\\Config' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Config.php', + 'Kirby\\Toolkit\\Controller' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Controller.php', + 'Kirby\\Toolkit\\Date' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Date.php', + 'Kirby\\Toolkit\\Dom' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Dom.php', + 'Kirby\\Toolkit\\Escape' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Escape.php', + 'Kirby\\Toolkit\\Facade' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Facade.php', + 'Kirby\\Toolkit\\Html' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Html.php', + 'Kirby\\Toolkit\\I18n' => __DIR__ . '/../..' . '/kirby/src/Toolkit/I18n.php', + 'Kirby\\Toolkit\\Iterator' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Iterator.php', + 'Kirby\\Toolkit\\LazyValue' => __DIR__ . '/../..' . '/kirby/src/Toolkit/LazyValue.php', + 'Kirby\\Toolkit\\Locale' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Locale.php', + 'Kirby\\Toolkit\\Obj' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Obj.php', + 'Kirby\\Toolkit\\Pagination' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Pagination.php', + 'Kirby\\Toolkit\\Silo' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Silo.php', + 'Kirby\\Toolkit\\Str' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Str.php', + 'Kirby\\Toolkit\\SymmetricCrypto' => __DIR__ . '/../..' . '/kirby/src/Toolkit/SymmetricCrypto.php', + 'Kirby\\Toolkit\\Totp' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Totp.php', + 'Kirby\\Toolkit\\Tpl' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Tpl.php', + 'Kirby\\Toolkit\\V' => __DIR__ . '/../..' . '/kirby/src/Toolkit/V.php', + 'Kirby\\Toolkit\\View' => __DIR__ . '/../..' . '/kirby/src/Toolkit/View.php', + 'Kirby\\Toolkit\\Xml' => __DIR__ . '/../..' . '/kirby/src/Toolkit/Xml.php', + 'Kirby\\Uuid\\BlockUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/BlockUuid.php', + 'Kirby\\Uuid\\FieldUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/FieldUuid.php', + 'Kirby\\Uuid\\FileUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/FileUuid.php', + 'Kirby\\Uuid\\HasUuids' => __DIR__ . '/../..' . '/kirby/src/Uuid/HasUuids.php', + 'Kirby\\Uuid\\Identifiable' => __DIR__ . '/../..' . '/kirby/src/Uuid/Identifiable.php', + 'Kirby\\Uuid\\ModelUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/ModelUuid.php', + 'Kirby\\Uuid\\PageUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/PageUuid.php', + 'Kirby\\Uuid\\SiteUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/SiteUuid.php', + 'Kirby\\Uuid\\StructureUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/StructureUuid.php', + 'Kirby\\Uuid\\Uri' => __DIR__ . '/../..' . '/kirby/src/Uuid/Uri.php', + 'Kirby\\Uuid\\UserUuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/UserUuid.php', + 'Kirby\\Uuid\\Uuid' => __DIR__ . '/../..' . '/kirby/src/Uuid/Uuid.php', + 'Kirby\\Uuid\\Uuids' => __DIR__ . '/../..' . '/kirby/src/Uuid/Uuids.php', + 'Laminas\\Escaper\\Escaper' => __DIR__ . '/..' . '/laminas/laminas-escaper/src/Escaper.php', + 'Laminas\\Escaper\\EscaperInterface' => __DIR__ . '/..' . '/laminas/laminas-escaper/src/EscaperInterface.php', + 'Laminas\\Escaper\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/laminas/laminas-escaper/src/Exception/ExceptionInterface.php', + 'Laminas\\Escaper\\Exception\\InvalidArgumentException' => __DIR__ . '/..' . '/laminas/laminas-escaper/src/Exception/InvalidArgumentException.php', + 'Laminas\\Escaper\\Exception\\RuntimeException' => __DIR__ . '/..' . '/laminas/laminas-escaper/src/Exception/RuntimeException.php', + 'League\\ColorExtractor\\Color' => __DIR__ . '/..' . '/league/color-extractor/src/Color.php', + 'League\\ColorExtractor\\ColorExtractor' => __DIR__ . '/..' . '/league/color-extractor/src/ColorExtractor.php', + 'League\\ColorExtractor\\Palette' => __DIR__ . '/..' . '/league/color-extractor/src/Palette.php', + 'Michelf\\SmartyPants' => __DIR__ . '/..' . '/michelf/php-smartypants/Michelf/SmartyPants.php', + 'Michelf\\SmartyPantsTypographer' => __DIR__ . '/..' . '/michelf/php-smartypants/Michelf/SmartyPantsTypographer.php', + 'Normalizer' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php', + 'PHPMailer\\PHPMailer\\DSNConfigurator' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/DSNConfigurator.php', + 'PHPMailer\\PHPMailer\\Exception' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/Exception.php', + 'PHPMailer\\PHPMailer\\OAuth' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/OAuth.php', + 'PHPMailer\\PHPMailer\\OAuthTokenProvider' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/OAuthTokenProvider.php', + 'PHPMailer\\PHPMailer\\PHPMailer' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/PHPMailer.php', + 'PHPMailer\\PHPMailer\\POP3' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/POP3.php', + 'PHPMailer\\PHPMailer\\SMTP' => __DIR__ . '/..' . '/phpmailer/phpmailer/src/SMTP.php', + 'Parsedown' => __DIR__ . '/../..' . '/kirby/dependencies/parsedown/Parsedown.php', + 'ParsedownExtra' => __DIR__ . '/../..' . '/kirby/dependencies/parsedown-extra/ParsedownExtra.php', + 'Psr\\Log\\AbstractLogger' => __DIR__ . '/..' . '/psr/log/src/AbstractLogger.php', + 'Psr\\Log\\InvalidArgumentException' => __DIR__ . '/..' . '/psr/log/src/InvalidArgumentException.php', + 'Psr\\Log\\LogLevel' => __DIR__ . '/..' . '/psr/log/src/LogLevel.php', + 'Psr\\Log\\LoggerAwareInterface' => __DIR__ . '/..' . '/psr/log/src/LoggerAwareInterface.php', + 'Psr\\Log\\LoggerAwareTrait' => __DIR__ . '/..' . '/psr/log/src/LoggerAwareTrait.php', + 'Psr\\Log\\LoggerInterface' => __DIR__ . '/..' . '/psr/log/src/LoggerInterface.php', + 'Psr\\Log\\LoggerTrait' => __DIR__ . '/..' . '/psr/log/src/LoggerTrait.php', + 'Psr\\Log\\NullLogger' => __DIR__ . '/..' . '/psr/log/src/NullLogger.php', + 'Spyc' => __DIR__ . '/../..' . '/kirby/dependencies/spyc/Spyc.php', + 'Symfony\\Component\\Yaml\\Command\\LintCommand' => __DIR__ . '/..' . '/symfony/yaml/Command/LintCommand.php', + 'Symfony\\Component\\Yaml\\Dumper' => __DIR__ . '/..' . '/symfony/yaml/Dumper.php', + 'Symfony\\Component\\Yaml\\Escaper' => __DIR__ . '/..' . '/symfony/yaml/Escaper.php', + 'Symfony\\Component\\Yaml\\Exception\\DumpException' => __DIR__ . '/..' . '/symfony/yaml/Exception/DumpException.php', + 'Symfony\\Component\\Yaml\\Exception\\ExceptionInterface' => __DIR__ . '/..' . '/symfony/yaml/Exception/ExceptionInterface.php', + 'Symfony\\Component\\Yaml\\Exception\\ParseException' => __DIR__ . '/..' . '/symfony/yaml/Exception/ParseException.php', + 'Symfony\\Component\\Yaml\\Exception\\RuntimeException' => __DIR__ . '/..' . '/symfony/yaml/Exception/RuntimeException.php', + 'Symfony\\Component\\Yaml\\Inline' => __DIR__ . '/..' . '/symfony/yaml/Inline.php', + 'Symfony\\Component\\Yaml\\Parser' => __DIR__ . '/..' . '/symfony/yaml/Parser.php', + 'Symfony\\Component\\Yaml\\Tag\\TaggedValue' => __DIR__ . '/..' . '/symfony/yaml/Tag/TaggedValue.php', + 'Symfony\\Component\\Yaml\\Unescaper' => __DIR__ . '/..' . '/symfony/yaml/Unescaper.php', + 'Symfony\\Component\\Yaml\\Yaml' => __DIR__ . '/..' . '/symfony/yaml/Yaml.php', + 'Symfony\\Polyfill\\Ctype\\Ctype' => __DIR__ . '/..' . '/symfony/polyfill-ctype/Ctype.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Idn' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/Idn.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Info' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/Info.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Resources\\unidata\\DisallowedRanges' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/Resources/unidata/DisallowedRanges.php', + 'Symfony\\Polyfill\\Intl\\Idn\\Resources\\unidata\\Regex' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/Resources/unidata/Regex.php', + 'Symfony\\Polyfill\\Intl\\Normalizer\\Normalizer' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/Normalizer.php', + 'Symfony\\Polyfill\\Mbstring\\Mbstring' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/Mbstring.php', + 'Whoops\\Exception\\ErrorException' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Exception/ErrorException.php', + 'Whoops\\Exception\\Formatter' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Exception/Formatter.php', + 'Whoops\\Exception\\Frame' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Exception/Frame.php', + 'Whoops\\Exception\\FrameCollection' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Exception/FrameCollection.php', + 'Whoops\\Exception\\Inspector' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Exception/Inspector.php', + 'Whoops\\Handler\\CallbackHandler' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/CallbackHandler.php', + 'Whoops\\Handler\\Handler' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/Handler.php', + 'Whoops\\Handler\\HandlerInterface' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/HandlerInterface.php', + 'Whoops\\Handler\\JsonResponseHandler' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/JsonResponseHandler.php', + 'Whoops\\Handler\\PlainTextHandler' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/PlainTextHandler.php', + 'Whoops\\Handler\\PrettyPageHandler' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php', + 'Whoops\\Handler\\XmlResponseHandler' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Handler/XmlResponseHandler.php', + 'Whoops\\Inspector\\InspectorFactory' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Inspector/InspectorFactory.php', + 'Whoops\\Inspector\\InspectorFactoryInterface' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Inspector/InspectorFactoryInterface.php', + 'Whoops\\Inspector\\InspectorInterface' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Inspector/InspectorInterface.php', + 'Whoops\\Run' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Run.php', + 'Whoops\\RunInterface' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/RunInterface.php', + 'Whoops\\Util\\HtmlDumperOutput' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php', + 'Whoops\\Util\\Misc' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Util/Misc.php', + 'Whoops\\Util\\SystemFacade' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Util/SystemFacade.php', + 'Whoops\\Util\\TemplateHelper' => __DIR__ . '/..' . '/filp/whoops/src/Whoops/Util/TemplateHelper.php', + 'claviska\\SimpleImage' => __DIR__ . '/..' . '/claviska/simpleimage/src/claviska/SimpleImage.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit0b7fb803e22a45eb87e24172337208aa::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit0b7fb803e22a45eb87e24172337208aa::$prefixDirsPsr4; + $loader->prefixesPsr0 = ComposerStaticInit0b7fb803e22a45eb87e24172337208aa::$prefixesPsr0; + $loader->classMap = ComposerStaticInit0b7fb803e22a45eb87e24172337208aa::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/public/vendor/composer/installed.json b/public/vendor/composer/installed.json new file mode 100644 index 0000000..755b06b --- /dev/null +++ b/public/vendor/composer/installed.json @@ -0,0 +1,1259 @@ +{ + "packages": [ + { + "name": "christian-riesen/base32", + "version": "1.6.0", + "version_normalized": "1.6.0.0", + "source": { + "type": "git", + "url": "https://github.com/ChristianRiesen/base32.git", + "reference": "2e82dab3baa008e24a505649b0d583c31d31e894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristianRiesen/base32/zipball/2e82dab3baa008e24a505649b0d583c31d31e894", + "reference": "2e82dab3baa008e24a505649b0d583c31d31e894", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.17", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.5.13 || ^9.5" + }, + "time": "2021-02-26T10:19:33+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Base32\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Riesen", + "email": "chris.riesen@gmail.com", + "homepage": "http://christianriesen.com", + "role": "Developer" + } + ], + "description": "Base32 encoder/decoder according to RFC 4648", + "homepage": "https://github.com/ChristianRiesen/base32", + "keywords": [ + "base32", + "decode", + "encode", + "rfc4648" + ], + "support": { + "issues": "https://github.com/ChristianRiesen/base32/issues", + "source": "https://github.com/ChristianRiesen/base32/tree/1.6.0" + }, + "install-path": "../christian-riesen/base32" + }, + { + "name": "claviska/simpleimage", + "version": "4.2.1", + "version_normalized": "4.2.1.0", + "source": { + "type": "git", + "url": "https://github.com/claviska/SimpleImage.git", + "reference": "ec6d5021e5a7153a2520d64c59b86b6f3c4157c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/claviska/SimpleImage/zipball/ec6d5021e5a7153a2520d64c59b86b6f3c4157c5", + "reference": "ec6d5021e5a7153a2520d64c59b86b6f3c4157c5", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "league/color-extractor": "0.4.*", + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.5", + "phpstan/phpstan": "^1.10" + }, + "time": "2024-11-22T13:25:03+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-0": { + "claviska": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cory LaViska", + "homepage": "http://www.abeautifulsite.net/", + "role": "Developer" + } + ], + "description": "A PHP class that makes working with images as simple as possible.", + "support": { + "issues": "https://github.com/claviska/SimpleImage/issues", + "source": "https://github.com/claviska/SimpleImage/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/claviska", + "type": "github" + } + ], + "install-path": "../claviska/simpleimage" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "version_normalized": "3.4.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "time": "2025-08-20T19:15:30+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "install-path": "./semver" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "version_normalized": "2.18.4.0", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "time": "2025-08-08T12:00:00+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "install-path": "../filp/whoops" + }, + { + "name": "getkirby/cms", + "version": "5.1.4", + "version_normalized": "5.1.4.0", + "source": { + "type": "git", + "url": "https://github.com/getkirby/kirby.git", + "reference": "2278dae6b41879bd1a8ac70ae62a6ea781fc629a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getkirby/kirby/zipball/2278dae6b41879bd1a8ac70ae62a6ea781fc629a", + "reference": "2278dae6b41879bd1a8ac70ae62a6ea781fc629a", + "shasum": "" + }, + "require": { + "christian-riesen/base32": "1.6.0", + "claviska/simpleimage": "4.2.1", + "composer/semver": "3.4.4", + "ext-ctype": "*", + "ext-curl": "*", + "ext-dom": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "filp/whoops": "2.18.4", + "getkirby/composer-installer": "^1.2.1", + "laminas/laminas-escaper": "2.18.0", + "michelf/php-smartypants": "1.8.1", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "phpmailer/phpmailer": "6.10.0", + "symfony/polyfill-intl-idn": "1.33.0", + "symfony/polyfill-mbstring": "1.33.0", + "symfony/yaml": "7.3.5" + }, + "replace": { + "symfony/polyfill-php72": "*" + }, + "suggest": { + "ext-PDO": "Support for using databases", + "ext-apcu": "Support for the Apcu cache driver", + "ext-exif": "Support for exif information from images", + "ext-fileinfo": "Improved mime type detection for files", + "ext-imagick": "Improved thumbnail generation", + "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" + }, + "time": "2025-11-18T10:47:26+00:00", + "type": "kirby-cms", + "extra": { + "unused": [ + "symfony/polyfill-intl-idn" + ] + }, + "installation-source": "dist", + "autoload": { + "files": [ + "config/setup.php", + "config/helpers.php" + ], + "psr-4": { + "Kirby\\": "src/" + }, + "classmap": [ + "dependencies/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "proprietary" + ], + "authors": [ + { + "name": "Kirby Team", + "email": "support@getkirby.com", + "homepage": "https://getkirby.com" + } + ], + "description": "The Kirby core", + "homepage": "https://getkirby.com", + "keywords": [ + "cms", + "core", + "kirby" + ], + "support": { + "email": "support@getkirby.com", + "forum": "https://forum.getkirby.com", + "issues": "https://github.com/getkirby/kirby/issues", + "source": "https://github.com/getkirby/kirby" + }, + "funding": [ + { + "url": "https://getkirby.com/buy", + "type": "custom" + } + ], + "install-path": "../../kirby" + }, + { + "name": "getkirby/composer-installer", + "version": "1.2.1", + "version_normalized": "1.2.1.0", + "source": { + "type": "git", + "url": "https://github.com/getkirby/composer-installer.git", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^1.8 || ^2.0" + }, + "time": "2020-12-28T12:54:39+00:00", + "type": "composer-plugin", + "extra": { + "class": "Kirby\\ComposerInstaller\\Plugin" + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Kirby\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins", + "homepage": "https://getkirby.com", + "support": { + "issues": "https://github.com/getkirby/composer-installer/issues", + "source": "https://github.com/getkirby/composer-installer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://getkirby.com/buy", + "type": "custom" + } + ], + "install-path": "../getkirby/composer-installer" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.18.0", + "version_normalized": "2.18.0.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "time": "2025-10-14T18:31:13+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "install-path": "../laminas/laminas-escaper" + }, + { + "name": "league/color-extractor", + "version": "0.4.0", + "version_normalized": "0.4.0.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/color-extractor.git", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/21fcac6249c5ef7d00eb83e128743ee6678fe505", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "php": "^7.3 || ^8.0" + }, + "replace": { + "matthecat/colorextractor": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons" + }, + "time": "2022-09-24T15:57:16+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "League\\ColorExtractor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "description": "Extract colors from an image as a human would do.", + "homepage": "https://github.com/thephpleague/color-extractor", + "keywords": [ + "color", + "extract", + "human", + "image", + "palette" + ], + "support": { + "issues": "https://github.com/thephpleague/color-extractor/issues", + "source": "https://github.com/thephpleague/color-extractor/tree/0.4.0" + }, + "install-path": "../league/color-extractor" + }, + { + "name": "michelf/php-smartypants", + "version": "1.8.1", + "version_normalized": "1.8.1.0", + "source": { + "type": "git", + "url": "https://github.com/michelf/php-smartypants.git", + "reference": "47d17c90a4dfd0ccf1f87e25c65e6c8012415aad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/michelf/php-smartypants/zipball/47d17c90a4dfd0ccf1f87e25c65e6c8012415aad", + "reference": "47d17c90a4dfd0ccf1f87e25c65e6c8012415aad", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "time": "2016-12-13T01:01:17+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-0": { + "Michelf": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Michel Fortin", + "email": "michel.fortin@michelf.ca", + "homepage": "https://michelf.ca/", + "role": "Developer" + }, + { + "name": "John Gruber", + "homepage": "https://daringfireball.net/" + } + ], + "description": "PHP SmartyPants", + "homepage": "https://michelf.ca/projects/php-smartypants/", + "keywords": [ + "dashes", + "quotes", + "spaces", + "typographer", + "typography" + ], + "support": { + "issues": "https://github.com/michelf/php-smartypants/issues", + "source": "https://github.com/michelf/php-smartypants/tree/1.8.1" + }, + "install-path": "../michelf/php-smartypants" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.10.0", + "version_normalized": "6.10.0.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "time": "2025-04-24T15:19:31+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "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.10.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "install-path": "../phpmailer/phpmailer" + }, + { + "name": "psr/log", + "version": "3.0.2", + "version_normalized": "3.0.2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "time": "2024-09-11T13:17:53+00:00", + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "installation-source": "dist", + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "install-path": "../psr/log" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "version_normalized": "3.6.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "time": "2024-09-25T14:21:43+00:00", + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "installation-source": "dist", + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "install-path": "../symfony/deprecation-contracts" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "version_normalized": "1.33.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "time": "2024-09-09T11:45:10+00:00", + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "installation-source": "dist", + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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" + } + ], + "install-path": "../symfony/polyfill-ctype" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "version_normalized": "1.33.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "time": "2024-09-10T14:38:51+00:00", + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "installation-source": "dist", + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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" + } + ], + "install-path": "../symfony/polyfill-intl-idn" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "version_normalized": "1.33.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "time": "2024-09-09T11:45:10+00:00", + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "installation-source": "dist", + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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" + } + ], + "install-path": "../symfony/polyfill-intl-normalizer" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "version_normalized": "1.33.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "time": "2024-12-23T08:48:59+00:00", + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "installation-source": "dist", + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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" + } + ], + "install-path": "../symfony/polyfill-mbstring" + }, + { + "name": "symfony/yaml", + "version": "v7.3.5", + "version_normalized": "7.3.5.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "time": "2025-09-27T09:00:46+00:00", + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "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" + } + ], + "install-path": "../symfony/yaml" + } + ], + "dev": true, + "dev-package-names": [] +} diff --git a/public/vendor/composer/installed.php b/public/vendor/composer/installed.php new file mode 100644 index 0000000..b182cad --- /dev/null +++ b/public/vendor/composer/installed.php @@ -0,0 +1,188 @@ + array( + 'name' => 'getkirby/plainkit', + 'pretty_version' => '5.1.4', + 'version' => '5.1.4.0', + 'reference' => null, + 'type' => 'project', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev' => true, + ), + 'versions' => array( + 'christian-riesen/base32' => array( + 'pretty_version' => '1.6.0', + 'version' => '1.6.0.0', + 'reference' => '2e82dab3baa008e24a505649b0d583c31d31e894', + 'type' => 'library', + 'install_path' => __DIR__ . '/../christian-riesen/base32', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'claviska/simpleimage' => array( + 'pretty_version' => '4.2.1', + 'version' => '4.2.1.0', + 'reference' => 'ec6d5021e5a7153a2520d64c59b86b6f3c4157c5', + 'type' => 'library', + 'install_path' => __DIR__ . '/../claviska/simpleimage', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'composer/semver' => array( + 'pretty_version' => '3.4.4', + 'version' => '3.4.4.0', + 'reference' => '198166618906cb2de69b95d7d47e5fa8aa1b2b95', + 'type' => 'library', + 'install_path' => __DIR__ . '/./semver', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'filp/whoops' => array( + 'pretty_version' => '2.18.4', + 'version' => '2.18.4.0', + 'reference' => 'd2102955e48b9fd9ab24280a7ad12ed552752c4d', + 'type' => 'library', + 'install_path' => __DIR__ . '/../filp/whoops', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'getkirby/cms' => array( + 'pretty_version' => '5.1.4', + 'version' => '5.1.4.0', + 'reference' => '2278dae6b41879bd1a8ac70ae62a6ea781fc629a', + 'type' => 'kirby-cms', + 'install_path' => __DIR__ . '/../../kirby', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'getkirby/composer-installer' => array( + 'pretty_version' => '1.2.1', + 'version' => '1.2.1.0', + 'reference' => 'c98ece30bfba45be7ce457e1102d1b169d922f3d', + 'type' => 'composer-plugin', + 'install_path' => __DIR__ . '/../getkirby/composer-installer', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'getkirby/plainkit' => array( + 'pretty_version' => '5.1.4', + 'version' => '5.1.4.0', + 'reference' => null, + 'type' => 'project', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'laminas/laminas-escaper' => array( + 'pretty_version' => '2.18.0', + 'version' => '2.18.0.0', + 'reference' => '06f211dfffff18d91844c1f55250d5d13c007e18', + 'type' => 'library', + 'install_path' => __DIR__ . '/../laminas/laminas-escaper', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'league/color-extractor' => array( + 'pretty_version' => '0.4.0', + 'version' => '0.4.0.0', + 'reference' => '21fcac6249c5ef7d00eb83e128743ee6678fe505', + 'type' => 'library', + 'install_path' => __DIR__ . '/../league/color-extractor', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'matthecat/colorextractor' => array( + 'dev_requirement' => false, + 'replaced' => array( + 0 => '*', + ), + ), + 'michelf/php-smartypants' => array( + 'pretty_version' => '1.8.1', + 'version' => '1.8.1.0', + 'reference' => '47d17c90a4dfd0ccf1f87e25c65e6c8012415aad', + 'type' => 'library', + 'install_path' => __DIR__ . '/../michelf/php-smartypants', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'phpmailer/phpmailer' => array( + 'pretty_version' => 'v6.10.0', + 'version' => '6.10.0.0', + 'reference' => 'bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144', + 'type' => 'library', + 'install_path' => __DIR__ . '/../phpmailer/phpmailer', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'psr/log' => array( + 'pretty_version' => '3.0.2', + 'version' => '3.0.2.0', + 'reference' => 'f16e1d5863e37f8d8c2a01719f5b34baa2b714d3', + 'type' => 'library', + 'install_path' => __DIR__ . '/../psr/log', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'symfony/deprecation-contracts' => array( + 'pretty_version' => 'v3.6.0', + 'version' => '3.6.0.0', + 'reference' => '63afe740e99a13ba87ec199bb07bbdee937a5b62', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/deprecation-contracts', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'symfony/polyfill-ctype' => array( + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => 'a3cc8b044a6ea513310cbd48ef7333b384945638', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/polyfill-ctype', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'symfony/polyfill-intl-idn' => array( + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '9614ac4d8061dc257ecc64cba1b140873dce8ad3', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/polyfill-intl-idn', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'symfony/polyfill-intl-normalizer' => array( + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '3833d7255cc303546435cb650316bff708a1c75c', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/polyfill-intl-normalizer', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'symfony/polyfill-mbstring' => array( + 'pretty_version' => 'v1.33.0', + 'version' => '1.33.0.0', + 'reference' => '6d857f4d76bd4b343eac26d6b539585d2bc56493', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/polyfill-mbstring', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'symfony/polyfill-php72' => array( + 'dev_requirement' => false, + 'replaced' => array( + 0 => '*', + ), + ), + 'symfony/yaml' => array( + 'pretty_version' => 'v7.3.5', + 'version' => '7.3.5.0', + 'reference' => '90208e2fc6f68f613eae7ca25a2458a931b1bacc', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfony/yaml', + 'aliases' => array(), + 'dev_requirement' => false, + ), + ), +); diff --git a/public/vendor/composer/platform_check.php b/public/vendor/composer/platform_check.php new file mode 100644 index 0000000..d32d90c --- /dev/null +++ b/public/vendor/composer/platform_check.php @@ -0,0 +1,26 @@ += 80200)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.'; +} + +if ($issues) { + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); + } elseif (!headers_sent()) { + echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; + } + } + trigger_error( + 'Composer detected issues in your platform: ' . implode(' ', $issues), + E_USER_ERROR + ); +} diff --git a/public/vendor/composer/semver/CHANGELOG.md b/public/vendor/composer/semver/CHANGELOG.md new file mode 100644 index 0000000..bad46cd --- /dev/null +++ b/public/vendor/composer/semver/CHANGELOG.md @@ -0,0 +1,229 @@ +# Change Log + +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org/). + +### [3.4.3] 2024-09-19 + + * Fixed some type annotations + +### [3.4.2] 2024-07-12 + + * Fixed PHP 5.3 syntax error + +### [3.4.1] 2024-07-12 + + * Fixed normalizeStability's return type to enforce valid stabilities + +### [3.4.0] 2023-08-31 + + * Support larger major version numbers (#149) + +### [3.3.2] 2022-04-01 + + * Fixed handling of non-string values (#134) + +### [3.3.1] 2022-03-16 + + * Fixed possible cache key clash in the CompilingMatcher memoization (#132) + +### [3.3.0] 2022-03-15 + + * Improved performance of CompilingMatcher by memoizing more (#131) + * Added CompilingMatcher::clear to clear all memoization caches + +### [3.2.9] 2022-02-04 + + * Revert #129 (Fixed MultiConstraint with MatchAllConstraint) which caused regressions + +### [3.2.8] 2022-02-04 + + * Updates to latest phpstan / CI by @Seldaek in https://github.com/composer/semver/pull/130 + * Fixed MultiConstraint with MatchAllConstraint by @Toflar in https://github.com/composer/semver/pull/129 + +### [3.2.7] 2022-01-04 + + * Fixed: typo in type definition of Intervals class causing issues with Psalm scanning vendors + +### [3.2.6] 2021-10-25 + + * Fixed: type improvements to parseStability + +### [3.2.5] 2021-05-24 + + * Fixed: issue comparing disjunctive MultiConstraints to conjunctive ones (#127) + * Fixed: added complete type information using phpstan annotations + +### [3.2.4] 2020-11-13 + + * Fixed: code clean-up + +### [3.2.3] 2020-11-12 + + * Fixed: constraints in the form of `X || Y, >=Y.1` and other such complex constructs were in some cases being optimized into a more restrictive constraint + +### [3.2.2] 2020-10-14 + + * Fixed: internal code cleanups + +### [3.2.1] 2020-09-27 + + * Fixed: accidental validation of broken constraints combining ^/~ and wildcards, and -dev suffix allowing weird cases + * Fixed: normalization of beta0 and such which was dropping the 0 + +### [3.2.0] 2020-09-09 + + * Added: support for `x || @dev`, not very useful but seen in the wild and failed to validate with 1.5.2/1.6.0 + * Added: support for `foobar-dev` being equal to `dev-foobar`, dev-foobar is the official way to write it but we need to support the other for BC and convenience + +### [3.1.0] 2020-09-08 + + * Added: support for constraints like `^2.x-dev` and `~2.x-dev`, not very useful but seen in the wild and failed to validate with 3.0.1 + * Fixed: invalid aliases will no longer throw, unless explicitly validated by Composer in the root package + +### [3.0.1] 2020-09-08 + + * Fixed: handling of some invalid -dev versions which were seen as valid + +### [3.0.0] 2020-05-26 + + * Break: Renamed `EmptyConstraint`, replace it with `MatchAllConstraint` + * Break: Unlikely to affect anyone but strictly speaking a breaking change, `*.*` and such variants will not match all `dev-*` versions anymore, only `*` does + * Break: ConstraintInterface is now considered internal/private and not meant to be implemented by third parties anymore + * Added `Intervals` class to check if a constraint is a subsets of another one, and allow compacting complex MultiConstraints into simpler ones + * Added `CompilingMatcher` class to speed up constraint matching against simple Constraint instances + * Added `MatchAllConstraint` and `MatchNoneConstraint` which match everything and nothing + * Added more advanced optimization of contiguous constraints inside MultiConstraint + * Added tentative support for PHP 8 + * Fixed ConstraintInterface::matches to be commutative in all cases + +### [2.0.0] 2020-04-21 + + * Break: `dev-master`, `dev-trunk` and `dev-default` now normalize to `dev-master`, `dev-trunk` and `dev-default` instead of `9999999-dev` in 1.x + * Break: Removed the deprecated `AbstractConstraint` + * Added `getUpperBound` and `getLowerBound` to ConstraintInterface. They return `Composer\Semver\Constraint\Bound` instances + * Added `MultiConstraint::create` to create the most-optimal form of ConstraintInterface from an array of constraint strings + +### [1.7.2] 2020-12-03 + + * Fixed: Allow installing on php 8 + +### [1.7.1] 2020-09-27 + + * Fixed: accidental validation of broken constraints combining ^/~ and wildcards, and -dev suffix allowing weird cases + * Fixed: normalization of beta0 and such which was dropping the 0 + +### [1.7.0] 2020-09-09 + + * Added: support for `x || @dev`, not very useful but seen in the wild and failed to validate with 1.5.2/1.6.0 + * Added: support for `foobar-dev` being equal to `dev-foobar`, dev-foobar is the official way to write it but we need to support the other for BC and convenience + +### [1.6.0] 2020-09-08 + + * Added: support for constraints like `^2.x-dev` and `~2.x-dev`, not very useful but seen in the wild and failed to validate with 1.5.2 + * Fixed: invalid aliases will no longer throw, unless explicitly validated by Composer in the root package + +### [1.5.2] 2020-09-08 + + * Fixed: handling of some invalid -dev versions which were seen as valid + * Fixed: some doctypes + +### [1.5.1] 2020-01-13 + + * Fixed: Parsing of aliased version was not validating the alias to be a valid version + +### [1.5.0] 2019-03-19 + + * Added: some support for date versions (e.g. 201903) in `~` operator + * Fixed: support for stabilities in `~` operator was inconsistent + +### [1.4.2] 2016-08-30 + + * Fixed: collapsing of complex constraints lead to buggy constraints + +### [1.4.1] 2016-06-02 + + * Changed: branch-like requirements no longer strip build metadata - [composer/semver#38](https://github.com/composer/semver/pull/38). + +### [1.4.0] 2016-03-30 + + * Added: getters on MultiConstraint - [composer/semver#35](https://github.com/composer/semver/pull/35). + +### [1.3.0] 2016-02-25 + + * Fixed: stability parsing - [composer/composer#1234](https://github.com/composer/composer/issues/4889). + * Changed: collapse contiguous constraints when possible. + +### [1.2.0] 2015-11-10 + + * Changed: allow multiple numerical identifiers in 'pre-release' version part. + * Changed: add more 'v' prefix support. + +### [1.1.0] 2015-11-03 + + * Changed: dropped redundant `test` namespace. + * Changed: minor adjustment in datetime parsing normalization. + * Changed: `ConstraintInterface` relaxed, setPrettyString is not required anymore. + * Changed: `AbstractConstraint` marked deprecated, will be removed in 2.0. + * Changed: `Constraint` is now extensible. + +### [1.0.0] 2015-09-21 + + * Break: `VersionConstraint` renamed to `Constraint`. + * Break: `SpecificConstraint` renamed to `AbstractConstraint`. + * Break: `LinkConstraintInterface` renamed to `ConstraintInterface`. + * Break: `VersionParser::parseNameVersionPairs` was removed. + * Changed: `VersionParser::parseConstraints` allows (but ignores) build metadata now. + * Changed: `VersionParser::parseConstraints` allows (but ignores) prefixing numeric versions with a 'v' now. + * Changed: Fixed namespace(s) of test files. + * Changed: `Comparator::compare` no longer throws `InvalidArgumentException`. + * Changed: `Constraint` now throws `InvalidArgumentException`. + +### [0.1.0] 2015-07-23 + + * Added: `Composer\Semver\Comparator`, various methods to compare versions. + * Added: various documents such as README.md, LICENSE, etc. + * Added: configuration files for Git, Travis, php-cs-fixer, phpunit. + * Break: the following namespaces were renamed: + - Namespace: `Composer\Package\Version` -> `Composer\Semver` + - Namespace: `Composer\Package\LinkConstraint` -> `Composer\Semver\Constraint` + - Namespace: `Composer\Test\Package\Version` -> `Composer\Test\Semver` + - Namespace: `Composer\Test\Package\LinkConstraint` -> `Composer\Test\Semver\Constraint` + * Changed: code style using php-cs-fixer. + +[3.4.3]: https://github.com/composer/semver/compare/3.4.2...3.4.3 +[3.4.2]: https://github.com/composer/semver/compare/3.4.1...3.4.2 +[3.4.1]: https://github.com/composer/semver/compare/3.4.0...3.4.1 +[3.4.0]: https://github.com/composer/semver/compare/3.3.2...3.4.0 +[3.3.2]: https://github.com/composer/semver/compare/3.3.1...3.3.2 +[3.3.1]: https://github.com/composer/semver/compare/3.3.0...3.3.1 +[3.3.0]: https://github.com/composer/semver/compare/3.2.9...3.3.0 +[3.2.9]: https://github.com/composer/semver/compare/3.2.8...3.2.9 +[3.2.8]: https://github.com/composer/semver/compare/3.2.7...3.2.8 +[3.2.7]: https://github.com/composer/semver/compare/3.2.6...3.2.7 +[3.2.6]: https://github.com/composer/semver/compare/3.2.5...3.2.6 +[3.2.5]: https://github.com/composer/semver/compare/3.2.4...3.2.5 +[3.2.4]: https://github.com/composer/semver/compare/3.2.3...3.2.4 +[3.2.3]: https://github.com/composer/semver/compare/3.2.2...3.2.3 +[3.2.2]: https://github.com/composer/semver/compare/3.2.1...3.2.2 +[3.2.1]: https://github.com/composer/semver/compare/3.2.0...3.2.1 +[3.2.0]: https://github.com/composer/semver/compare/3.1.0...3.2.0 +[3.1.0]: https://github.com/composer/semver/compare/3.0.1...3.1.0 +[3.0.1]: https://github.com/composer/semver/compare/3.0.0...3.0.1 +[3.0.0]: https://github.com/composer/semver/compare/2.0.0...3.0.0 +[2.0.0]: https://github.com/composer/semver/compare/1.5.1...2.0.0 +[1.7.2]: https://github.com/composer/semver/compare/1.7.1...1.7.2 +[1.7.1]: https://github.com/composer/semver/compare/1.7.0...1.7.1 +[1.7.0]: https://github.com/composer/semver/compare/1.6.0...1.7.0 +[1.6.0]: https://github.com/composer/semver/compare/1.5.2...1.6.0 +[1.5.2]: https://github.com/composer/semver/compare/1.5.1...1.5.2 +[1.5.1]: https://github.com/composer/semver/compare/1.5.0...1.5.1 +[1.5.0]: https://github.com/composer/semver/compare/1.4.2...1.5.0 +[1.4.2]: https://github.com/composer/semver/compare/1.4.1...1.4.2 +[1.4.1]: https://github.com/composer/semver/compare/1.4.0...1.4.1 +[1.4.0]: https://github.com/composer/semver/compare/1.3.0...1.4.0 +[1.3.0]: https://github.com/composer/semver/compare/1.2.0...1.3.0 +[1.2.0]: https://github.com/composer/semver/compare/1.1.0...1.2.0 +[1.1.0]: https://github.com/composer/semver/compare/1.0.0...1.1.0 +[1.0.0]: https://github.com/composer/semver/compare/0.1.0...1.0.0 +[0.1.0]: https://github.com/composer/semver/compare/5e0b9a4da...0.1.0 diff --git a/public/vendor/composer/semver/LICENSE b/public/vendor/composer/semver/LICENSE new file mode 100644 index 0000000..4669758 --- /dev/null +++ b/public/vendor/composer/semver/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2015 Composer + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/public/vendor/composer/semver/README.md b/public/vendor/composer/semver/README.md new file mode 100644 index 0000000..7677849 --- /dev/null +++ b/public/vendor/composer/semver/README.md @@ -0,0 +1,99 @@ +composer/semver +=============== + +Semver (Semantic Versioning) library that offers utilities, version constraint parsing and validation. + +Originally written as part of [composer/composer](https://github.com/composer/composer), +now extracted and made available as a stand-alone library. + +[![Continuous Integration](https://github.com/composer/semver/actions/workflows/continuous-integration.yml/badge.svg?branch=main)](https://github.com/composer/semver/actions/workflows/continuous-integration.yml) +[![PHP Lint](https://github.com/composer/semver/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/composer/semver/actions/workflows/lint.yml) +[![PHPStan](https://github.com/composer/semver/actions/workflows/phpstan.yml/badge.svg?branch=main)](https://github.com/composer/semver/actions/workflows/phpstan.yml) + +Installation +------------ + +Install the latest version with: + +```bash +composer require composer/semver +``` + + +Requirements +------------ + +* PHP 5.3.2 is required but using the latest version of PHP is highly recommended. + + +Version Comparison +------------------ + +For details on how versions are compared, refer to the [Versions](https://getcomposer.org/doc/articles/versions.md) +article in the documentation section of the [getcomposer.org](https://getcomposer.org) website. + + +Basic usage +----------- + +### Comparator + +The [`Composer\Semver\Comparator`](https://github.com/composer/semver/blob/main/src/Comparator.php) class provides the following methods for comparing versions: + +* greaterThan($v1, $v2) +* greaterThanOrEqualTo($v1, $v2) +* lessThan($v1, $v2) +* lessThanOrEqualTo($v1, $v2) +* equalTo($v1, $v2) +* notEqualTo($v1, $v2) + +Each function takes two version strings as arguments and returns a boolean. For example: + +```php +use Composer\Semver\Comparator; + +Comparator::greaterThan('1.25.0', '1.24.0'); // 1.25.0 > 1.24.0 +``` + +### Semver + +The [`Composer\Semver\Semver`](https://github.com/composer/semver/blob/main/src/Semver.php) class provides the following methods: + +* satisfies($version, $constraints) +* satisfiedBy(array $versions, $constraint) +* sort($versions) +* rsort($versions) + +### Intervals + +The [`Composer\Semver\Intervals`](https://github.com/composer/semver/blob/main/src/Intervals.php) static class provides +a few utilities to work with complex constraints or read version intervals from a constraint: + +```php +use Composer\Semver\Intervals; + +// Checks whether $candidate is a subset of $constraint +Intervals::isSubsetOf(ConstraintInterface $candidate, ConstraintInterface $constraint); + +// Checks whether $a and $b have any intersection, equivalent to $a->matches($b) +Intervals::haveIntersections(ConstraintInterface $a, ConstraintInterface $b); + +// Optimizes a complex multi constraint by merging all intervals down to the smallest +// possible multi constraint. The drawbacks are this is not very fast, and the resulting +// multi constraint will have no human readable prettyConstraint configured on it +Intervals::compactConstraint(ConstraintInterface $constraint); + +// Creates an array of numeric intervals and branch constraints representing a given constraint +Intervals::get(ConstraintInterface $constraint); + +// Clears the memoization cache when you are done processing constraints +Intervals::clear() +``` + +See the class docblocks for more details. + + +License +------- + +composer/semver is licensed under the MIT License, see the LICENSE file for details. diff --git a/public/vendor/composer/semver/composer.json b/public/vendor/composer/semver/composer.json new file mode 100644 index 0000000..1fad9e5 --- /dev/null +++ b/public/vendor/composer/semver/composer.json @@ -0,0 +1,59 @@ +{ + "name": "composer/semver", + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "type": "library", + "license": "MIT", + "keywords": [ + "semver", + "semantic", + "versioning", + "validation" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^7", + "phpstan/phpstan": "^1.11" + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Composer\\Semver\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "scripts": { + "test": "SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT=1 vendor/bin/simple-phpunit", + "phpstan": "@php vendor/bin/phpstan analyse" + } +} diff --git a/public/vendor/composer/semver/src/Comparator.php b/public/vendor/composer/semver/src/Comparator.php new file mode 100644 index 0000000..38f483a --- /dev/null +++ b/public/vendor/composer/semver/src/Comparator.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver; + +use Composer\Semver\Constraint\Constraint; + +class Comparator +{ + /** + * Evaluates the expression: $version1 > $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + */ + public static function greaterThan($version1, $version2) + { + return self::compare($version1, '>', $version2); + } + + /** + * Evaluates the expression: $version1 >= $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + */ + public static function greaterThanOrEqualTo($version1, $version2) + { + return self::compare($version1, '>=', $version2); + } + + /** + * Evaluates the expression: $version1 < $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + */ + public static function lessThan($version1, $version2) + { + return self::compare($version1, '<', $version2); + } + + /** + * Evaluates the expression: $version1 <= $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + */ + public static function lessThanOrEqualTo($version1, $version2) + { + return self::compare($version1, '<=', $version2); + } + + /** + * Evaluates the expression: $version1 == $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + */ + public static function equalTo($version1, $version2) + { + return self::compare($version1, '==', $version2); + } + + /** + * Evaluates the expression: $version1 != $version2. + * + * @param string $version1 + * @param string $version2 + * + * @return bool + */ + public static function notEqualTo($version1, $version2) + { + return self::compare($version1, '!=', $version2); + } + + /** + * Evaluates the expression: $version1 $operator $version2. + * + * @param string $version1 + * @param string $operator + * @param string $version2 + * + * @return bool + * + * @phpstan-param Constraint::STR_OP_* $operator + */ + public static function compare($version1, $operator, $version2) + { + $constraint = new Constraint($operator, $version2); + + return $constraint->matchSpecific(new Constraint('==', $version1), true); + } +} diff --git a/public/vendor/composer/semver/src/CompilingMatcher.php b/public/vendor/composer/semver/src/CompilingMatcher.php new file mode 100644 index 0000000..aea1d3b --- /dev/null +++ b/public/vendor/composer/semver/src/CompilingMatcher.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver; + +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\ConstraintInterface; + +/** + * Helper class to evaluate constraint by compiling and reusing the code to evaluate + */ +class CompilingMatcher +{ + /** + * @var array + * @phpstan-var array + */ + private static $compiledCheckerCache = array(); + /** + * @var array + * @phpstan-var array + */ + private static $resultCache = array(); + + /** @var bool */ + private static $enabled; + + /** + * @phpstan-var array + */ + private static $transOpInt = array( + Constraint::OP_EQ => Constraint::STR_OP_EQ, + Constraint::OP_LT => Constraint::STR_OP_LT, + Constraint::OP_LE => Constraint::STR_OP_LE, + Constraint::OP_GT => Constraint::STR_OP_GT, + Constraint::OP_GE => Constraint::STR_OP_GE, + Constraint::OP_NE => Constraint::STR_OP_NE, + ); + + /** + * Clears the memoization cache once you are done + * + * @return void + */ + public static function clear() + { + self::$resultCache = array(); + self::$compiledCheckerCache = array(); + } + + /** + * Evaluates the expression: $constraint match $operator $version + * + * @param ConstraintInterface $constraint + * @param int $operator + * @phpstan-param Constraint::OP_* $operator + * @param string $version + * + * @return bool + */ + public static function match(ConstraintInterface $constraint, $operator, $version) + { + $resultCacheKey = $operator.$constraint.';'.$version; + + if (isset(self::$resultCache[$resultCacheKey])) { + return self::$resultCache[$resultCacheKey]; + } + + if (self::$enabled === null) { + self::$enabled = !\in_array('eval', explode(',', (string) ini_get('disable_functions')), true); + } + if (!self::$enabled) { + return self::$resultCache[$resultCacheKey] = $constraint->matches(new Constraint(self::$transOpInt[$operator], $version)); + } + + $cacheKey = $operator.$constraint; + if (!isset(self::$compiledCheckerCache[$cacheKey])) { + $code = $constraint->compile($operator); + self::$compiledCheckerCache[$cacheKey] = $function = eval('return function($v, $b){return '.$code.';};'); + } else { + $function = self::$compiledCheckerCache[$cacheKey]; + } + + return self::$resultCache[$resultCacheKey] = $function($version, strpos($version, 'dev-') === 0); + } +} diff --git a/public/vendor/composer/semver/src/Constraint/Bound.php b/public/vendor/composer/semver/src/Constraint/Bound.php new file mode 100644 index 0000000..7effb11 --- /dev/null +++ b/public/vendor/composer/semver/src/Constraint/Bound.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver\Constraint; + +class Bound +{ + /** + * @var string + */ + private $version; + + /** + * @var bool + */ + private $isInclusive; + + /** + * @param string $version + * @param bool $isInclusive + */ + public function __construct($version, $isInclusive) + { + $this->version = $version; + $this->isInclusive = $isInclusive; + } + + /** + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * @return bool + */ + public function isInclusive() + { + return $this->isInclusive; + } + + /** + * @return bool + */ + public function isZero() + { + return $this->getVersion() === '0.0.0.0-dev' && $this->isInclusive(); + } + + /** + * @return bool + */ + public function isPositiveInfinity() + { + return $this->getVersion() === PHP_INT_MAX.'.0.0.0' && !$this->isInclusive(); + } + + /** + * Compares a bound to another with a given operator. + * + * @param Bound $other + * @param string $operator + * + * @return bool + */ + public function compareTo(Bound $other, $operator) + { + if (!\in_array($operator, array('<', '>'), true)) { + throw new \InvalidArgumentException('Does not support any other operator other than > or <.'); + } + + // If they are the same it doesn't matter + if ($this == $other) { + return false; + } + + $compareResult = version_compare($this->getVersion(), $other->getVersion()); + + // Not the same version means we don't need to check if the bounds are inclusive or not + if (0 !== $compareResult) { + return (('>' === $operator) ? 1 : -1) === $compareResult; + } + + // Question we're answering here is "am I higher than $other?" + return '>' === $operator ? $other->isInclusive() : !$other->isInclusive(); + } + + public function __toString() + { + return sprintf( + '%s [%s]', + $this->getVersion(), + $this->isInclusive() ? 'inclusive' : 'exclusive' + ); + } + + /** + * @return self + */ + public static function zero() + { + return new Bound('0.0.0.0-dev', true); + } + + /** + * @return self + */ + public static function positiveInfinity() + { + return new Bound(PHP_INT_MAX.'.0.0.0', false); + } +} diff --git a/public/vendor/composer/semver/src/Constraint/Constraint.php b/public/vendor/composer/semver/src/Constraint/Constraint.php new file mode 100644 index 0000000..dc39482 --- /dev/null +++ b/public/vendor/composer/semver/src/Constraint/Constraint.php @@ -0,0 +1,435 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver\Constraint; + +/** + * Defines a constraint. + */ +class Constraint implements ConstraintInterface +{ + /* operator integer values */ + const OP_EQ = 0; + const OP_LT = 1; + const OP_LE = 2; + const OP_GT = 3; + const OP_GE = 4; + const OP_NE = 5; + + /* operator string values */ + const STR_OP_EQ = '=='; + const STR_OP_EQ_ALT = '='; + const STR_OP_LT = '<'; + const STR_OP_LE = '<='; + const STR_OP_GT = '>'; + const STR_OP_GE = '>='; + const STR_OP_NE = '!='; + const STR_OP_NE_ALT = '<>'; + + /** + * Operator to integer translation table. + * + * @var array + * @phpstan-var array + */ + private static $transOpStr = array( + '=' => self::OP_EQ, + '==' => self::OP_EQ, + '<' => self::OP_LT, + '<=' => self::OP_LE, + '>' => self::OP_GT, + '>=' => self::OP_GE, + '<>' => self::OP_NE, + '!=' => self::OP_NE, + ); + + /** + * Integer to operator translation table. + * + * @var array + * @phpstan-var array + */ + private static $transOpInt = array( + self::OP_EQ => '==', + self::OP_LT => '<', + self::OP_LE => '<=', + self::OP_GT => '>', + self::OP_GE => '>=', + self::OP_NE => '!=', + ); + + /** + * @var int + * @phpstan-var self::OP_* + */ + protected $operator; + + /** @var string */ + protected $version; + + /** @var string|null */ + protected $prettyString; + + /** @var Bound */ + protected $lowerBound; + + /** @var Bound */ + protected $upperBound; + + /** + * Sets operator and version to compare with. + * + * @param string $operator + * @param string $version + * + * @throws \InvalidArgumentException if invalid operator is given. + * + * @phpstan-param self::STR_OP_* $operator + */ + public function __construct($operator, $version) + { + if (!isset(self::$transOpStr[$operator])) { + throw new \InvalidArgumentException(sprintf( + 'Invalid operator "%s" given, expected one of: %s', + $operator, + implode(', ', self::getSupportedOperators()) + )); + } + + $this->operator = self::$transOpStr[$operator]; + $this->version = $version; + } + + /** + * @return string + */ + public function getVersion() + { + return $this->version; + } + + /** + * @return string + * + * @phpstan-return self::STR_OP_* + */ + public function getOperator() + { + return self::$transOpInt[$this->operator]; + } + + /** + * @param ConstraintInterface $provider + * + * @return bool + */ + public function matches(ConstraintInterface $provider) + { + if ($provider instanceof self) { + return $this->matchSpecific($provider); + } + + // turn matching around to find a match + return $provider->matches($this); + } + + /** + * {@inheritDoc} + */ + public function setPrettyString($prettyString) + { + $this->prettyString = $prettyString; + } + + /** + * {@inheritDoc} + */ + public function getPrettyString() + { + if ($this->prettyString) { + return $this->prettyString; + } + + return $this->__toString(); + } + + /** + * Get all supported comparison operators. + * + * @return array + * + * @phpstan-return list + */ + public static function getSupportedOperators() + { + return array_keys(self::$transOpStr); + } + + /** + * @param string $operator + * @return int + * + * @phpstan-param self::STR_OP_* $operator + * @phpstan-return self::OP_* + */ + public static function getOperatorConstant($operator) + { + return self::$transOpStr[$operator]; + } + + /** + * @param string $a + * @param string $b + * @param string $operator + * @param bool $compareBranches + * + * @throws \InvalidArgumentException if invalid operator is given. + * + * @return bool + * + * @phpstan-param self::STR_OP_* $operator + */ + public function versionCompare($a, $b, $operator, $compareBranches = false) + { + if (!isset(self::$transOpStr[$operator])) { + throw new \InvalidArgumentException(sprintf( + 'Invalid operator "%s" given, expected one of: %s', + $operator, + implode(', ', self::getSupportedOperators()) + )); + } + + $aIsBranch = strpos($a, 'dev-') === 0; + $bIsBranch = strpos($b, 'dev-') === 0; + + if ($operator === '!=' && ($aIsBranch || $bIsBranch)) { + return $a !== $b; + } + + if ($aIsBranch && $bIsBranch) { + return $operator === '==' && $a === $b; + } + + // when branches are not comparable, we make sure dev branches never match anything + if (!$compareBranches && ($aIsBranch || $bIsBranch)) { + return false; + } + + return \version_compare($a, $b, $operator); + } + + /** + * {@inheritDoc} + */ + public function compile($otherOperator) + { + if (strpos($this->version, 'dev-') === 0) { + if (self::OP_EQ === $this->operator) { + if (self::OP_EQ === $otherOperator) { + return sprintf('$b && $v === %s', \var_export($this->version, true)); + } + if (self::OP_NE === $otherOperator) { + return sprintf('!$b || $v !== %s', \var_export($this->version, true)); + } + return 'false'; + } + + if (self::OP_NE === $this->operator) { + if (self::OP_EQ === $otherOperator) { + return sprintf('!$b || $v !== %s', \var_export($this->version, true)); + } + if (self::OP_NE === $otherOperator) { + return 'true'; + } + return '!$b'; + } + + return 'false'; + } + + if (self::OP_EQ === $this->operator) { + if (self::OP_EQ === $otherOperator) { + return sprintf('\version_compare($v, %s, \'==\')', \var_export($this->version, true)); + } + if (self::OP_NE === $otherOperator) { + return sprintf('$b || \version_compare($v, %s, \'!=\')', \var_export($this->version, true)); + } + + return sprintf('!$b && \version_compare(%s, $v, \'%s\')', \var_export($this->version, true), self::$transOpInt[$otherOperator]); + } + + if (self::OP_NE === $this->operator) { + if (self::OP_EQ === $otherOperator) { + return sprintf('$b || (!$b && \version_compare($v, %s, \'!=\'))', \var_export($this->version, true)); + } + + if (self::OP_NE === $otherOperator) { + return 'true'; + } + return '!$b'; + } + + if (self::OP_LT === $this->operator || self::OP_LE === $this->operator) { + if (self::OP_LT === $otherOperator || self::OP_LE === $otherOperator) { + return '!$b'; + } + } else { // $this->operator must be self::OP_GT || self::OP_GE here + if (self::OP_GT === $otherOperator || self::OP_GE === $otherOperator) { + return '!$b'; + } + } + + if (self::OP_NE === $otherOperator) { + return 'true'; + } + + $codeComparison = sprintf('\version_compare($v, %s, \'%s\')', \var_export($this->version, true), self::$transOpInt[$this->operator]); + if ($this->operator === self::OP_LE) { + if ($otherOperator === self::OP_GT) { + return sprintf('!$b && \version_compare($v, %s, \'!=\') && ', \var_export($this->version, true)) . $codeComparison; + } + } elseif ($this->operator === self::OP_GE) { + if ($otherOperator === self::OP_LT) { + return sprintf('!$b && \version_compare($v, %s, \'!=\') && ', \var_export($this->version, true)) . $codeComparison; + } + } + + return sprintf('!$b && %s', $codeComparison); + } + + /** + * @param Constraint $provider + * @param bool $compareBranches + * + * @return bool + */ + public function matchSpecific(Constraint $provider, $compareBranches = false) + { + $noEqualOp = str_replace('=', '', self::$transOpInt[$this->operator]); + $providerNoEqualOp = str_replace('=', '', self::$transOpInt[$provider->operator]); + + $isEqualOp = self::OP_EQ === $this->operator; + $isNonEqualOp = self::OP_NE === $this->operator; + $isProviderEqualOp = self::OP_EQ === $provider->operator; + $isProviderNonEqualOp = self::OP_NE === $provider->operator; + + // '!=' operator is match when other operator is not '==' operator or version is not match + // these kinds of comparisons always have a solution + if ($isNonEqualOp || $isProviderNonEqualOp) { + if ($isNonEqualOp && !$isProviderNonEqualOp && !$isProviderEqualOp && strpos($provider->version, 'dev-') === 0) { + return false; + } + + if ($isProviderNonEqualOp && !$isNonEqualOp && !$isEqualOp && strpos($this->version, 'dev-') === 0) { + return false; + } + + if (!$isEqualOp && !$isProviderEqualOp) { + return true; + } + return $this->versionCompare($provider->version, $this->version, '!=', $compareBranches); + } + + // an example for the condition is <= 2.0 & < 1.0 + // these kinds of comparisons always have a solution + if ($this->operator !== self::OP_EQ && $noEqualOp === $providerNoEqualOp) { + return !(strpos($this->version, 'dev-') === 0 || strpos($provider->version, 'dev-') === 0); + } + + $version1 = $isEqualOp ? $this->version : $provider->version; + $version2 = $isEqualOp ? $provider->version : $this->version; + $operator = $isEqualOp ? $provider->operator : $this->operator; + + if ($this->versionCompare($version1, $version2, self::$transOpInt[$operator], $compareBranches)) { + // special case, e.g. require >= 1.0 and provide < 1.0 + // 1.0 >= 1.0 but 1.0 is outside of the provided interval + + return !(self::$transOpInt[$provider->operator] === $providerNoEqualOp + && self::$transOpInt[$this->operator] !== $noEqualOp + && \version_compare($provider->version, $this->version, '==')); + } + + return false; + } + + /** + * @return string + */ + public function __toString() + { + return self::$transOpInt[$this->operator] . ' ' . $this->version; + } + + /** + * {@inheritDoc} + */ + public function getLowerBound() + { + $this->extractBounds(); + + return $this->lowerBound; + } + + /** + * {@inheritDoc} + */ + public function getUpperBound() + { + $this->extractBounds(); + + return $this->upperBound; + } + + /** + * @return void + */ + private function extractBounds() + { + if (null !== $this->lowerBound) { + return; + } + + // Branches + if (strpos($this->version, 'dev-') === 0) { + $this->lowerBound = Bound::zero(); + $this->upperBound = Bound::positiveInfinity(); + + return; + } + + switch ($this->operator) { + case self::OP_EQ: + $this->lowerBound = new Bound($this->version, true); + $this->upperBound = new Bound($this->version, true); + break; + case self::OP_LT: + $this->lowerBound = Bound::zero(); + $this->upperBound = new Bound($this->version, false); + break; + case self::OP_LE: + $this->lowerBound = Bound::zero(); + $this->upperBound = new Bound($this->version, true); + break; + case self::OP_GT: + $this->lowerBound = new Bound($this->version, false); + $this->upperBound = Bound::positiveInfinity(); + break; + case self::OP_GE: + $this->lowerBound = new Bound($this->version, true); + $this->upperBound = Bound::positiveInfinity(); + break; + case self::OP_NE: + $this->lowerBound = Bound::zero(); + $this->upperBound = Bound::positiveInfinity(); + break; + } + } +} diff --git a/public/vendor/composer/semver/src/Constraint/ConstraintInterface.php b/public/vendor/composer/semver/src/Constraint/ConstraintInterface.php new file mode 100644 index 0000000..389b935 --- /dev/null +++ b/public/vendor/composer/semver/src/Constraint/ConstraintInterface.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver\Constraint; + +/** + * DO NOT IMPLEMENT this interface. It is only meant for usage as a type hint + * in libraries relying on composer/semver but creating your own constraint class + * that implements this interface is not a supported use case and will cause the + * composer/semver components to return unexpected results. + */ +interface ConstraintInterface +{ + /** + * Checks whether the given constraint intersects in any way with this constraint + * + * @param ConstraintInterface $provider + * + * @return bool + */ + public function matches(ConstraintInterface $provider); + + /** + * Provides a compiled version of the constraint for the given operator + * The compiled version must be a PHP expression. + * Executor of compile version must provide 2 variables: + * - $v = the string version to compare with + * - $b = whether or not the version is a non-comparable branch (starts with "dev-") + * + * @see Constraint::OP_* for the list of available operators. + * @example return '!$b && version_compare($v, '1.0', '>')'; + * + * @param int $otherOperator one Constraint::OP_* + * + * @return string + * + * @phpstan-param Constraint::OP_* $otherOperator + */ + public function compile($otherOperator); + + /** + * @return Bound + */ + public function getUpperBound(); + + /** + * @return Bound + */ + public function getLowerBound(); + + /** + * @return string + */ + public function getPrettyString(); + + /** + * @param string|null $prettyString + * + * @return void + */ + public function setPrettyString($prettyString); + + /** + * @return string + */ + public function __toString(); +} diff --git a/public/vendor/composer/semver/src/Constraint/MatchAllConstraint.php b/public/vendor/composer/semver/src/Constraint/MatchAllConstraint.php new file mode 100644 index 0000000..5e51af9 --- /dev/null +++ b/public/vendor/composer/semver/src/Constraint/MatchAllConstraint.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver\Constraint; + +/** + * Defines the absence of a constraint. + * + * This constraint matches everything. + */ +class MatchAllConstraint implements ConstraintInterface +{ + /** @var string|null */ + protected $prettyString; + + /** + * @param ConstraintInterface $provider + * + * @return bool + */ + public function matches(ConstraintInterface $provider) + { + return true; + } + + /** + * {@inheritDoc} + */ + public function compile($otherOperator) + { + return 'true'; + } + + /** + * {@inheritDoc} + */ + public function setPrettyString($prettyString) + { + $this->prettyString = $prettyString; + } + + /** + * {@inheritDoc} + */ + public function getPrettyString() + { + if ($this->prettyString) { + return $this->prettyString; + } + + return (string) $this; + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + return '*'; + } + + /** + * {@inheritDoc} + */ + public function getUpperBound() + { + return Bound::positiveInfinity(); + } + + /** + * {@inheritDoc} + */ + public function getLowerBound() + { + return Bound::zero(); + } +} diff --git a/public/vendor/composer/semver/src/Constraint/MatchNoneConstraint.php b/public/vendor/composer/semver/src/Constraint/MatchNoneConstraint.php new file mode 100644 index 0000000..dadcf62 --- /dev/null +++ b/public/vendor/composer/semver/src/Constraint/MatchNoneConstraint.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver\Constraint; + +/** + * Blackhole of constraints, nothing escapes it + */ +class MatchNoneConstraint implements ConstraintInterface +{ + /** @var string|null */ + protected $prettyString; + + /** + * @param ConstraintInterface $provider + * + * @return bool + */ + public function matches(ConstraintInterface $provider) + { + return false; + } + + /** + * {@inheritDoc} + */ + public function compile($otherOperator) + { + return 'false'; + } + + /** + * {@inheritDoc} + */ + public function setPrettyString($prettyString) + { + $this->prettyString = $prettyString; + } + + /** + * {@inheritDoc} + */ + public function getPrettyString() + { + if ($this->prettyString) { + return $this->prettyString; + } + + return (string) $this; + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + return '[]'; + } + + /** + * {@inheritDoc} + */ + public function getUpperBound() + { + return new Bound('0.0.0.0-dev', false); + } + + /** + * {@inheritDoc} + */ + public function getLowerBound() + { + return new Bound('0.0.0.0-dev', false); + } +} diff --git a/public/vendor/composer/semver/src/Constraint/MultiConstraint.php b/public/vendor/composer/semver/src/Constraint/MultiConstraint.php new file mode 100644 index 0000000..1f4c006 --- /dev/null +++ b/public/vendor/composer/semver/src/Constraint/MultiConstraint.php @@ -0,0 +1,325 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver\Constraint; + +/** + * Defines a conjunctive or disjunctive set of constraints. + */ +class MultiConstraint implements ConstraintInterface +{ + /** + * @var ConstraintInterface[] + * @phpstan-var non-empty-array + */ + protected $constraints; + + /** @var string|null */ + protected $prettyString; + + /** @var string|null */ + protected $string; + + /** @var bool */ + protected $conjunctive; + + /** @var Bound|null */ + protected $lowerBound; + + /** @var Bound|null */ + protected $upperBound; + + /** + * @param ConstraintInterface[] $constraints A set of constraints + * @param bool $conjunctive Whether the constraints should be treated as conjunctive or disjunctive + * + * @throws \InvalidArgumentException If less than 2 constraints are passed + */ + public function __construct(array $constraints, $conjunctive = true) + { + if (\count($constraints) < 2) { + throw new \InvalidArgumentException( + 'Must provide at least two constraints for a MultiConstraint. Use '. + 'the regular Constraint class for one constraint only or MatchAllConstraint for none. You may use '. + 'MultiConstraint::create() which optimizes and handles those cases automatically.' + ); + } + + $this->constraints = $constraints; + $this->conjunctive = $conjunctive; + } + + /** + * @return ConstraintInterface[] + */ + public function getConstraints() + { + return $this->constraints; + } + + /** + * @return bool + */ + public function isConjunctive() + { + return $this->conjunctive; + } + + /** + * @return bool + */ + public function isDisjunctive() + { + return !$this->conjunctive; + } + + /** + * {@inheritDoc} + */ + public function compile($otherOperator) + { + $parts = array(); + foreach ($this->constraints as $constraint) { + $code = $constraint->compile($otherOperator); + if ($code === 'true') { + if (!$this->conjunctive) { + return 'true'; + } + } elseif ($code === 'false') { + if ($this->conjunctive) { + return 'false'; + } + } else { + $parts[] = '('.$code.')'; + } + } + + if (!$parts) { + return $this->conjunctive ? 'true' : 'false'; + } + + return $this->conjunctive ? implode('&&', $parts) : implode('||', $parts); + } + + /** + * @param ConstraintInterface $provider + * + * @return bool + */ + public function matches(ConstraintInterface $provider) + { + if (false === $this->conjunctive) { + foreach ($this->constraints as $constraint) { + if ($provider->matches($constraint)) { + return true; + } + } + + return false; + } + + // when matching a conjunctive and a disjunctive multi constraint we have to iterate over the disjunctive one + // otherwise we'd return true if different parts of the disjunctive constraint match the conjunctive one + // which would lead to incorrect results, e.g. [>1 and <2] would match [<1 or >2] although they do not intersect + if ($provider instanceof MultiConstraint && $provider->isDisjunctive()) { + return $provider->matches($this); + } + + foreach ($this->constraints as $constraint) { + if (!$provider->matches($constraint)) { + return false; + } + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function setPrettyString($prettyString) + { + $this->prettyString = $prettyString; + } + + /** + * {@inheritDoc} + */ + public function getPrettyString() + { + if ($this->prettyString) { + return $this->prettyString; + } + + return (string) $this; + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + if ($this->string !== null) { + return $this->string; + } + + $constraints = array(); + foreach ($this->constraints as $constraint) { + $constraints[] = (string) $constraint; + } + + return $this->string = '[' . implode($this->conjunctive ? ' ' : ' || ', $constraints) . ']'; + } + + /** + * {@inheritDoc} + */ + public function getLowerBound() + { + $this->extractBounds(); + + if (null === $this->lowerBound) { + throw new \LogicException('extractBounds should have populated the lowerBound property'); + } + + return $this->lowerBound; + } + + /** + * {@inheritDoc} + */ + public function getUpperBound() + { + $this->extractBounds(); + + if (null === $this->upperBound) { + throw new \LogicException('extractBounds should have populated the upperBound property'); + } + + return $this->upperBound; + } + + /** + * Tries to optimize the constraints as much as possible, meaning + * reducing/collapsing congruent constraints etc. + * Does not necessarily return a MultiConstraint instance if + * things can be reduced to a simple constraint + * + * @param ConstraintInterface[] $constraints A set of constraints + * @param bool $conjunctive Whether the constraints should be treated as conjunctive or disjunctive + * + * @return ConstraintInterface + */ + public static function create(array $constraints, $conjunctive = true) + { + if (0 === \count($constraints)) { + return new MatchAllConstraint(); + } + + if (1 === \count($constraints)) { + return $constraints[0]; + } + + $optimized = self::optimizeConstraints($constraints, $conjunctive); + if ($optimized !== null) { + list($constraints, $conjunctive) = $optimized; + if (\count($constraints) === 1) { + return $constraints[0]; + } + } + + return new self($constraints, $conjunctive); + } + + /** + * @param ConstraintInterface[] $constraints + * @param bool $conjunctive + * @return ?array + * + * @phpstan-return array{0: list, 1: bool}|null + */ + private static function optimizeConstraints(array $constraints, $conjunctive) + { + // parse the two OR groups and if they are contiguous we collapse + // them into one constraint + // [>= 1 < 2] || [>= 2 < 3] || [>= 3 < 4] => [>= 1 < 4] + if (!$conjunctive) { + $left = $constraints[0]; + $mergedConstraints = array(); + $optimized = false; + for ($i = 1, $l = \count($constraints); $i < $l; $i++) { + $right = $constraints[$i]; + if ( + $left instanceof self + && $left->conjunctive + && $right instanceof self + && $right->conjunctive + && \count($left->constraints) === 2 + && \count($right->constraints) === 2 + && ($left0 = (string) $left->constraints[0]) + && $left0[0] === '>' && $left0[1] === '=' + && ($left1 = (string) $left->constraints[1]) + && $left1[0] === '<' + && ($right0 = (string) $right->constraints[0]) + && $right0[0] === '>' && $right0[1] === '=' + && ($right1 = (string) $right->constraints[1]) + && $right1[0] === '<' + && substr($left1, 2) === substr($right0, 3) + ) { + $optimized = true; + $left = new MultiConstraint( + array( + $left->constraints[0], + $right->constraints[1], + ), + true); + } else { + $mergedConstraints[] = $left; + $left = $right; + } + } + if ($optimized) { + $mergedConstraints[] = $left; + return array($mergedConstraints, false); + } + } + + // TODO: Here's the place to put more optimizations + + return null; + } + + /** + * @return void + */ + private function extractBounds() + { + if (null !== $this->lowerBound) { + return; + } + + foreach ($this->constraints as $constraint) { + if (null === $this->lowerBound || null === $this->upperBound) { + $this->lowerBound = $constraint->getLowerBound(); + $this->upperBound = $constraint->getUpperBound(); + continue; + } + + if ($constraint->getLowerBound()->compareTo($this->lowerBound, $this->isConjunctive() ? '>' : '<')) { + $this->lowerBound = $constraint->getLowerBound(); + } + + if ($constraint->getUpperBound()->compareTo($this->upperBound, $this->isConjunctive() ? '<' : '>')) { + $this->upperBound = $constraint->getUpperBound(); + } + } + } +} diff --git a/public/vendor/composer/semver/src/Interval.php b/public/vendor/composer/semver/src/Interval.php new file mode 100644 index 0000000..43d5a4f --- /dev/null +++ b/public/vendor/composer/semver/src/Interval.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver; + +use Composer\Semver\Constraint\Constraint; + +class Interval +{ + /** @var Constraint */ + private $start; + /** @var Constraint */ + private $end; + + public function __construct(Constraint $start, Constraint $end) + { + $this->start = $start; + $this->end = $end; + } + + /** + * @return Constraint + */ + public function getStart() + { + return $this->start; + } + + /** + * @return Constraint + */ + public function getEnd() + { + return $this->end; + } + + /** + * @return Constraint + */ + public static function fromZero() + { + static $zero; + + if (null === $zero) { + $zero = new Constraint('>=', '0.0.0.0-dev'); + } + + return $zero; + } + + /** + * @return Constraint + */ + public static function untilPositiveInfinity() + { + static $positiveInfinity; + + if (null === $positiveInfinity) { + $positiveInfinity = new Constraint('<', PHP_INT_MAX.'.0.0.0'); + } + + return $positiveInfinity; + } + + /** + * @return self + */ + public static function any() + { + return new self(self::fromZero(), self::untilPositiveInfinity()); + } + + /** + * @return array{'names': string[], 'exclude': bool} + */ + public static function anyDev() + { + // any == exclude nothing + return array('names' => array(), 'exclude' => true); + } + + /** + * @return array{'names': string[], 'exclude': bool} + */ + public static function noDev() + { + // nothing == no names included + return array('names' => array(), 'exclude' => false); + } +} diff --git a/public/vendor/composer/semver/src/Intervals.php b/public/vendor/composer/semver/src/Intervals.php new file mode 100644 index 0000000..d889d0a --- /dev/null +++ b/public/vendor/composer/semver/src/Intervals.php @@ -0,0 +1,478 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver; + +use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Semver\Constraint\MatchAllConstraint; +use Composer\Semver\Constraint\MatchNoneConstraint; +use Composer\Semver\Constraint\MultiConstraint; + +/** + * Helper class generating intervals from constraints + * + * This contains utilities for: + * + * - compacting an existing constraint which can be used to combine several into one + * by creating a MultiConstraint out of the many constraints you have. + * + * - checking whether one subset is a subset of another. + * + * Note: You should call clear to free memoization memory usage when you are done using this class + */ +class Intervals +{ + /** + * @phpstan-var array + */ + private static $intervalsCache = array(); + + /** + * @phpstan-var array + */ + private static $opSortOrder = array( + '>=' => -3, + '<' => -2, + '>' => 2, + '<=' => 3, + ); + + /** + * Clears the memoization cache once you are done + * + * @return void + */ + public static function clear() + { + self::$intervalsCache = array(); + } + + /** + * Checks whether $candidate is a subset of $constraint + * + * @return bool + */ + public static function isSubsetOf(ConstraintInterface $candidate, ConstraintInterface $constraint) + { + if ($constraint instanceof MatchAllConstraint) { + return true; + } + + if ($candidate instanceof MatchNoneConstraint || $constraint instanceof MatchNoneConstraint) { + return false; + } + + $intersectionIntervals = self::get(new MultiConstraint(array($candidate, $constraint), true)); + $candidateIntervals = self::get($candidate); + if (\count($intersectionIntervals['numeric']) !== \count($candidateIntervals['numeric'])) { + return false; + } + + foreach ($intersectionIntervals['numeric'] as $index => $interval) { + if (!isset($candidateIntervals['numeric'][$index])) { + return false; + } + + if ((string) $candidateIntervals['numeric'][$index]->getStart() !== (string) $interval->getStart()) { + return false; + } + + if ((string) $candidateIntervals['numeric'][$index]->getEnd() !== (string) $interval->getEnd()) { + return false; + } + } + + if ($intersectionIntervals['branches']['exclude'] !== $candidateIntervals['branches']['exclude']) { + return false; + } + if (\count($intersectionIntervals['branches']['names']) !== \count($candidateIntervals['branches']['names'])) { + return false; + } + foreach ($intersectionIntervals['branches']['names'] as $index => $name) { + if ($name !== $candidateIntervals['branches']['names'][$index]) { + return false; + } + } + + return true; + } + + /** + * Checks whether $a and $b have any intersection, equivalent to $a->matches($b) + * + * @return bool + */ + public static function haveIntersections(ConstraintInterface $a, ConstraintInterface $b) + { + if ($a instanceof MatchAllConstraint || $b instanceof MatchAllConstraint) { + return true; + } + + if ($a instanceof MatchNoneConstraint || $b instanceof MatchNoneConstraint) { + return false; + } + + $intersectionIntervals = self::generateIntervals(new MultiConstraint(array($a, $b), true), true); + + return \count($intersectionIntervals['numeric']) > 0 || $intersectionIntervals['branches']['exclude'] || \count($intersectionIntervals['branches']['names']) > 0; + } + + /** + * Attempts to optimize a MultiConstraint + * + * When merging MultiConstraints together they can get very large, this will + * compact it by looking at the real intervals covered by all the constraints + * and then creates a new constraint containing only the smallest amount of rules + * to match the same intervals. + * + * @return ConstraintInterface + */ + public static function compactConstraint(ConstraintInterface $constraint) + { + if (!$constraint instanceof MultiConstraint) { + return $constraint; + } + + $intervals = self::generateIntervals($constraint); + $constraints = array(); + $hasNumericMatchAll = false; + + if (\count($intervals['numeric']) === 1 && (string) $intervals['numeric'][0]->getStart() === (string) Interval::fromZero() && (string) $intervals['numeric'][0]->getEnd() === (string) Interval::untilPositiveInfinity()) { + $constraints[] = $intervals['numeric'][0]->getStart(); + $hasNumericMatchAll = true; + } else { + $unEqualConstraints = array(); + for ($i = 0, $count = \count($intervals['numeric']); $i < $count; $i++) { + $interval = $intervals['numeric'][$i]; + + // if current interval ends with < N and next interval begins with > N we can swap this out for != N + // but this needs to happen as a conjunctive expression together with the start of the current interval + // and end of next interval, so [>=M, N, [>=M, !=N, getEnd()->getOperator() === '<' && $i+1 < $count) { + $nextInterval = $intervals['numeric'][$i+1]; + if ($interval->getEnd()->getVersion() === $nextInterval->getStart()->getVersion() && $nextInterval->getStart()->getOperator() === '>') { + // only add a start if we didn't already do so, can be skipped if we're looking at second + // interval in [>=M, N, P, =M, !=N] already and we only want to add !=P right now + if (\count($unEqualConstraints) === 0 && (string) $interval->getStart() !== (string) Interval::fromZero()) { + $unEqualConstraints[] = $interval->getStart(); + } + $unEqualConstraints[] = new Constraint('!=', $interval->getEnd()->getVersion()); + continue; + } + } + + if (\count($unEqualConstraints) > 0) { + // this is where the end of the following interval of a != constraint is added as explained above + if ((string) $interval->getEnd() !== (string) Interval::untilPositiveInfinity()) { + $unEqualConstraints[] = $interval->getEnd(); + } + + // count is 1 if entire constraint is just one != expression + if (\count($unEqualConstraints) > 1) { + $constraints[] = new MultiConstraint($unEqualConstraints, true); + } else { + $constraints[] = $unEqualConstraints[0]; + } + + $unEqualConstraints = array(); + continue; + } + + // convert back >= x - <= x intervals to == x + if ($interval->getStart()->getVersion() === $interval->getEnd()->getVersion() && $interval->getStart()->getOperator() === '>=' && $interval->getEnd()->getOperator() === '<=') { + $constraints[] = new Constraint('==', $interval->getStart()->getVersion()); + continue; + } + + if ((string) $interval->getStart() === (string) Interval::fromZero()) { + $constraints[] = $interval->getEnd(); + } elseif ((string) $interval->getEnd() === (string) Interval::untilPositiveInfinity()) { + $constraints[] = $interval->getStart(); + } else { + $constraints[] = new MultiConstraint(array($interval->getStart(), $interval->getEnd()), true); + } + } + } + + $devConstraints = array(); + + if (0 === \count($intervals['branches']['names'])) { + if ($intervals['branches']['exclude']) { + if ($hasNumericMatchAll) { + return new MatchAllConstraint; + } + // otherwise constraint should contain a != operator and already cover this + } + } else { + foreach ($intervals['branches']['names'] as $branchName) { + if ($intervals['branches']['exclude']) { + $devConstraints[] = new Constraint('!=', $branchName); + } else { + $devConstraints[] = new Constraint('==', $branchName); + } + } + + // excluded branches, e.g. != dev-foo are conjunctive with the interval, so + // > 2.0 != dev-foo must return a conjunctive constraint + if ($intervals['branches']['exclude']) { + if (\count($constraints) > 1) { + return new MultiConstraint(array_merge( + array(new MultiConstraint($constraints, false)), + $devConstraints + ), true); + } + + if (\count($constraints) === 1 && (string)$constraints[0] === (string)Interval::fromZero()) { + if (\count($devConstraints) > 1) { + return new MultiConstraint($devConstraints, true); + } + return $devConstraints[0]; + } + + return new MultiConstraint(array_merge($constraints, $devConstraints), true); + } + + // otherwise devConstraints contains a list of == operators for branches which are disjunctive with the + // rest of the constraint + $constraints = array_merge($constraints, $devConstraints); + } + + if (\count($constraints) > 1) { + return new MultiConstraint($constraints, false); + } + + if (\count($constraints) === 1) { + return $constraints[0]; + } + + return new MatchNoneConstraint; + } + + /** + * Creates an array of numeric intervals and branch constraints representing a given constraint + * + * if the returned numeric array is empty it means the constraint matches nothing in the numeric range (0 - +inf) + * if the returned branches array is empty it means no dev-* versions are matched + * if a constraint matches all possible dev-* versions, branches will contain Interval::anyDev() + * + * @return array + * @phpstan-return array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}} + */ + public static function get(ConstraintInterface $constraint) + { + $key = (string) $constraint; + + if (!isset(self::$intervalsCache[$key])) { + self::$intervalsCache[$key] = self::generateIntervals($constraint); + } + + return self::$intervalsCache[$key]; + } + + /** + * @param bool $stopOnFirstValidInterval + * + * @phpstan-return array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}} + */ + private static function generateIntervals(ConstraintInterface $constraint, $stopOnFirstValidInterval = false) + { + if ($constraint instanceof MatchAllConstraint) { + return array('numeric' => array(new Interval(Interval::fromZero(), Interval::untilPositiveInfinity())), 'branches' => Interval::anyDev()); + } + + if ($constraint instanceof MatchNoneConstraint) { + return array('numeric' => array(), 'branches' => array('names' => array(), 'exclude' => false)); + } + + if ($constraint instanceof Constraint) { + return self::generateSingleConstraintIntervals($constraint); + } + + if (!$constraint instanceof MultiConstraint) { + throw new \UnexpectedValueException('The constraint passed in should be an MatchAllConstraint, Constraint or MultiConstraint instance, got '.\get_class($constraint).'.'); + } + + $constraints = $constraint->getConstraints(); + + $numericGroups = array(); + $constraintBranches = array(); + foreach ($constraints as $c) { + $res = self::get($c); + $numericGroups[] = $res['numeric']; + $constraintBranches[] = $res['branches']; + } + + if ($constraint->isDisjunctive()) { + $branches = Interval::noDev(); + foreach ($constraintBranches as $b) { + if ($b['exclude']) { + if ($branches['exclude']) { + // disjunctive constraint, so only exclude what's excluded in all constraints + // !=a,!=b || !=b,!=c => !=b + $branches['names'] = array_intersect($branches['names'], $b['names']); + } else { + // disjunctive constraint so exclude all names which are not explicitly included in the alternative + // (==b || ==c) || !=a,!=b => !=a + $branches['exclude'] = true; + $branches['names'] = array_diff($b['names'], $branches['names']); + } + } else { + if ($branches['exclude']) { + // disjunctive constraint so exclude all names which are not explicitly included in the alternative + // !=a,!=b || (==b || ==c) => !=a + $branches['names'] = array_diff($branches['names'], $b['names']); + } else { + // disjunctive constraint, so just add all the other branches + // (==a || ==b) || ==c => ==a || ==b || ==c + $branches['names'] = array_merge($branches['names'], $b['names']); + } + } + } + } else { + $branches = Interval::anyDev(); + foreach ($constraintBranches as $b) { + if ($b['exclude']) { + if ($branches['exclude']) { + // conjunctive, so just add all branch names to be excluded + // !=a && !=b => !=a,!=b + $branches['names'] = array_merge($branches['names'], $b['names']); + } else { + // conjunctive, so only keep included names which are not excluded + // (==a||==c) && !=a,!=b => ==c + $branches['names'] = array_diff($branches['names'], $b['names']); + } + } else { + if ($branches['exclude']) { + // conjunctive, so only keep included names which are not excluded + // !=a,!=b && (==a||==c) => ==c + $branches['names'] = array_diff($b['names'], $branches['names']); + $branches['exclude'] = false; + } else { + // conjunctive, so only keep names that are included in both + // (==a||==b) && (==a||==c) => ==a + $branches['names'] = array_intersect($branches['names'], $b['names']); + } + } + } + } + + $branches['names'] = array_unique($branches['names']); + + if (\count($numericGroups) === 1) { + return array('numeric' => $numericGroups[0], 'branches' => $branches); + } + + $borders = array(); + foreach ($numericGroups as $group) { + foreach ($group as $interval) { + $borders[] = array('version' => $interval->getStart()->getVersion(), 'operator' => $interval->getStart()->getOperator(), 'side' => 'start'); + $borders[] = array('version' => $interval->getEnd()->getVersion(), 'operator' => $interval->getEnd()->getOperator(), 'side' => 'end'); + } + } + + $opSortOrder = self::$opSortOrder; + usort($borders, function ($a, $b) use ($opSortOrder) { + $order = version_compare($a['version'], $b['version']); + if ($order === 0) { + return $opSortOrder[$a['operator']] - $opSortOrder[$b['operator']]; + } + + return $order; + }); + + $activeIntervals = 0; + $intervals = array(); + $index = 0; + $activationThreshold = $constraint->isConjunctive() ? \count($numericGroups) : 1; + $start = null; + foreach ($borders as $border) { + if ($border['side'] === 'start') { + $activeIntervals++; + } else { + $activeIntervals--; + } + if (!$start && $activeIntervals >= $activationThreshold) { + $start = new Constraint($border['operator'], $border['version']); + } elseif ($start && $activeIntervals < $activationThreshold) { + // filter out invalid intervals like > x - <= x, or >= x - < x + if ( + version_compare($start->getVersion(), $border['version'], '=') + && ( + ($start->getOperator() === '>' && $border['operator'] === '<=') + || ($start->getOperator() === '>=' && $border['operator'] === '<') + ) + ) { + unset($intervals[$index]); + } else { + $intervals[$index] = new Interval($start, new Constraint($border['operator'], $border['version'])); + $index++; + + if ($stopOnFirstValidInterval) { + break; + } + } + + $start = null; + } + } + + return array('numeric' => $intervals, 'branches' => $branches); + } + + /** + * @phpstan-return array{'numeric': Interval[], 'branches': array{'names': string[], 'exclude': bool}} + */ + private static function generateSingleConstraintIntervals(Constraint $constraint) + { + $op = $constraint->getOperator(); + + // handle branch constraints first + if (strpos($constraint->getVersion(), 'dev-') === 0) { + $intervals = array(); + $branches = array('names' => array(), 'exclude' => false); + + // != dev-foo means any numeric version may match, we treat >/< like != they are not really defined for branches + if ($op === '!=') { + $intervals[] = new Interval(Interval::fromZero(), Interval::untilPositiveInfinity()); + $branches = array('names' => array($constraint->getVersion()), 'exclude' => true); + } elseif ($op === '==') { + $branches['names'][] = $constraint->getVersion(); + } + + return array( + 'numeric' => $intervals, + 'branches' => $branches, + ); + } + + if ($op[0] === '>') { // > & >= + return array('numeric' => array(new Interval($constraint, Interval::untilPositiveInfinity())), 'branches' => Interval::noDev()); + } + if ($op[0] === '<') { // < & <= + return array('numeric' => array(new Interval(Interval::fromZero(), $constraint)), 'branches' => Interval::noDev()); + } + if ($op === '!=') { + // convert !=x to intervals of 0 - x - +inf + dev* + return array('numeric' => array( + new Interval(Interval::fromZero(), new Constraint('<', $constraint->getVersion())), + new Interval(new Constraint('>', $constraint->getVersion()), Interval::untilPositiveInfinity()), + ), 'branches' => Interval::anyDev()); + } + + // convert ==x to an interval of >=x - <=x + return array('numeric' => array( + new Interval(new Constraint('>=', $constraint->getVersion()), new Constraint('<=', $constraint->getVersion())), + ), 'branches' => Interval::noDev()); + } +} diff --git a/public/vendor/composer/semver/src/Semver.php b/public/vendor/composer/semver/src/Semver.php new file mode 100644 index 0000000..4fe9075 --- /dev/null +++ b/public/vendor/composer/semver/src/Semver.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver; + +use Composer\Semver\Constraint\Constraint; + +class Semver +{ + const SORT_ASC = 1; + const SORT_DESC = -1; + + /** @var VersionParser */ + private static $versionParser; + + /** + * Determine if given version satisfies given constraints. + * + * @param string $version + * @param string $constraints + * + * @return bool + */ + public static function satisfies($version, $constraints) + { + if (null === self::$versionParser) { + self::$versionParser = new VersionParser(); + } + + $versionParser = self::$versionParser; + $provider = new Constraint('==', $versionParser->normalize($version)); + $parsedConstraints = $versionParser->parseConstraints($constraints); + + return $parsedConstraints->matches($provider); + } + + /** + * Return all versions that satisfy given constraints. + * + * @param string[] $versions + * @param string $constraints + * + * @return list + */ + public static function satisfiedBy(array $versions, $constraints) + { + $versions = array_filter($versions, function ($version) use ($constraints) { + return Semver::satisfies($version, $constraints); + }); + + return array_values($versions); + } + + /** + * Sort given array of versions. + * + * @param string[] $versions + * + * @return list + */ + public static function sort(array $versions) + { + return self::usort($versions, self::SORT_ASC); + } + + /** + * Sort given array of versions in reverse. + * + * @param string[] $versions + * + * @return list + */ + public static function rsort(array $versions) + { + return self::usort($versions, self::SORT_DESC); + } + + /** + * @param string[] $versions + * @param int $direction + * + * @return list + */ + private static function usort(array $versions, $direction) + { + if (null === self::$versionParser) { + self::$versionParser = new VersionParser(); + } + + $versionParser = self::$versionParser; + $normalized = array(); + + // Normalize outside of usort() scope for minor performance increase. + // Creates an array of arrays: [[normalized, key], ...] + foreach ($versions as $key => $version) { + $normalizedVersion = $versionParser->normalize($version); + $normalizedVersion = $versionParser->normalizeDefaultBranch($normalizedVersion); + $normalized[] = array($normalizedVersion, $key); + } + + usort($normalized, function (array $left, array $right) use ($direction) { + if ($left[0] === $right[0]) { + return 0; + } + + if (Comparator::lessThan($left[0], $right[0])) { + return -$direction; + } + + return $direction; + }); + + // Recreate input array, using the original indexes which are now in sorted order. + $sorted = array(); + foreach ($normalized as $item) { + $sorted[] = $versions[$item[1]]; + } + + return $sorted; + } +} diff --git a/public/vendor/composer/semver/src/VersionParser.php b/public/vendor/composer/semver/src/VersionParser.php new file mode 100644 index 0000000..305a0fa --- /dev/null +++ b/public/vendor/composer/semver/src/VersionParser.php @@ -0,0 +1,591 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Composer\Semver; + +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Semver\Constraint\MatchAllConstraint; +use Composer\Semver\Constraint\MultiConstraint; +use Composer\Semver\Constraint\Constraint; + +/** + * Version parser. + * + * @author Jordi Boggiano + */ +class VersionParser +{ + /** + * Regex to match pre-release data (sort of). + * + * Due to backwards compatibility: + * - Instead of enforcing hyphen, an underscore, dot or nothing at all are also accepted. + * - Only stabilities as recognized by Composer are allowed to precede a numerical identifier. + * - Numerical-only pre-release identifiers are not supported, see tests. + * + * |--------------| + * [major].[minor].[patch] -[pre-release] +[build-metadata] + * + * @var string + */ + private static $modifierRegex = '[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*+)?)?([.-]?dev)?'; + + /** @var string */ + private static $stabilitiesRegex = 'stable|RC|beta|alpha|dev'; + + /** + * Returns the stability of a version. + * + * @param string $version + * + * @return string + * @phpstan-return 'stable'|'RC'|'beta'|'alpha'|'dev' + */ + public static function parseStability($version) + { + $version = (string) preg_replace('{#.+$}', '', (string) $version); + + if (strpos($version, 'dev-') === 0 || '-dev' === substr($version, -4)) { + return 'dev'; + } + + preg_match('{' . self::$modifierRegex . '(?:\+.*)?$}i', strtolower($version), $match); + + if (!empty($match[3])) { + return 'dev'; + } + + if (!empty($match[1])) { + if ('beta' === $match[1] || 'b' === $match[1]) { + return 'beta'; + } + if ('alpha' === $match[1] || 'a' === $match[1]) { + return 'alpha'; + } + if ('rc' === $match[1]) { + return 'RC'; + } + } + + return 'stable'; + } + + /** + * @param string $stability + * + * @return string + * @phpstan-return 'stable'|'RC'|'beta'|'alpha'|'dev' + */ + public static function normalizeStability($stability) + { + $stability = strtolower((string) $stability); + + if (!in_array($stability, array('stable', 'rc', 'beta', 'alpha', 'dev'), true)) { + throw new \InvalidArgumentException('Invalid stability string "'.$stability.'", expected one of stable, RC, beta, alpha or dev'); + } + + return $stability === 'rc' ? 'RC' : $stability; + } + + /** + * Normalizes a version string to be able to perform comparisons on it. + * + * @param string $version + * @param ?string $fullVersion optional complete version string to give more context + * + * @throws \UnexpectedValueException + * + * @return string + */ + public function normalize($version, $fullVersion = null) + { + $version = trim((string) $version); + $origVersion = $version; + if (null === $fullVersion) { + $fullVersion = $version; + } + + // strip off aliasing + if (preg_match('{^([^,\s]++) ++as ++([^,\s]++)$}', $version, $match)) { + $version = $match[1]; + } + + // strip off stability flag + if (preg_match('{@(?:' . self::$stabilitiesRegex . ')$}i', $version, $match)) { + $version = substr($version, 0, strlen($version) - strlen($match[0])); + } + + // normalize master/trunk/default branches to dev-name for BC with 1.x as these used to be valid constraints + if (\in_array($version, array('master', 'trunk', 'default'), true)) { + $version = 'dev-' . $version; + } + + // if requirement is branch-like, use full name + if (stripos($version, 'dev-') === 0) { + return 'dev-' . substr($version, 4); + } + + // strip off build metadata + if (preg_match('{^([^,\s+]++)\+[^\s]++$}', $version, $match)) { + $version = $match[1]; + } + + // match classical versioning + if (preg_match('{^v?(\d{1,5}+)(\.\d++)?(\.\d++)?(\.\d++)?' . self::$modifierRegex . '$}i', $version, $matches)) { + $version = $matches[1] + . (!empty($matches[2]) ? $matches[2] : '.0') + . (!empty($matches[3]) ? $matches[3] : '.0') + . (!empty($matches[4]) ? $matches[4] : '.0'); + $index = 5; + // match date(time) based versioning + } elseif (preg_match('{^v?(\d{4}(?:[.:-]?\d{2}){1,6}(?:[.:-]?\d{1,3}){0,2})' . self::$modifierRegex . '$}i', $version, $matches)) { + $version = (string) preg_replace('{\D}', '.', $matches[1]); + $index = 2; + } + + // add version modifiers if a version was matched + if (isset($index)) { + if (!empty($matches[$index])) { + if ('stable' === $matches[$index]) { + return $version; + } + $version .= '-' . $this->expandStability($matches[$index]) . (isset($matches[$index + 1]) && '' !== $matches[$index + 1] ? ltrim($matches[$index + 1], '.-') : ''); + } + + if (!empty($matches[$index + 2])) { + $version .= '-dev'; + } + + return $version; + } + + // match dev branches + if (preg_match('{(.*?)[.-]?dev$}i', $version, $match)) { + try { + $normalized = $this->normalizeBranch($match[1]); + // a branch ending with -dev is only valid if it is numeric + // if it gets prefixed with dev- it means the branch name should + // have had a dev- prefix already when passed to normalize + if (strpos($normalized, 'dev-') === false) { + return $normalized; + } + } catch (\Exception $e) { + } + } + + $extraMessage = ''; + if (preg_match('{ +as +' . preg_quote($version) . '(?:@(?:'.self::$stabilitiesRegex.'))?$}', $fullVersion)) { + $extraMessage = ' in "' . $fullVersion . '", the alias must be an exact version'; + } elseif (preg_match('{^' . preg_quote($version) . '(?:@(?:'.self::$stabilitiesRegex.'))? +as +}', $fullVersion)) { + $extraMessage = ' in "' . $fullVersion . '", the alias source must be an exact version, if it is a branch name you should prefix it with dev-'; + } + + throw new \UnexpectedValueException('Invalid version string "' . $origVersion . '"' . $extraMessage); + } + + /** + * Extract numeric prefix from alias, if it is in numeric format, suitable for version comparison. + * + * @param string $branch Branch name (e.g. 2.1.x-dev) + * + * @return string|false Numeric prefix if present (e.g. 2.1.) or false + */ + public function parseNumericAliasPrefix($branch) + { + if (preg_match('{^(?P(\d++\\.)*\d++)(?:\.x)?-dev$}i', (string) $branch, $matches)) { + return $matches['version'] . '.'; + } + + return false; + } + + /** + * Normalizes a branch name to be able to perform comparisons on it. + * + * @param string $name + * + * @return string + */ + public function normalizeBranch($name) + { + $name = trim((string) $name); + + if (preg_match('{^v?(\d++)(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?$}i', $name, $matches)) { + $version = ''; + for ($i = 1; $i < 5; ++$i) { + $version .= isset($matches[$i]) ? str_replace(array('*', 'X'), 'x', $matches[$i]) : '.x'; + } + + return str_replace('x', '9999999', $version) . '-dev'; + } + + return 'dev-' . $name; + } + + /** + * Normalizes a default branch name (i.e. master on git) to 9999999-dev. + * + * @param string $name + * + * @return string + * + * @deprecated No need to use this anymore in theory, Composer 2 does not normalize any branch names to 9999999-dev anymore + */ + public function normalizeDefaultBranch($name) + { + if ($name === 'dev-master' || $name === 'dev-default' || $name === 'dev-trunk') { + return '9999999-dev'; + } + + return (string) $name; + } + + /** + * Parses a constraint string into MultiConstraint and/or Constraint objects. + * + * @param string $constraints + * + * @return ConstraintInterface + */ + public function parseConstraints($constraints) + { + $prettyConstraint = (string) $constraints; + + $orConstraints = preg_split('{\s*\|\|?\s*}', trim((string) $constraints)); + if (false === $orConstraints) { + throw new \RuntimeException('Failed to preg_split string: '.$constraints); + } + $orGroups = array(); + + foreach ($orConstraints as $orConstraint) { + $andConstraints = preg_split('{(?< ,]) *(? 1) { + $constraintObjects = array(); + foreach ($andConstraints as $andConstraint) { + foreach ($this->parseConstraint($andConstraint) as $parsedAndConstraint) { + $constraintObjects[] = $parsedAndConstraint; + } + } + } else { + $constraintObjects = $this->parseConstraint($andConstraints[0]); + } + + if (1 === \count($constraintObjects)) { + $constraint = $constraintObjects[0]; + } else { + $constraint = new MultiConstraint($constraintObjects); + } + + $orGroups[] = $constraint; + } + + $parsedConstraint = MultiConstraint::create($orGroups, false); + + $parsedConstraint->setPrettyString($prettyConstraint); + + return $parsedConstraint; + } + + /** + * @param string $constraint + * + * @throws \UnexpectedValueException + * + * @return array + * + * @phpstan-return non-empty-array + */ + private function parseConstraint($constraint) + { + // strip off aliasing + if (preg_match('{^([^,\s]++) ++as ++([^,\s]++)$}', $constraint, $match)) { + $constraint = $match[1]; + } + + // strip @stability flags, and keep it for later use + if (preg_match('{^([^,\s]*?)@(' . self::$stabilitiesRegex . ')$}i', $constraint, $match)) { + $constraint = '' !== $match[1] ? $match[1] : '*'; + if ($match[2] !== 'stable') { + $stabilityModifier = $match[2]; + } + } + + // get rid of #refs as those are used by composer only + if (preg_match('{^(dev-[^,\s@]+?|[^,\s@]+?\.x-dev)#.+$}i', $constraint, $match)) { + $constraint = $match[1]; + } + + if (preg_match('{^(v)?[xX*](\.[xX*])*$}i', $constraint, $match)) { + if (!empty($match[1]) || !empty($match[2])) { + return array(new Constraint('>=', '0.0.0.0-dev')); + } + + return array(new MatchAllConstraint()); + } + + $versionRegex = 'v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.(\d++))?(?:' . self::$modifierRegex . '|\.([xX*][.-]?dev))(?:\+[^\s]+)?'; + + // Tilde Range + // + // Like wildcard constraints, unsuffixed tilde constraints say that they must be greater than the previous + // version, to ensure that unstable instances of the current version are allowed. However, if a stability + // suffix is added to the constraint, then a >= match on the current version is used instead. + if (preg_match('{^~>?' . $versionRegex . '$}i', $constraint, $matches)) { + if (strpos($constraint, '~>') === 0) { + throw new \UnexpectedValueException( + 'Could not parse version constraint ' . $constraint . ': ' . + 'Invalid operator "~>", you probably meant to use the "~" operator' + ); + } + + // Work out which position in the version we are operating at + if (isset($matches[4]) && '' !== $matches[4] && null !== $matches[4]) { + $position = 4; + } elseif (isset($matches[3]) && '' !== $matches[3] && null !== $matches[3]) { + $position = 3; + } elseif (isset($matches[2]) && '' !== $matches[2] && null !== $matches[2]) { + $position = 2; + } else { + $position = 1; + } + + // when matching 2.x-dev or 3.0.x-dev we have to shift the second or third number, despite no second/third number matching above + if (!empty($matches[8])) { + $position++; + } + + // Calculate the stability suffix + $stabilitySuffix = ''; + if (empty($matches[5]) && empty($matches[7]) && empty($matches[8])) { + $stabilitySuffix .= '-dev'; + } + + $lowVersion = $this->normalize(substr($constraint . $stabilitySuffix, 1)); + $lowerBound = new Constraint('>=', $lowVersion); + + // For upper bound, we increment the position of one more significance, + // but highPosition = 0 would be illegal + $highPosition = max(1, $position - 1); + $highVersion = $this->manipulateVersionString($matches, $highPosition, 1) . '-dev'; + $upperBound = new Constraint('<', $highVersion); + + return array( + $lowerBound, + $upperBound, + ); + } + + // Caret Range + // + // Allows changes that do not modify the left-most non-zero digit in the [major, minor, patch] tuple. + // In other words, this allows patch and minor updates for versions 1.0.0 and above, patch updates for + // versions 0.X >=0.1.0, and no updates for versions 0.0.X + if (preg_match('{^\^' . $versionRegex . '($)}i', $constraint, $matches)) { + // Work out which position in the version we are operating at + if ('0' !== $matches[1] || '' === $matches[2] || null === $matches[2]) { + $position = 1; + } elseif ('0' !== $matches[2] || '' === $matches[3] || null === $matches[3]) { + $position = 2; + } else { + $position = 3; + } + + // Calculate the stability suffix + $stabilitySuffix = ''; + if (empty($matches[5]) && empty($matches[7]) && empty($matches[8])) { + $stabilitySuffix .= '-dev'; + } + + $lowVersion = $this->normalize(substr($constraint . $stabilitySuffix, 1)); + $lowerBound = new Constraint('>=', $lowVersion); + + // For upper bound, we increment the position of one more significance, + // but highPosition = 0 would be illegal + $highVersion = $this->manipulateVersionString($matches, $position, 1) . '-dev'; + $upperBound = new Constraint('<', $highVersion); + + return array( + $lowerBound, + $upperBound, + ); + } + + // X Range + // + // Any of X, x, or * may be used to "stand in" for one of the numeric values in the [major, minor, patch] tuple. + // A partial version range is treated as an X-Range, so the special character is in fact optional. + if (preg_match('{^v?(\d++)(?:\.(\d++))?(?:\.(\d++))?(?:\.[xX*])++$}', $constraint, $matches)) { + if (isset($matches[3]) && '' !== $matches[3] && null !== $matches[3]) { + $position = 3; + } elseif (isset($matches[2]) && '' !== $matches[2] && null !== $matches[2]) { + $position = 2; + } else { + $position = 1; + } + + $lowVersion = $this->manipulateVersionString($matches, $position) . '-dev'; + $highVersion = $this->manipulateVersionString($matches, $position, 1) . '-dev'; + + if ($lowVersion === '0.0.0.0-dev') { + return array(new Constraint('<', $highVersion)); + } + + return array( + new Constraint('>=', $lowVersion), + new Constraint('<', $highVersion), + ); + } + + // Hyphen Range + // + // Specifies an inclusive set. If a partial version is provided as the first version in the inclusive range, + // then the missing pieces are replaced with zeroes. If a partial version is provided as the second version in + // the inclusive range, then all versions that start with the supplied parts of the tuple are accepted, but + // nothing that would be greater than the provided tuple parts. + if (preg_match('{^(?P' . $versionRegex . ') +- +(?P' . $versionRegex . ')($)}i', $constraint, $matches)) { + // Calculate the stability suffix + $lowStabilitySuffix = ''; + if (empty($matches[6]) && empty($matches[8]) && empty($matches[9])) { + $lowStabilitySuffix = '-dev'; + } + + $lowVersion = $this->normalize($matches['from']); + $lowerBound = new Constraint('>=', $lowVersion . $lowStabilitySuffix); + + $empty = function ($x) { + return ($x === 0 || $x === '0') ? false : empty($x); + }; + + if ((!$empty($matches[12]) && !$empty($matches[13])) || !empty($matches[15]) || !empty($matches[17]) || !empty($matches[18])) { + $highVersion = $this->normalize($matches['to']); + $upperBound = new Constraint('<=', $highVersion); + } else { + $highMatch = array('', $matches[11], $matches[12], $matches[13], $matches[14]); + + // validate to version + $this->normalize($matches['to']); + + $highVersion = $this->manipulateVersionString($highMatch, $empty($matches[12]) ? 1 : 2, 1) . '-dev'; + $upperBound = new Constraint('<', $highVersion); + } + + return array( + $lowerBound, + $upperBound, + ); + } + + // Basic Comparators + if (preg_match('{^(<>|!=|>=?|<=?|==?)?\s*(.*)}', $constraint, $matches)) { + try { + try { + $version = $this->normalize($matches[2]); + } catch (\UnexpectedValueException $e) { + // recover from an invalid constraint like foobar-dev which should be dev-foobar + // except if the constraint uses a known operator, in which case it must be a parse error + if (substr($matches[2], -4) === '-dev' && preg_match('{^[0-9a-zA-Z-./]+$}', $matches[2])) { + $version = $this->normalize('dev-'.substr($matches[2], 0, -4)); + } else { + throw $e; + } + } + + $op = $matches[1] ?: '='; + + if ($op !== '==' && $op !== '=' && !empty($stabilityModifier) && self::parseStability($version) === 'stable') { + $version .= '-' . $stabilityModifier; + } elseif ('<' === $op || '>=' === $op) { + if (!preg_match('/-' . self::$modifierRegex . '$/', strtolower($matches[2]))) { + if (strpos($matches[2], 'dev-') !== 0) { + $version .= '-dev'; + } + } + } + + return array(new Constraint($matches[1] ?: '=', $version)); + } catch (\Exception $e) { + } + } + + $message = 'Could not parse version constraint ' . $constraint; + if (isset($e)) { + $message .= ': ' . $e->getMessage(); + } + + throw new \UnexpectedValueException($message); + } + + /** + * Increment, decrement, or simply pad a version number. + * + * Support function for {@link parseConstraint()} + * + * @param array $matches Array with version parts in array indexes 1,2,3,4 + * @param int $position 1,2,3,4 - which segment of the version to increment/decrement + * @param int $increment + * @param string $pad The string to pad version parts after $position + * + * @return string|null The new version + * + * @phpstan-param string[] $matches + */ + private function manipulateVersionString(array $matches, $position, $increment = 0, $pad = '0') + { + for ($i = 4; $i > 0; --$i) { + if ($i > $position) { + $matches[$i] = $pad; + } elseif ($i === $position && $increment) { + $matches[$i] += $increment; + // If $matches[$i] was 0, carry the decrement + if ($matches[$i] < 0) { + $matches[$i] = $pad; + --$position; + + // Return null on a carry overflow + if ($i === 1) { + return null; + } + } + } + } + + return $matches[1] . '.' . $matches[2] . '.' . $matches[3] . '.' . $matches[4]; + } + + /** + * Expand shorthand stability string to long version. + * + * @param string $stability + * + * @return string + */ + private function expandStability($stability) + { + $stability = strtolower($stability); + + switch ($stability) { + case 'a': + return 'alpha'; + case 'b': + return 'beta'; + case 'p': + case 'pl': + return 'patch'; + case 'rc': + return 'RC'; + default: + return $stability; + } + } +} diff --git a/public/vendor/filp/whoops/.mailmap b/public/vendor/filp/whoops/.mailmap new file mode 100644 index 0000000..13ac5d7 --- /dev/null +++ b/public/vendor/filp/whoops/.mailmap @@ -0,0 +1,2 @@ +Denis Sokolov +Filipe Dobreira diff --git a/public/vendor/filp/whoops/CHANGELOG.md b/public/vendor/filp/whoops/CHANGELOG.md new file mode 100644 index 0000000..5d43412 --- /dev/null +++ b/public/vendor/filp/whoops/CHANGELOG.md @@ -0,0 +1,168 @@ +# CHANGELOG + +## v2.18.0 + +* Line numbers are now clickable. + +## v2.17.0 + +* Support cursor IDE. + +## v2.16.0 + +* Support PHP `8.4`. +* Drop support for PHP older than `7.1`. + +## v2.15.4 + +* Improve link color in comments. + +## v2.15.3 + +* Improve performance of the syntax highlighting (#758). + +## v2.15.2 + +* Fixed missing code highlight, which additionally led to issue with switching tabs, between application and all frames ([#747](https://github.com/filp/whoops/issues/747)). + +## v2.15.1 + +* Fixed bug with PrettyPageHandler "*Calling `getFrameFilters` method on null*" ([#751](https://github.com/filp/whoops/pull/751)). + +## v2.15.0 + +* Add addFrameFilter ([#749](https://github.com/filp/whoops/pull/749)) + +## v2.14.6 + +* Upgraded prismJS to version `1.29.0` due to security issue ([#741][i741]). + +[i741]: https://github.com/filp/whoops/pull/741 + +## v2.14.5 + +* Allow `ArrayAccess` on super globals. + +## v2.14.4 + +* Fix PHP `5.5` support. +* Allow to use psr/log `2` or `3`. + +## v2.14.3 + +* Support PHP `8.1`. + +## v2.14.1 + +* Fix syntax highlighting scrolling too far. +* Improve the way we detect xdebug linkformat. + +## v2.14.0 + +* Switched syntax highlighting to Prism.js. + +Avoids licensing issues with prettify, and uses a maintained, modern project. + +## v2.13.0 + +* Add Netbeans editor. + +## v2.12.1 + +* Avoid redirecting away from an error. + +## v2.12.0 + +* Hide non-string values in super globals when requested. + +## v2.11.0 + +* Customize exit code. + +## v2.10.0 + +* Better chaining on handler classes. + +## v2.9.2 + +* Fix copy button styles. + +## v2.9.1 + +* Fix xdebug function crash on PHP `8`. + +## v2.9.0 + +* `JsonResponseHandler` includes the exception code. + +## v2.8.0 + +* Support PHP 8. + +## v2.7.3 + +* `PrettyPageHandler` functionality to hide superglobal keys has a clearer name +(`hideSuperglobalKey`). + +## v2.7.2 + +* `PrettyPageHandler` now accepts custom js files. +* `PrettyPageHandler` and `templateHelper` is now accessible through inheritance. + +## v2.7.1 + +* Fix a PHP warning in some cases with anonymous classes. + +## v2.7.0 + +* Added `removeFirstHandler` and `removeLastHandler`. + +## v2.6.0 + +* Fix 2.4.0 `pushHandler` changing the order of handlers. + +## v2.5.1 + +* Fix error messaging in a rare case. + +## v2.5.0 + +* Automatically configure xdebug if available. + +## v2.4.1 + +* Try harder to close all output buffers. + +## v2.4.0 + +* Allow to prepend and append handlers. + +## v2.3.2 + +* Various fixes from the community. + +## v2.3.1 + +* Prevent exception in Whoops when caught exception frame is not related to real file. + +## v2.3.0 + +* Show previous exception messages. + +## v2.2.0 + +* Support PHP `7.2`. + +## v2.1.0 + +* Add a `SystemFacade` to allow clients to override Whoops behavior. +* Show frame arguments in `PrettyPageHandler`. +* Highlight the line with the error. +* Add icons to search on Google and Stack Overflow. + +## v2.0.0 + +Backwards compatibility breaking changes: + +* `Run` class is now `final`. If you inherited from `Run`, please now instead use a custom `SystemFacade` injected into the `Run` constructor, or contribute your changes to our core. +* PHP < 5.5 support dropped. diff --git a/public/vendor/filp/whoops/LICENSE.md b/public/vendor/filp/whoops/LICENSE.md new file mode 100644 index 0000000..80407e7 --- /dev/null +++ b/public/vendor/filp/whoops/LICENSE.md @@ -0,0 +1,19 @@ +# The MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/public/vendor/filp/whoops/SECURITY.md b/public/vendor/filp/whoops/SECURITY.md new file mode 100644 index 0000000..edfd946 --- /dev/null +++ b/public/vendor/filp/whoops/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported Versions + +Only the latest released version of Whoops is supported. +To facilitate upgrades we almost never make backwards-incompatible changes. + +## Reporting a Vulnerability + +Please report vulnerabilities over email, by sending an email to `denis` at `sokolov` dot `cc`. + + diff --git a/public/vendor/filp/whoops/composer.json b/public/vendor/filp/whoops/composer.json new file mode 100644 index 0000000..31e1a92 --- /dev/null +++ b/public/vendor/filp/whoops/composer.json @@ -0,0 +1,46 @@ +{ + "name": "filp/whoops", + "license": "MIT", + "description": "php error handling for cool kids", + "keywords": ["library", "error", "handling", "exception", "whoops", "throwable"], + "homepage": "https://filp.github.io/whoops/", + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "scripts": { + "demo": "php -S localhost:8000 ./examples/example.php", + "test": "phpunit --testdox tests" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "mockery/mockery": "^1.0", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "autoload-dev": { + "psr-4": { + "Whoops\\": "tests/Whoops/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Exception/ErrorException.php b/public/vendor/filp/whoops/src/Whoops/Exception/ErrorException.php new file mode 100644 index 0000000..d74e823 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Exception/ErrorException.php @@ -0,0 +1,17 @@ + + */ + +namespace Whoops\Exception; + +use ErrorException as BaseErrorException; + +/** + * Wraps ErrorException; mostly used for typing (at least now) + * to easily cleanup the stack trace of redundant info. + */ +class ErrorException extends BaseErrorException +{ +} diff --git a/public/vendor/filp/whoops/src/Whoops/Exception/Formatter.php b/public/vendor/filp/whoops/src/Whoops/Exception/Formatter.php new file mode 100644 index 0000000..a041530 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Exception/Formatter.php @@ -0,0 +1,77 @@ + + */ + +namespace Whoops\Exception; + +use Whoops\Inspector\InspectorInterface; + +class Formatter +{ + /** + * Returns all basic information about the exception in a simple array + * for further convertion to other languages + * @param InspectorInterface $inspector + * @param bool $shouldAddTrace + * @param array $frameFilters + * @return array + */ + public static function formatExceptionAsDataArray(InspectorInterface $inspector, $shouldAddTrace, array $frameFilters = []) + { + $exception = $inspector->getException(); + $response = [ + 'type' => get_class($exception), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]; + + if ($shouldAddTrace) { + $frames = $inspector->getFrames($frameFilters); + $frameData = []; + + foreach ($frames as $frame) { + /** @var Frame $frame */ + $frameData[] = [ + 'file' => $frame->getFile(), + 'line' => $frame->getLine(), + 'function' => $frame->getFunction(), + 'class' => $frame->getClass(), + 'args' => $frame->getArgs(), + ]; + } + + $response['trace'] = $frameData; + } + + return $response; + } + + public static function formatExceptionPlain(InspectorInterface $inspector) + { + $message = $inspector->getException()->getMessage(); + $frames = $inspector->getFrames(); + + $plain = $inspector->getExceptionName(); + $plain .= ' thrown with message "'; + $plain .= $message; + $plain .= '"'."\n\n"; + + $plain .= "Stacktrace:\n"; + foreach ($frames as $i => $frame) { + $plain .= "#". (count($frames) - $i - 1). " "; + $plain .= $frame->getClass() ?: ''; + $plain .= $frame->getClass() && $frame->getFunction() ? ":" : ""; + $plain .= $frame->getFunction() ?: ''; + $plain .= ' in '; + $plain .= ($frame->getFile() ?: '<#unknown>'); + $plain .= ':'; + $plain .= (int) $frame->getLine(). "\n"; + } + + return $plain; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Exception/Frame.php b/public/vendor/filp/whoops/src/Whoops/Exception/Frame.php new file mode 100644 index 0000000..469070e --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Exception/Frame.php @@ -0,0 +1,311 @@ + + */ + +namespace Whoops\Exception; + +use InvalidArgumentException; +use Serializable; + +class Frame implements Serializable +{ + /** + * @var array + */ + protected $frame; + + /** + * @var string + */ + protected $fileContentsCache; + + /** + * @var array[] + */ + protected $comments = []; + + /** + * @var bool + */ + protected $application; + + public function __construct(array $frame) + { + $this->frame = $frame; + } + + /** + * @param bool $shortened + * @return string|null + */ + public function getFile($shortened = false) + { + if (empty($this->frame['file'])) { + return null; + } + + $file = $this->frame['file']; + + // Check if this frame occurred within an eval(). + // @todo: This can be made more reliable by checking if we've entered + // eval() in a previous trace, but will need some more work on the upper + // trace collector(s). + if (preg_match('/^(.*)\((\d+)\) : (?:eval\(\)\'d|assert) code$/', $file, $matches)) { + $file = $this->frame['file'] = $matches[1]; + $this->frame['line'] = (int) $matches[2]; + } + + if ($shortened && is_string($file)) { + // Replace the part of the path that all frames have in common, and add 'soft hyphens' for smoother line-breaks. + $dirname = dirname(dirname(dirname(dirname(dirname(dirname(__DIR__)))))); + if ($dirname !== '/') { + $file = str_replace($dirname, "…", $file); + } + $file = str_replace("/", "/­", $file); + } + + return $file; + } + + /** + * @return int|null + */ + public function getLine() + { + return isset($this->frame['line']) ? $this->frame['line'] : null; + } + + /** + * @return string|null + */ + public function getClass() + { + return isset($this->frame['class']) ? $this->frame['class'] : null; + } + + /** + * @return string|null + */ + public function getFunction() + { + return isset($this->frame['function']) ? $this->frame['function'] : null; + } + + /** + * @return array + */ + public function getArgs() + { + return isset($this->frame['args']) ? (array) $this->frame['args'] : []; + } + + /** + * Returns the full contents of the file for this frame, + * if it's known. + * @return string|null + */ + public function getFileContents() + { + if ($this->fileContentsCache === null && $filePath = $this->getFile()) { + // Leave the stage early when 'Unknown' or '[internal]' is passed + // this would otherwise raise an exception when + // open_basedir is enabled. + if ($filePath === "Unknown" || $filePath === '[internal]') { + return null; + } + + try { + $this->fileContentsCache = file_get_contents($filePath); + } catch (ErrorException $exception) { + // Internal file paths of PHP extensions cannot be opened + } + } + + return $this->fileContentsCache; + } + + /** + * Adds a comment to this frame, that can be received and + * used by other handlers. For example, the PrettyPage handler + * can attach these comments under the code for each frame. + * + * An interesting use for this would be, for example, code analysis + * & annotations. + * + * @param string $comment + * @param string $context Optional string identifying the origin of the comment + */ + public function addComment($comment, $context = 'global') + { + $this->comments[] = [ + 'comment' => $comment, + 'context' => $context, + ]; + } + + /** + * Returns all comments for this frame. Optionally allows + * a filter to only retrieve comments from a specific + * context. + * + * @param string $filter + * @return array[] + */ + public function getComments($filter = null) + { + $comments = $this->comments; + + if ($filter !== null) { + $comments = array_filter($comments, function ($c) use ($filter) { + return $c['context'] == $filter; + }); + } + + return $comments; + } + + /** + * Returns the array containing the raw frame data from which + * this Frame object was built + * + * @return array + */ + public function getRawFrame() + { + return $this->frame; + } + + /** + * Returns the contents of the file for this frame as an + * array of lines, and optionally as a clamped range of lines. + * + * NOTE: lines are 0-indexed + * + * @example + * Get all lines for this file + * $frame->getFileLines(); // => array( 0 => ' '...', ...) + * @example + * Get one line for this file, starting at line 10 (zero-indexed, remember!) + * $frame->getFileLines(9, 1); // array( 9 => '...' ) + * + * @throws InvalidArgumentException if $length is less than or equal to 0 + * @param int $start + * @param int $length + * @return string[]|null + */ + public function getFileLines($start = 0, $length = null) + { + if (null !== ($contents = $this->getFileContents())) { + $lines = explode("\n", $contents); + + // Get a subset of lines from $start to $end + if ($length !== null) { + $start = (int) $start; + $length = (int) $length; + if ($start < 0) { + $start = 0; + } + + if ($length <= 0) { + throw new InvalidArgumentException( + "\$length($length) cannot be lower or equal to 0" + ); + } + + $lines = array_slice($lines, $start, $length, true); + } + + return $lines; + } + } + + /** + * Implements the Serializable interface, with special + * steps to also save the existing comments. + * + * @see Serializable::serialize + * @return string + */ + public function serialize() + { + $frame = $this->frame; + if (!empty($this->comments)) { + $frame['_comments'] = $this->comments; + } + + return serialize($frame); + } + + public function __serialize() + { + $frame = $this->frame; + if (!empty($this->comments)) { + $frame['_comments'] = $this->comments; + } + return $frame; + } + + /** + * Unserializes the frame data, while also preserving + * any existing comment data. + * + * @see Serializable::unserialize + * @param string $serializedFrame + */ + public function unserialize($serializedFrame) + { + $frame = unserialize($serializedFrame); + + if (!empty($frame['_comments'])) { + $this->comments = $frame['_comments']; + unset($frame['_comments']); + } + + $this->frame = $frame; + } + + public function __unserialize($frame) + { + if (!empty($frame['_comments'])) { + $this->comments = $frame['_comments']; + unset($frame['_comments']); + } + + $this->frame = $frame; + } + + /** + * Compares Frame against one another + * @param Frame $frame + * @return bool + */ + public function equals(Frame $frame) + { + if (!$this->getFile() || $this->getFile() === 'Unknown' || !$this->getLine()) { + return false; + } + return $frame->getFile() === $this->getFile() && $frame->getLine() === $this->getLine(); + } + + /** + * Returns whether this frame belongs to the application or not. + * + * @return boolean + */ + public function isApplication() + { + return $this->application; + } + + /** + * Mark as an frame belonging to the application. + * + * @param boolean $application + */ + public function setApplication($application) + { + $this->application = $application; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Exception/FrameCollection.php b/public/vendor/filp/whoops/src/Whoops/Exception/FrameCollection.php new file mode 100644 index 0000000..922c035 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Exception/FrameCollection.php @@ -0,0 +1,219 @@ + + */ + +namespace Whoops\Exception; + +use ArrayAccess; +use ArrayIterator; +use Countable; +use IteratorAggregate; +use ReturnTypeWillChange; +use Serializable; +use UnexpectedValueException; + +/** + * Exposes a fluent interface for dealing with an ordered list + * of stack-trace frames. + */ +class FrameCollection implements ArrayAccess, IteratorAggregate, Serializable, Countable +{ + /** + * @var array[] + */ + private $frames; + + public function __construct(array $frames) + { + $this->frames = array_map(function ($frame) { + return new Frame($frame); + }, $frames); + } + + /** + * Filters frames using a callable, returns the same FrameCollection + * + * @param callable $callable + * @return FrameCollection + */ + public function filter($callable) + { + $this->frames = array_values(array_filter($this->frames, $callable)); + return $this; + } + + /** + * Map the collection of frames + * + * @param callable $callable + * @return FrameCollection + */ + public function map($callable) + { + // Contain the map within a higher-order callable + // that enforces type-correctness for the $callable + $this->frames = array_map(function ($frame) use ($callable) { + $frame = call_user_func($callable, $frame); + + if (!$frame instanceof Frame) { + throw new UnexpectedValueException( + "Callable to " . __CLASS__ . "::map must return a Frame object" + ); + } + + return $frame; + }, $this->frames); + + return $this; + } + + /** + * Returns an array with all frames, does not affect + * the internal array. + * + * @todo If this gets any more complex than this, + * have getIterator use this method. + * @see FrameCollection::getIterator + * @return array + */ + public function getArray() + { + return $this->frames; + } + + /** + * @see IteratorAggregate::getIterator + * @return ArrayIterator + */ + #[ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->frames); + } + + /** + * @see ArrayAccess::offsetExists + * @param int $offset + */ + #[ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->frames[$offset]); + } + + /** + * @see ArrayAccess::offsetGet + * @param int $offset + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->frames[$offset]; + } + + /** + * @see ArrayAccess::offsetSet + * @param int $offset + */ + #[ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + throw new \Exception(__CLASS__ . ' is read only'); + } + + /** + * @see ArrayAccess::offsetUnset + * @param int $offset + */ + #[ReturnTypeWillChange] + public function offsetUnset($offset) + { + throw new \Exception(__CLASS__ . ' is read only'); + } + + /** + * @see Countable::count + * @return int + */ + #[ReturnTypeWillChange] + public function count() + { + return count($this->frames); + } + + /** + * Count the frames that belongs to the application. + * + * @return int + */ + public function countIsApplication() + { + return count(array_filter($this->frames, function (Frame $f) { + return $f->isApplication(); + })); + } + + /** + * @see Serializable::serialize + * @return string + */ + #[ReturnTypeWillChange] + public function serialize() + { + return serialize($this->frames); + } + + /** + * @see Serializable::unserialize + * @param string $serializedFrames + */ + #[ReturnTypeWillChange] + public function unserialize($serializedFrames) + { + $this->frames = unserialize($serializedFrames); + } + + public function __serialize() + { + return $this->frames; + } + + public function __unserialize(array $serializedFrames) + { + $this->frames = $serializedFrames; + } + + /** + * @param Frame[] $frames Array of Frame instances, usually from $e->getPrevious() + */ + public function prependFrames(array $frames) + { + $this->frames = array_merge($frames, $this->frames); + } + + /** + * Gets the innermost part of stack trace that is not the same as that of outer exception + * + * @param FrameCollection $parentFrames Outer exception frames to compare tail against + * @return Frame[] + */ + public function topDiff(FrameCollection $parentFrames) + { + $diff = $this->frames; + + $parentFrames = $parentFrames->getArray(); + $p = count($parentFrames)-1; + + for ($i = count($diff)-1; $i >= 0 && $p >= 0; $i--) { + /** @var Frame $tailFrame */ + $tailFrame = $diff[$i]; + if ($tailFrame->equals($parentFrames[$p])) { + unset($diff[$i]); + } + $p--; + } + return $diff; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Exception/Inspector.php b/public/vendor/filp/whoops/src/Whoops/Exception/Inspector.php new file mode 100644 index 0000000..a183563 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Exception/Inspector.php @@ -0,0 +1,341 @@ + + */ + +namespace Whoops\Exception; + +use Whoops\Inspector\InspectorFactory; +use Whoops\Inspector\InspectorInterface; +use Whoops\Util\Misc; + +class Inspector implements InspectorInterface +{ + /** + * @var \Throwable + */ + private $exception; + + /** + * @var \Whoops\Exception\FrameCollection + */ + private $frames; + + /** + * @var \Whoops\Exception\Inspector + */ + private $previousExceptionInspector; + + /** + * @var \Throwable[] + */ + private $previousExceptions; + + /** + * @var \Whoops\Inspector\InspectorFactoryInterface|null + */ + protected $inspectorFactory; + + /** + * @param \Throwable $exception The exception to inspect + * @param \Whoops\Inspector\InspectorFactoryInterface $factory + */ + public function __construct($exception, $factory = null) + { + $this->exception = $exception; + $this->inspectorFactory = $factory ?: new InspectorFactory(); + } + + /** + * @return \Throwable + */ + public function getException() + { + return $this->exception; + } + + /** + * @return string + */ + public function getExceptionName() + { + return get_class($this->exception); + } + + /** + * @return string + */ + public function getExceptionMessage() + { + return $this->extractDocrefUrl($this->exception->getMessage())['message']; + } + + /** + * @return string[] + */ + public function getPreviousExceptionMessages() + { + return array_map(function ($prev) { + /** @var \Throwable $prev */ + return $this->extractDocrefUrl($prev->getMessage())['message']; + }, $this->getPreviousExceptions()); + } + + /** + * @return int[] + */ + public function getPreviousExceptionCodes() + { + return array_map(function ($prev) { + /** @var \Throwable $prev */ + return $prev->getCode(); + }, $this->getPreviousExceptions()); + } + + /** + * Returns a url to the php-manual related to the underlying error - when available. + * + * @return string|null + */ + public function getExceptionDocrefUrl() + { + return $this->extractDocrefUrl($this->exception->getMessage())['url']; + } + + private function extractDocrefUrl($message) + { + $docref = [ + 'message' => $message, + 'url' => null, + ]; + + // php embbeds urls to the manual into the Exception message with the following ini-settings defined + // http://php.net/manual/en/errorfunc.configuration.php#ini.docref-root + if (!ini_get('html_errors') || !ini_get('docref_root')) { + return $docref; + } + + $pattern = "/\[(?:[^<]+)<\/a>\]/"; + if (preg_match($pattern, $message, $matches)) { + // -> strip those automatically generated links from the exception message + $docref['message'] = preg_replace($pattern, '', $message, 1); + $docref['url'] = $matches[1]; + } + + return $docref; + } + + /** + * Does the wrapped Exception has a previous Exception? + * @return bool + */ + public function hasPreviousException() + { + return $this->previousExceptionInspector || $this->exception->getPrevious(); + } + + /** + * Returns an Inspector for a previous Exception, if any. + * @todo Clean this up a bit, cache stuff a bit better. + * @return Inspector + */ + public function getPreviousExceptionInspector() + { + if ($this->previousExceptionInspector === null) { + $previousException = $this->exception->getPrevious(); + + if ($previousException) { + $this->previousExceptionInspector = $this->inspectorFactory->create($previousException); + } + } + + return $this->previousExceptionInspector; + } + + + /** + * Returns an array of all previous exceptions for this inspector's exception + * @return \Throwable[] + */ + public function getPreviousExceptions() + { + if ($this->previousExceptions === null) { + $this->previousExceptions = []; + + $prev = $this->exception->getPrevious(); + while ($prev !== null) { + $this->previousExceptions[] = $prev; + $prev = $prev->getPrevious(); + } + } + + return $this->previousExceptions; + } + + /** + * Returns an iterator for the inspected exception's + * frames. + * + * @param array $frameFilters + * + * @return \Whoops\Exception\FrameCollection + */ + public function getFrames(array $frameFilters = []) + { + if ($this->frames === null) { + $frames = $this->getTrace($this->exception); + + // Fill empty line/file info for call_user_func_array usages (PHP Bug #44428) + foreach ($frames as $k => $frame) { + if (empty($frame['file'])) { + // Default values when file and line are missing + $file = '[internal]'; + $line = 0; + + $next_frame = !empty($frames[$k + 1]) ? $frames[$k + 1] : []; + + if ($this->isValidNextFrame($next_frame)) { + $file = $next_frame['file']; + $line = $next_frame['line']; + } + + $frames[$k]['file'] = $file; + $frames[$k]['line'] = $line; + } + } + + // Find latest non-error handling frame index ($i) used to remove error handling frames + $i = 0; + foreach ($frames as $k => $frame) { + if ($frame['file'] == $this->exception->getFile() && $frame['line'] == $this->exception->getLine()) { + $i = $k; + } + } + + // Remove error handling frames + if ($i > 0) { + array_splice($frames, 0, $i); + } + + $firstFrame = $this->getFrameFromException($this->exception); + array_unshift($frames, $firstFrame); + + $this->frames = new FrameCollection($frames); + + if ($previousInspector = $this->getPreviousExceptionInspector()) { + // Keep outer frame on top of the inner one + $outerFrames = $this->frames; + $newFrames = clone $previousInspector->getFrames(); + // I assume it will always be set, but let's be safe + if (isset($newFrames[0])) { + $newFrames[0]->addComment( + $previousInspector->getExceptionMessage(), + 'Exception message:' + ); + } + $newFrames->prependFrames($outerFrames->topDiff($newFrames)); + $this->frames = $newFrames; + } + + // Apply frame filters callbacks on the frames stack + if (!empty($frameFilters)) { + foreach ($frameFilters as $filterCallback) { + $this->frames->filter($filterCallback); + } + } + } + + return $this->frames; + } + + /** + * Gets the backtrace from an exception. + * + * If xdebug is installed + * + * @param \Throwable $e + * @return array + */ + protected function getTrace($e) + { + $traces = $e->getTrace(); + + // Get trace from xdebug if enabled, failure exceptions only trace to the shutdown handler by default + if (!$e instanceof \ErrorException) { + return $traces; + } + + if (!Misc::isLevelFatal($e->getSeverity())) { + return $traces; + } + + if (!extension_loaded('xdebug') || !function_exists('xdebug_is_enabled') || !xdebug_is_enabled()) { + return $traces; + } + + // Use xdebug to get the full stack trace and remove the shutdown handler stack trace + $stack = array_reverse(xdebug_get_function_stack()); + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $traces = array_diff_key($stack, $trace); + + return $traces; + } + + /** + * Given an exception, generates an array in the format + * generated by Exception::getTrace() + * @param \Throwable $exception + * @return array + */ + protected function getFrameFromException($exception) + { + return [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'class' => get_class($exception), + 'args' => [ + $exception->getMessage(), + ], + ]; + } + + /** + * Given an error, generates an array in the format + * generated by ErrorException + * @param ErrorException $exception + * @return array + */ + protected function getFrameFromError(ErrorException $exception) + { + return [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'class' => null, + 'args' => [], + ]; + } + + /** + * Determine if the frame can be used to fill in previous frame's missing info + * happens for call_user_func and call_user_func_array usages (PHP Bug #44428) + * + * @return bool + */ + protected function isValidNextFrame(array $frame) + { + if (empty($frame['file'])) { + return false; + } + + if (empty($frame['line'])) { + return false; + } + + if (empty($frame['function']) || !stristr($frame['function'], 'call_user_func')) { + return false; + } + + return true; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/CallbackHandler.php b/public/vendor/filp/whoops/src/Whoops/Handler/CallbackHandler.php new file mode 100644 index 0000000..cc46e70 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/CallbackHandler.php @@ -0,0 +1,52 @@ + + */ + +namespace Whoops\Handler; + +use InvalidArgumentException; + +/** + * Wrapper for Closures passed as handlers. Can be used + * directly, or will be instantiated automagically by Whoops\Run + * if passed to Run::pushHandler + */ +class CallbackHandler extends Handler +{ + /** + * @var callable + */ + protected $callable; + + /** + * @throws InvalidArgumentException If argument is not callable + * @param callable $callable + */ + public function __construct($callable) + { + if (!is_callable($callable)) { + throw new InvalidArgumentException( + 'Argument to ' . __METHOD__ . ' must be valid callable' + ); + } + + $this->callable = $callable; + } + + /** + * @return int|null + */ + public function handle() + { + $exception = $this->getException(); + $inspector = $this->getInspector(); + $run = $this->getRun(); + $callable = $this->callable; + + // invoke the callable directly, to get simpler stacktraces (in comparison to call_user_func). + // this assumes that $callable is a properly typed php-callable, which we check in __construct(). + return $callable($exception, $inspector, $run); + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/Handler.php b/public/vendor/filp/whoops/src/Whoops/Handler/Handler.php new file mode 100644 index 0000000..21435fc --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/Handler.php @@ -0,0 +1,95 @@ + + */ + +namespace Whoops\Handler; + +use Whoops\Inspector\InspectorInterface; +use Whoops\RunInterface; + +/** + * Abstract implementation of a Handler. + */ +abstract class Handler implements HandlerInterface +{ + /* + Return constants that can be returned from Handler::handle + to message the handler walker. + */ + const DONE = 0x10; // returning this is optional, only exists for + // semantic purposes + /** + * The Handler has handled the Throwable in some way, and wishes to skip any other Handler. + * Execution will continue. + */ + const LAST_HANDLER = 0x20; + /** + * The Handler has handled the Throwable in some way, and wishes to quit/stop execution + */ + const QUIT = 0x30; + + /** + * @var RunInterface + */ + private $run; + + /** + * @var InspectorInterface $inspector + */ + private $inspector; + + /** + * @var \Throwable $exception + */ + private $exception; + + /** + * @param RunInterface $run + */ + public function setRun(RunInterface $run) + { + $this->run = $run; + } + + /** + * @return RunInterface + */ + protected function getRun() + { + return $this->run; + } + + /** + * @param InspectorInterface $inspector + */ + public function setInspector(InspectorInterface $inspector) + { + $this->inspector = $inspector; + } + + /** + * @return InspectorInterface + */ + protected function getInspector() + { + return $this->inspector; + } + + /** + * @param \Throwable $exception + */ + public function setException($exception) + { + $this->exception = $exception; + } + + /** + * @return \Throwable + */ + protected function getException() + { + return $this->exception; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/HandlerInterface.php b/public/vendor/filp/whoops/src/Whoops/Handler/HandlerInterface.php new file mode 100644 index 0000000..2deae98 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/HandlerInterface.php @@ -0,0 +1,36 @@ + + */ + +namespace Whoops\Handler; + +use Whoops\Inspector\InspectorInterface; +use Whoops\RunInterface; + +interface HandlerInterface +{ + /** + * @return int|null A handler may return nothing, or a Handler::HANDLE_* constant + */ + public function handle(); + + /** + * @param RunInterface $run + * @return void + */ + public function setRun(RunInterface $run); + + /** + * @param \Throwable $exception + * @return void + */ + public function setException($exception); + + /** + * @param InspectorInterface $inspector + * @return void + */ + public function setInspector(InspectorInterface $inspector); +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/JsonResponseHandler.php b/public/vendor/filp/whoops/src/Whoops/Handler/JsonResponseHandler.php new file mode 100644 index 0000000..9051b36 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/JsonResponseHandler.php @@ -0,0 +1,90 @@ + + */ + +namespace Whoops\Handler; + +use Whoops\Exception\Formatter; + +/** + * Catches an exception and converts it to a JSON + * response. Additionally can also return exception + * frames for consumption by an API. + */ +class JsonResponseHandler extends Handler +{ + /** + * @var bool + */ + private $returnFrames = false; + + /** + * @var bool + */ + private $jsonApi = false; + + /** + * Returns errors[[]] instead of error[] to be in compliance with the json:api spec + * @param bool $jsonApi Default is false + * @return static + */ + public function setJsonApi($jsonApi = false) + { + $this->jsonApi = (bool) $jsonApi; + return $this; + } + + /** + * @param bool|null $returnFrames + * @return bool|static + */ + public function addTraceToOutput($returnFrames = null) + { + if (func_num_args() == 0) { + return $this->returnFrames; + } + + $this->returnFrames = (bool) $returnFrames; + return $this; + } + + /** + * @return int + */ + public function handle() + { + if ($this->jsonApi === true) { + $response = [ + 'errors' => [ + Formatter::formatExceptionAsDataArray( + $this->getInspector(), + $this->addTraceToOutput(), + $this->getRun()->getFrameFilters() + ), + ] + ]; + } else { + $response = [ + 'error' => Formatter::formatExceptionAsDataArray( + $this->getInspector(), + $this->addTraceToOutput(), + $this->getRun()->getFrameFilters() + ), + ]; + } + + echo json_encode($response, defined('JSON_PARTIAL_OUTPUT_ON_ERROR') ? JSON_PARTIAL_OUTPUT_ON_ERROR : 0); + + return Handler::QUIT; + } + + /** + * @return string + */ + public function contentType() + { + return 'application/json'; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/PlainTextHandler.php b/public/vendor/filp/whoops/src/Whoops/Handler/PlainTextHandler.php new file mode 100644 index 0000000..711cf0d --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/PlainTextHandler.php @@ -0,0 +1,359 @@ + +* Plaintext handler for command line and logs. +* @author Pierre-Yves Landuré +*/ + +namespace Whoops\Handler; + +use InvalidArgumentException; +use Psr\Log\LoggerInterface; +use Whoops\Exception\Frame; + +/** +* Handler outputing plaintext error messages. Can be used +* directly, or will be instantiated automagically by Whoops\Run +* if passed to Run::pushHandler +*/ +class PlainTextHandler extends Handler +{ + const VAR_DUMP_PREFIX = ' | '; + + /** + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * @var callable + */ + protected $dumper; + + /** + * @var bool + */ + private $addTraceToOutput = true; + + /** + * @var bool|integer + */ + private $addTraceFunctionArgsToOutput = false; + + /** + * @var integer + */ + private $traceFunctionArgsOutputLimit = 1024; + + /** + * @var bool + */ + private $addPreviousToOutput = true; + + /** + * @var bool + */ + private $loggerOnly = false; + + /** + * Constructor. + * @throws InvalidArgumentException If argument is not null or a LoggerInterface + * @param \Psr\Log\LoggerInterface|null $logger + */ + public function __construct($logger = null) + { + $this->setLogger($logger); + } + + /** + * Set the output logger interface. + * @throws InvalidArgumentException If argument is not null or a LoggerInterface + * @param \Psr\Log\LoggerInterface|null $logger + */ + public function setLogger($logger = null) + { + if (! (is_null($logger) + || $logger instanceof LoggerInterface)) { + throw new InvalidArgumentException( + 'Argument to ' . __METHOD__ . + " must be a valid Logger Interface (aka. Monolog), " . + get_class($logger) . ' given.' + ); + } + + $this->logger = $logger; + } + + /** + * @return \Psr\Log\LoggerInterface|null + */ + public function getLogger() + { + return $this->logger; + } + + /** + * Set var dumper callback function. + * + * @param callable $dumper + * @return static + */ + public function setDumper(callable $dumper) + { + $this->dumper = $dumper; + return $this; + } + + /** + * Add error trace to output. + * @param bool|null $addTraceToOutput + * @return bool|static + */ + public function addTraceToOutput($addTraceToOutput = null) + { + if (func_num_args() == 0) { + return $this->addTraceToOutput; + } + + $this->addTraceToOutput = (bool) $addTraceToOutput; + return $this; + } + + /** + * Add previous exceptions to output. + * @param bool|null $addPreviousToOutput + * @return bool|static + */ + public function addPreviousToOutput($addPreviousToOutput = null) + { + if (func_num_args() == 0) { + return $this->addPreviousToOutput; + } + + $this->addPreviousToOutput = (bool) $addPreviousToOutput; + return $this; + } + + /** + * Add error trace function arguments to output. + * Set to True for all frame args, or integer for the n first frame args. + * @param bool|integer|null $addTraceFunctionArgsToOutput + * @return static|bool|integer + */ + public function addTraceFunctionArgsToOutput($addTraceFunctionArgsToOutput = null) + { + if (func_num_args() == 0) { + return $this->addTraceFunctionArgsToOutput; + } + + if (! is_integer($addTraceFunctionArgsToOutput)) { + $this->addTraceFunctionArgsToOutput = (bool) $addTraceFunctionArgsToOutput; + } else { + $this->addTraceFunctionArgsToOutput = $addTraceFunctionArgsToOutput; + } + return $this; + } + + /** + * Set the size limit in bytes of frame arguments var_dump output. + * If the limit is reached, the var_dump output is discarded. + * Prevent memory limit errors. + * @param int $traceFunctionArgsOutputLimit + * @return static + */ + public function setTraceFunctionArgsOutputLimit($traceFunctionArgsOutputLimit) + { + $this->traceFunctionArgsOutputLimit = (int) $traceFunctionArgsOutputLimit; + return $this; + } + + /** + * Create plain text response and return it as a string + * @return string + */ + public function generateResponse() + { + $exception = $this->getException(); + $message = $this->getExceptionOutput($exception); + + if ($this->addPreviousToOutput) { + $previous = $exception->getPrevious(); + while ($previous) { + $message .= "\n\nCaused by\n" . $this->getExceptionOutput($previous); + $previous = $previous->getPrevious(); + } + } + + + return $message . $this->getTraceOutput() . "\n"; + } + + /** + * Get the size limit in bytes of frame arguments var_dump output. + * If the limit is reached, the var_dump output is discarded. + * Prevent memory limit errors. + * @return integer + */ + public function getTraceFunctionArgsOutputLimit() + { + return $this->traceFunctionArgsOutputLimit; + } + + /** + * Only output to logger. + * @param bool|null $loggerOnly + * @return static|bool + */ + public function loggerOnly($loggerOnly = null) + { + if (func_num_args() == 0) { + return $this->loggerOnly; + } + + $this->loggerOnly = (bool) $loggerOnly; + return $this; + } + + /** + * Test if handler can output to stdout. + * @return bool + */ + private function canOutput() + { + return !$this->loggerOnly(); + } + + /** + * Get the frame args var_dump. + * @param \Whoops\Exception\Frame $frame [description] + * @param integer $line [description] + * @return string + */ + private function getFrameArgsOutput(Frame $frame, $line) + { + if ($this->addTraceFunctionArgsToOutput() === false + || $this->addTraceFunctionArgsToOutput() < $line) { + return ''; + } + + // Dump the arguments: + ob_start(); + $this->dump($frame->getArgs()); + if (ob_get_length() > $this->getTraceFunctionArgsOutputLimit()) { + // The argument var_dump is to big. + // Discarded to limit memory usage. + ob_clean(); + return sprintf( + "\n%sArguments dump length greater than %d Bytes. Discarded.", + self::VAR_DUMP_PREFIX, + $this->getTraceFunctionArgsOutputLimit() + ); + } + + return sprintf( + "\n%s", + preg_replace('/^/m', self::VAR_DUMP_PREFIX, ob_get_clean()) + ); + } + + /** + * Dump variable. + * + * @param mixed $var + * @return void + */ + protected function dump($var) + { + if ($this->dumper) { + call_user_func($this->dumper, $var); + } else { + var_dump($var); + } + } + + /** + * Get the exception trace as plain text. + * @return string + */ + private function getTraceOutput() + { + if (! $this->addTraceToOutput()) { + return ''; + } + $inspector = $this->getInspector(); + $frames = $inspector->getFrames($this->getRun()->getFrameFilters()); + + $response = "\nStack trace:"; + + $line = 1; + foreach ($frames as $frame) { + /** @var Frame $frame */ + $class = $frame->getClass(); + + $template = "\n%3d. %s->%s() %s:%d%s"; + if (! $class) { + // Remove method arrow (->) from output. + $template = "\n%3d. %s%s() %s:%d%s"; + } + + $response .= sprintf( + $template, + $line, + $class, + $frame->getFunction(), + $frame->getFile(), + $frame->getLine(), + $this->getFrameArgsOutput($frame, $line) + ); + + $line++; + } + + return $response; + } + + /** + * Get the exception as plain text. + * @param \Throwable $exception + * @return string + */ + private function getExceptionOutput($exception) + { + return sprintf( + "%s: %s in file %s on line %d", + get_class($exception), + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + ); + } + + /** + * @return int + */ + public function handle() + { + $response = $this->generateResponse(); + + if ($this->getLogger()) { + $this->getLogger()->error($response); + } + + if (! $this->canOutput()) { + return Handler::DONE; + } + + echo $response; + + return Handler::QUIT; + } + + /** + * @return string + */ + public function contentType() + { + return 'text/plain'; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php b/public/vendor/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php new file mode 100644 index 0000000..161ba50 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/PrettyPageHandler.php @@ -0,0 +1,834 @@ + + */ + +namespace Whoops\Handler; + +use InvalidArgumentException; +use RuntimeException; +use Symfony\Component\VarDumper\Cloner\AbstractCloner; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use UnexpectedValueException; +use Whoops\Exception\Formatter; +use Whoops\Util\Misc; +use Whoops\Util\TemplateHelper; + +class PrettyPageHandler extends Handler +{ + const EDITOR_SUBLIME = "sublime"; + const EDITOR_TEXTMATE = "textmate"; + const EDITOR_EMACS = "emacs"; + const EDITOR_MACVIM = "macvim"; + const EDITOR_PHPSTORM = "phpstorm"; + const EDITOR_IDEA = "idea"; + const EDITOR_VSCODE = "vscode"; + const EDITOR_ATOM = "atom"; + const EDITOR_ESPRESSO = "espresso"; + const EDITOR_XDEBUG = "xdebug"; + const EDITOR_NETBEANS = "netbeans"; + const EDITOR_CURSOR = "cursor"; + + /** + * Search paths to be scanned for resources. + * + * Stored in the reverse order they're declared. + * + * @var array + */ + private $searchPaths = []; + + /** + * Fast lookup cache for known resource locations. + * + * @var array + */ + private $resourceCache = []; + + /** + * The name of the custom css file. + * + * @var string|null + */ + private $customCss = null; + + /** + * The name of the custom js file. + * + * @var string|null + */ + private $customJs = null; + + /** + * @var array[] + */ + private $extraTables = []; + + /** + * @var bool + */ + private $handleUnconditionally = false; + + /** + * @var string + */ + private $pageTitle = "Whoops! There was an error."; + + /** + * @var array[] + */ + private $applicationPaths; + + /** + * @var array[] + */ + private $blacklist = [ + '_GET' => [], + '_POST' => [], + '_FILES' => [], + '_COOKIE' => [], + '_SESSION' => [], + '_SERVER' => [], + '_ENV' => [], + ]; + + /** + * An identifier for a known IDE/text editor. + * + * Either a string, or a calalble that resolves a string, that can be used + * to open a given file in an editor. If the string contains the special + * substrings %file or %line, they will be replaced with the correct data. + * + * @example + * "txmt://open?url=%file&line=%line" + * + * @var callable|string $editor + */ + protected $editor; + + /** + * A list of known editor strings. + * + * @var array + */ + protected $editors = [ + "sublime" => "subl://open?url=file://%file&line=%line", + "textmate" => "txmt://open?url=file://%file&line=%line", + "emacs" => "emacs://open?url=file://%file&line=%line", + "macvim" => "mvim://open/?url=file://%file&line=%line", + "phpstorm" => "phpstorm://open?file=%file&line=%line", + "idea" => "idea://open?file=%file&line=%line", + "vscode" => "vscode://file/%file:%line", + "atom" => "atom://core/open/file?filename=%file&line=%line", + "espresso" => "x-espresso://open?filepath=%file&lines=%line", + "netbeans" => "netbeans://open/?f=%file:%line", + "cursor" => "cursor://file/%file:%line", + ]; + + /** + * @var TemplateHelper + */ + protected $templateHelper; + + /** + * Constructor. + * + * @return void + */ + public function __construct() + { + if (ini_get('xdebug.file_link_format') || get_cfg_var('xdebug.file_link_format')) { + // Register editor using xdebug's file_link_format option. + $this->editors['xdebug'] = function ($file, $line) { + return str_replace(['%f', '%l'], [$file, $line], ini_get('xdebug.file_link_format') ?: get_cfg_var('xdebug.file_link_format')); + }; + + // If xdebug is available, use it as default editor. + $this->setEditor('xdebug'); + } + + // Add the default, local resource search path: + $this->searchPaths[] = __DIR__ . "/../Resources"; + + // blacklist php provided auth based values + $this->blacklist('_SERVER', 'PHP_AUTH_PW'); + + $this->templateHelper = new TemplateHelper(); + + if (class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) { + $cloner = new VarCloner(); + // Only dump object internals if a custom caster exists for performance reasons + // https://github.com/filp/whoops/pull/404 + $cloner->addCasters(['*' => function ($obj, $a, $stub, $isNested, $filter = 0) { + $class = $stub->class; + $classes = [$class => $class] + class_parents($obj) + class_implements($obj); + + foreach ($classes as $class) { + if (isset(AbstractCloner::$defaultCasters[$class])) { + return $a; + } + } + + // Remove all internals + return []; + }]); + $this->templateHelper->setCloner($cloner); + } + } + + /** + * @return int|null + * + * @throws \Exception + */ + public function handle() + { + if (!$this->handleUnconditionally()) { + // Check conditions for outputting HTML: + // @todo: Make this more robust + if (PHP_SAPI === 'cli') { + // Help users who have been relying on an internal test value + // fix their code to the proper method + if (isset($_ENV['whoops-test'])) { + throw new \Exception( + 'Use handleUnconditionally instead of whoops-test' + .' environment variable' + ); + } + + return Handler::DONE; + } + } + + $templateFile = $this->getResource("views/layout.html.php"); + $cssFile = $this->getResource("css/whoops.base.css"); + $zeptoFile = $this->getResource("js/zepto.min.js"); + $prismJs = $this->getResource("js/prism.js"); + $prismCss = $this->getResource("css/prism.css"); + $clipboard = $this->getResource("js/clipboard.min.js"); + $jsFile = $this->getResource("js/whoops.base.js"); + + if ($this->customCss) { + $customCssFile = $this->getResource($this->customCss); + } + + if ($this->customJs) { + $customJsFile = $this->getResource($this->customJs); + } + + $inspector = $this->getInspector(); + $frames = $this->getExceptionFrames(); + $code = $this->getExceptionCode(); + + // List of variables that will be passed to the layout template. + $vars = [ + "page_title" => $this->getPageTitle(), + + // @todo: Asset compiler + "stylesheet" => file_get_contents($cssFile), + "zepto" => file_get_contents($zeptoFile), + "prismJs" => file_get_contents($prismJs), + "prismCss" => file_get_contents($prismCss), + "clipboard" => file_get_contents($clipboard), + "javascript" => file_get_contents($jsFile), + + // Template paths: + "header" => $this->getResource("views/header.html.php"), + "header_outer" => $this->getResource("views/header_outer.html.php"), + "frame_list" => $this->getResource("views/frame_list.html.php"), + "frames_description" => $this->getResource("views/frames_description.html.php"), + "frames_container" => $this->getResource("views/frames_container.html.php"), + "panel_details" => $this->getResource("views/panel_details.html.php"), + "panel_details_outer" => $this->getResource("views/panel_details_outer.html.php"), + "panel_left" => $this->getResource("views/panel_left.html.php"), + "panel_left_outer" => $this->getResource("views/panel_left_outer.html.php"), + "frame_code" => $this->getResource("views/frame_code.html.php"), + "env_details" => $this->getResource("views/env_details.html.php"), + + "title" => $this->getPageTitle(), + "name" => explode("\\", $inspector->getExceptionName()), + "message" => $inspector->getExceptionMessage(), + "previousMessages" => $inspector->getPreviousExceptionMessages(), + "docref_url" => $inspector->getExceptionDocrefUrl(), + "code" => $code, + "previousCodes" => $inspector->getPreviousExceptionCodes(), + "plain_exception" => Formatter::formatExceptionPlain($inspector), + "frames" => $frames, + "has_frames" => !!count($frames), + "handler" => $this, + "handlers" => $this->getRun()->getHandlers(), + + "active_frames_tab" => count($frames) && $frames->offsetGet(0)->isApplication() ? 'application' : 'all', + "has_frames_tabs" => $this->getApplicationPaths(), + + "tables" => [ + "GET Data" => $this->masked($_GET, '_GET'), + "POST Data" => $this->masked($_POST, '_POST'), + "Files" => isset($_FILES) ? $this->masked($_FILES, '_FILES') : [], + "Cookies" => $this->masked($_COOKIE, '_COOKIE'), + "Session" => isset($_SESSION) ? $this->masked($_SESSION, '_SESSION') : [], + "Server/Request Data" => $this->masked($_SERVER, '_SERVER'), + "Environment Variables" => $this->masked($_ENV, '_ENV'), + ], + ]; + + if (isset($customCssFile)) { + $vars["stylesheet"] .= file_get_contents($customCssFile); + } + + if (isset($customJsFile)) { + $vars["javascript"] .= file_get_contents($customJsFile); + } + + // Add extra entries list of data tables: + // @todo: Consolidate addDataTable and addDataTableCallback + $extraTables = array_map(function ($table) use ($inspector) { + return $table instanceof \Closure ? $table($inspector) : $table; + }, $this->getDataTables()); + $vars["tables"] = array_merge($extraTables, $vars["tables"]); + + $plainTextHandler = new PlainTextHandler(); + $plainTextHandler->setRun($this->getRun()); + $plainTextHandler->setException($this->getException()); + $plainTextHandler->setInspector($this->getInspector()); + $vars["preface"] = ""; + + $this->templateHelper->setVariables($vars); + $this->templateHelper->render($templateFile); + + return Handler::QUIT; + } + + /** + * Get the stack trace frames of the exception currently being handled. + * + * @return \Whoops\Exception\FrameCollection + */ + protected function getExceptionFrames() + { + $frames = $this->getInspector()->getFrames($this->getRun()->getFrameFilters()); + + if ($this->getApplicationPaths()) { + foreach ($frames as $frame) { + foreach ($this->getApplicationPaths() as $path) { + if (strpos($frame->getFile(), $path) === 0) { + $frame->setApplication(true); + break; + } + } + } + } + + return $frames; + } + + /** + * Get the code of the exception currently being handled. + * + * @return string + */ + protected function getExceptionCode() + { + $exception = $this->getException(); + + $code = $exception->getCode(); + if ($exception instanceof \ErrorException) { + // ErrorExceptions wrap the php-error types within the 'severity' property + $code = Misc::translateErrorCode($exception->getSeverity()); + } + + return (string) $code; + } + + /** + * @return string + */ + public function contentType() + { + return 'text/html'; + } + + /** + * Adds an entry to the list of tables displayed in the template. + * + * The expected data is a simple associative array. Any nested arrays + * will be flattened with `print_r`. + * + * @param string $label + * + * @return static + */ + public function addDataTable($label, array $data) + { + $this->extraTables[$label] = $data; + return $this; + } + + /** + * Lazily adds an entry to the list of tables displayed in the table. + * + * The supplied callback argument will be called when the error is + * rendered, it should produce a simple associative array. Any nested + * arrays will be flattened with `print_r`. + * + * @param string $label + * @param callable $callback Callable returning an associative array + * + * @throws InvalidArgumentException If $callback is not callable + * + * @return static + */ + public function addDataTableCallback($label, /* callable */ $callback) + { + if (!is_callable($callback)) { + throw new InvalidArgumentException('Expecting callback argument to be callable'); + } + + $this->extraTables[$label] = function (?\Whoops\Inspector\InspectorInterface $inspector = null) use ($callback) { + try { + $result = call_user_func($callback, $inspector); + + // Only return the result if it can be iterated over by foreach(). + return is_array($result) || $result instanceof \Traversable ? $result : []; + } catch (\Exception $e) { + // Don't allow failure to break the rendering of the original exception. + return []; + } + }; + + return $this; + } + + /** + * Returns all the extra data tables registered with this handler. + * + * Optionally accepts a 'label' parameter, to only return the data table + * under that label. + * + * @param string|null $label + * + * @return array[]|callable + */ + public function getDataTables($label = null) + { + if ($label !== null) { + return isset($this->extraTables[$label]) ? + $this->extraTables[$label] : []; + } + + return $this->extraTables; + } + + /** + * Set whether to handle unconditionally. + * + * Allows to disable all attempts to dynamically decide whether to handle + * or return prematurely. Set this to ensure that the handler will perform, + * no matter what. + * + * @param bool|null $value + * + * @return bool|static + */ + public function handleUnconditionally($value = null) + { + if (func_num_args() == 0) { + return $this->handleUnconditionally; + } + + $this->handleUnconditionally = (bool) $value; + return $this; + } + + /** + * Adds an editor resolver. + * + * Either a string, or a closure that resolves a string, that can be used + * to open a given file in an editor. If the string contains the special + * substrings %file or %line, they will be replaced with the correct data. + * + * @example + * $run->addEditor('macvim', "mvim://open?url=file://%file&line=%line") + * @example + * $run->addEditor('remove-it', function($file, $line) { + * unlink($file); + * return "http://stackoverflow.com"; + * }); + * + * @param string $identifier + * @param string|callable $resolver + * + * @return static + */ + public function addEditor($identifier, $resolver) + { + $this->editors[$identifier] = $resolver; + return $this; + } + + /** + * Set the editor to use to open referenced files. + * + * Pass either the name of a configured editor, or a closure that directly + * resolves an editor string. + * + * @example + * $run->setEditor(function($file, $line) { return "file:///{$file}"; }); + * @example + * $run->setEditor('sublime'); + * + * @param string|callable $editor + * + * @throws InvalidArgumentException If invalid argument identifier provided + * + * @return static + */ + public function setEditor($editor) + { + if (!is_callable($editor) && !isset($this->editors[$editor])) { + throw new InvalidArgumentException( + "Unknown editor identifier: $editor. Known editors:" . + implode(",", array_keys($this->editors)) + ); + } + + $this->editor = $editor; + return $this; + } + + /** + * Get the editor href for a given file and line, if available. + * + * @param string $filePath + * @param int $line + * + * @throws InvalidArgumentException If editor resolver does not return a string + * + * @return string|bool + */ + public function getEditorHref($filePath, $line) + { + $editor = $this->getEditor($filePath, $line); + + if (empty($editor)) { + return false; + } + + // Check that the editor is a string, and replace the + // %line and %file placeholders: + if (!isset($editor['url']) || !is_string($editor['url'])) { + throw new UnexpectedValueException( + __METHOD__ . " should always resolve to a string or a valid editor array; got something else instead." + ); + } + + $editor['url'] = str_replace("%line", rawurlencode($line), $editor['url']); + $editor['url'] = str_replace("%file", rawurlencode($filePath), $editor['url']); + + return $editor['url']; + } + + /** + * Determine if the editor link should act as an Ajax request. + * + * @param string $filePath + * @param int $line + * + * @throws UnexpectedValueException If editor resolver does not return a boolean + * + * @return bool + */ + public function getEditorAjax($filePath, $line) + { + $editor = $this->getEditor($filePath, $line); + + // Check that the ajax is a bool + if (!isset($editor['ajax']) || !is_bool($editor['ajax'])) { + throw new UnexpectedValueException( + __METHOD__ . " should always resolve to a bool; got something else instead." + ); + } + return $editor['ajax']; + } + + /** + * Determines both the editor and if ajax should be used. + * + * @param string $filePath + * @param int $line + * + * @return array + */ + protected function getEditor($filePath, $line) + { + if (!$this->editor || (!is_string($this->editor) && !is_callable($this->editor))) { + return []; + } + + if (is_string($this->editor) && isset($this->editors[$this->editor]) && !is_callable($this->editors[$this->editor])) { + return [ + 'ajax' => false, + 'url' => $this->editors[$this->editor], + ]; + } + + if (is_callable($this->editor) || (isset($this->editors[$this->editor]) && is_callable($this->editors[$this->editor]))) { + if (is_callable($this->editor)) { + $callback = call_user_func($this->editor, $filePath, $line); + } else { + $callback = call_user_func($this->editors[$this->editor], $filePath, $line); + } + + if (empty($callback)) { + return []; + } + + if (is_string($callback)) { + return [ + 'ajax' => false, + 'url' => $callback, + ]; + } + + return [ + 'ajax' => isset($callback['ajax']) ? $callback['ajax'] : false, + 'url' => isset($callback['url']) ? $callback['url'] : $callback, + ]; + } + + return []; + } + + /** + * Set the page title. + * + * @param string $title + * + * @return static + */ + public function setPageTitle($title) + { + $this->pageTitle = (string) $title; + return $this; + } + + /** + * Get the page title. + * + * @return string + */ + public function getPageTitle() + { + return $this->pageTitle; + } + + /** + * Adds a path to the list of paths to be searched for resources. + * + * @param string $path + * + * @throws InvalidArgumentException If $path is not a valid directory + * + * @return static + */ + public function addResourcePath($path) + { + if (!is_dir($path)) { + throw new InvalidArgumentException( + "'$path' is not a valid directory" + ); + } + + array_unshift($this->searchPaths, $path); + return $this; + } + + /** + * Adds a custom css file to be loaded. + * + * @param string|null $name + * + * @return static + */ + public function addCustomCss($name) + { + $this->customCss = $name; + return $this; + } + + /** + * Adds a custom js file to be loaded. + * + * @param string|null $name + * + * @return static + */ + public function addCustomJs($name) + { + $this->customJs = $name; + return $this; + } + + /** + * @return array + */ + public function getResourcePaths() + { + return $this->searchPaths; + } + + /** + * Finds a resource, by its relative path, in all available search paths. + * + * The search is performed starting at the last search path, and all the + * way back to the first, enabling a cascading-type system of overrides for + * all resources. + * + * @param string $resource + * + * @throws RuntimeException If resource cannot be found in any of the available paths + * + * @return string + */ + protected function getResource($resource) + { + // If the resource was found before, we can speed things up + // by caching its absolute, resolved path: + if (isset($this->resourceCache[$resource])) { + return $this->resourceCache[$resource]; + } + + // Search through available search paths, until we find the + // resource we're after: + foreach ($this->searchPaths as $path) { + $fullPath = $path . "/$resource"; + + if (is_file($fullPath)) { + // Cache the result: + $this->resourceCache[$resource] = $fullPath; + return $fullPath; + } + } + + // If we got this far, nothing was found. + throw new RuntimeException( + "Could not find resource '$resource' in any resource paths." + . "(searched: " . join(", ", $this->searchPaths). ")" + ); + } + + /** + * @deprecated + * + * @return string + */ + public function getResourcesPath() + { + $allPaths = $this->getResourcePaths(); + + // Compat: return only the first path added + return end($allPaths) ?: null; + } + + /** + * @deprecated + * + * @param string $resourcesPath + * + * @return static + */ + public function setResourcesPath($resourcesPath) + { + $this->addResourcePath($resourcesPath); + return $this; + } + + /** + * Return the application paths. + * + * @return array + */ + public function getApplicationPaths() + { + return $this->applicationPaths; + } + + /** + * Set the application paths. + * + * @return void + */ + public function setApplicationPaths(array $applicationPaths) + { + $this->applicationPaths = $applicationPaths; + } + + /** + * Set the application root path. + * + * @param string $applicationRootPath + * + * @return void + */ + public function setApplicationRootPath($applicationRootPath) + { + $this->templateHelper->setApplicationRootPath($applicationRootPath); + } + + /** + * blacklist a sensitive value within one of the superglobal arrays. + * Alias for the hideSuperglobalKey method. + * + * @param string $superGlobalName The name of the superglobal array, e.g. '_GET' + * @param string $key The key within the superglobal + * @see hideSuperglobalKey + * + * @return static + */ + public function blacklist($superGlobalName, $key) + { + $this->blacklist[$superGlobalName][] = $key; + return $this; + } + + /** + * Hide a sensitive value within one of the superglobal arrays. + * + * @param string $superGlobalName The name of the superglobal array, e.g. '_GET' + * @param string $key The key within the superglobal + * @return static + */ + public function hideSuperglobalKey($superGlobalName, $key) + { + return $this->blacklist($superGlobalName, $key); + } + + /** + * Checks all values within the given superGlobal array. + * + * Blacklisted values will be replaced by a equal length string containing + * only '*' characters for string values. + * Non-string values will be replaced with a fixed asterisk count. + * We intentionally dont rely on $GLOBALS as it depends on the 'auto_globals_jit' php.ini setting. + * + * @param array|\ArrayAccess $superGlobal One of the superglobal arrays + * @param string $superGlobalName The name of the superglobal array, e.g. '_GET' + * + * @return array $values without sensitive data + */ + private function masked($superGlobal, $superGlobalName) + { + $blacklisted = $this->blacklist[$superGlobalName]; + + $values = $superGlobal; + + foreach ($blacklisted as $key) { + if (isset($superGlobal[$key])) { + $values[$key] = str_repeat('*', is_string($superGlobal[$key]) ? strlen($superGlobal[$key]) : 3); + } + } + + return $values; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Handler/XmlResponseHandler.php b/public/vendor/filp/whoops/src/Whoops/Handler/XmlResponseHandler.php new file mode 100644 index 0000000..dcfd551 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Handler/XmlResponseHandler.php @@ -0,0 +1,108 @@ + + */ + +namespace Whoops\Handler; + +use SimpleXMLElement; +use Whoops\Exception\Formatter; + +/** + * Catches an exception and converts it to an XML + * response. Additionally can also return exception + * frames for consumption by an API. + */ +class XmlResponseHandler extends Handler +{ + /** + * @var bool + */ + private $returnFrames = false; + + /** + * @param bool|null $returnFrames + * @return bool|static + */ + public function addTraceToOutput($returnFrames = null) + { + if (func_num_args() == 0) { + return $this->returnFrames; + } + + $this->returnFrames = (bool) $returnFrames; + return $this; + } + + /** + * @return int + */ + public function handle() + { + $response = [ + 'error' => Formatter::formatExceptionAsDataArray( + $this->getInspector(), + $this->addTraceToOutput(), + $this->getRun()->getFrameFilters() + ), + ]; + + echo self::toXml($response); + + return Handler::QUIT; + } + + /** + * @return string + */ + public function contentType() + { + return 'application/xml'; + } + + /** + * @param SimpleXMLElement $node Node to append data to, will be modified in place + * @param array|\Traversable $data + * @return SimpleXMLElement The modified node, for chaining + */ + private static function addDataToNode(\SimpleXMLElement $node, $data) + { + assert(is_array($data) || $data instanceof Traversable); + + foreach ($data as $key => $value) { + if (is_numeric($key)) { + // Convert the key to a valid string + $key = "unknownNode_". (string) $key; + } + + // Delete any char not allowed in XML element names + $key = preg_replace('/[^a-z0-9\-\_\.\:]/i', '', $key); + + if (is_array($value)) { + $child = $node->addChild($key); + self::addDataToNode($child, $value); + } else { + $value = str_replace('&', '&', print_r($value, true)); + $node->addChild($key, $value); + } + } + + return $node; + } + + /** + * The main function for converting to an XML document. + * + * @param array|\Traversable $data + * @return string XML + */ + private static function toXml($data) + { + assert(is_array($data) || $data instanceof Traversable); + + $node = simplexml_load_string(""); + + return self::addDataToNode($node, $data)->asXML(); + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorFactory.php b/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorFactory.php new file mode 100644 index 0000000..ee19898 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorFactory.php @@ -0,0 +1,21 @@ + + */ + +namespace Whoops\Inspector; + +use Whoops\Exception\Inspector; + +class InspectorFactory implements InspectorFactoryInterface +{ + /** + * @param \Throwable $exception + * @return InspectorInterface + */ + public function create($exception) + { + return new Inspector($exception, $this); + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorFactoryInterface.php b/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorFactoryInterface.php new file mode 100644 index 0000000..e3907cf --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorFactoryInterface.php @@ -0,0 +1,16 @@ + + */ + +namespace Whoops\Inspector; + +interface InspectorFactoryInterface +{ + /** + * @param \Throwable $exception + * @return InspectorInterface + */ + public function create($exception); +} diff --git a/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorInterface.php b/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorInterface.php new file mode 100644 index 0000000..6893517 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Inspector/InspectorInterface.php @@ -0,0 +1,71 @@ + + */ + +namespace Whoops\Inspector; + +interface InspectorInterface +{ + /** + * @return \Throwable + */ + public function getException(); + + /** + * @return string + */ + public function getExceptionName(); + + /** + * @return string + */ + public function getExceptionMessage(); + + /** + * @return string[] + */ + public function getPreviousExceptionMessages(); + + /** + * @return int[] + */ + public function getPreviousExceptionCodes(); + + /** + * Returns a url to the php-manual related to the underlying error - when available. + * + * @return string|null + */ + public function getExceptionDocrefUrl(); + + /** + * Does the wrapped Exception has a previous Exception? + * @return bool + */ + public function hasPreviousException(); + + /** + * Returns an Inspector for a previous Exception, if any. + * @todo Clean this up a bit, cache stuff a bit better. + * @return InspectorInterface + */ + public function getPreviousExceptionInspector(); + + /** + * Returns an array of all previous exceptions for this inspector's exception + * @return \Throwable[] + */ + public function getPreviousExceptions(); + + /** + * Returns an iterator for the inspected exception's + * frames. + * + * @param array $frameFilters + * + * @return \Whoops\Exception\FrameCollection + */ + public function getFrames(array $frameFilters = []); +} diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/css/prism.css b/public/vendor/filp/whoops/src/Whoops/Resources/css/prism.css new file mode 100644 index 0000000..45ebad5 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/css/prism.css @@ -0,0 +1,5 @@ +/* PrismJS 1.30.0 +https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+markup-templating+php&plugins=line-highlight+line-numbers */ +code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green} +pre[data-line]{position:relative;padding:1em 0 1em 3em}.line-highlight{position:absolute;left:0;right:0;padding:inherit 0;margin-top:1em;background:hsla(24,20%,50%,.08);background:linear-gradient(to right,hsla(24,20%,50%,.1) 70%,hsla(24,20%,50%,0));pointer-events:none;line-height:inherit;white-space:pre}@media print{.line-highlight{-webkit-print-color-adjust:exact;color-adjust:exact}}.line-highlight:before,.line-highlight[data-end]:after{content:attr(data-start);position:absolute;top:.4em;left:.6em;min-width:1em;padding:0 .5em;background-color:hsla(24,20%,50%,.4);color:#f4f1ef;font:bold 65%/1.5 sans-serif;text-align:center;vertical-align:.3em;border-radius:999px;text-shadow:none;box-shadow:0 1px #fff}.line-highlight[data-end]:after{content:attr(data-end);top:auto;bottom:.4em}.line-numbers .line-highlight:after,.line-numbers .line-highlight:before{content:none}pre[id].linkable-line-numbers span.line-numbers-rows{pointer-events:all}pre[id].linkable-line-numbers span.line-numbers-rows>span:before{cursor:pointer}pre[id].linkable-line-numbers span.line-numbers-rows>span:hover:before{background-color:rgba(128,128,128,.2)} +pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right} diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/css/whoops.base.css b/public/vendor/filp/whoops/src/Whoops/Resources/css/whoops.base.css new file mode 100644 index 0000000..9abd15f --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/css/whoops.base.css @@ -0,0 +1,564 @@ +body { + font: 12px "Helvetica Neue", helvetica, arial, sans-serif; + color: #131313; + background: #eeeeee; + padding:0; + margin: 0; + max-height: 100%; + + text-rendering: optimizeLegibility; +} + a { + text-decoration: none; + } + +.Whoops.container { + position: relative; + z-index: 9999999999; +} + +.panel { + overflow-y: scroll; + height: 100%; + position: fixed; + margin: 0; + left: 0; + top: 0; +} + +.branding { + position: absolute; + top: 10px; + right: 20px; + color: #777777; + font-size: 10px; + z-index: 100; +} + .branding a { + color: #e95353; + } + +header { + color: white; + box-sizing: border-box; + background-color: #2a2a2a; + padding: 35px 40px; + max-height: 180px; + overflow: hidden; + transition: 0.5s; +} + + header.header-expand { + max-height: 1000px; + } + + .exc-title { + margin: 0; + color: #bebebe; + font-size: 14px; + } + .exc-title-primary, .exc-title-secondary { + color: #e95353; + } + + .exc-message { + font-size: 20px; + word-wrap: break-word; + margin: 4px 0 0 0; + color: white; + } + .exc-message span { + display: block; + } + .exc-message-empty-notice { + color: #a29d9d; + font-weight: 300; + } + +.prev-exc-title { + margin: 10px 0; +} + +.prev-exc-title + ul { + margin: 0; + padding: 0 0 0 20px; + line-height: 12px; +} + +.prev-exc-title + ul li { + font: 12px "Helvetica Neue", helvetica, arial, sans-serif; +} + +.prev-exc-title + ul li .prev-exc-code { + display: inline-block; + color: #bebebe; +} + +.details-container { + left: 30%; + width: 70%; + background: #fafafa; +} + .details { + padding: 5px; + } + + .details-heading { + color: #4288CE; + font-weight: 300; + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid rgba(0, 0, 0, .1); + } + + .details pre.sf-dump { + white-space: pre; + word-wrap: inherit; + } + + .details pre.sf-dump, + .details pre.sf-dump .sf-dump-num, + .details pre.sf-dump .sf-dump-const, + .details pre.sf-dump .sf-dump-str, + .details pre.sf-dump .sf-dump-note, + .details pre.sf-dump .sf-dump-ref, + .details pre.sf-dump .sf-dump-public, + .details pre.sf-dump .sf-dump-protected, + .details pre.sf-dump .sf-dump-private, + .details pre.sf-dump .sf-dump-meta, + .details pre.sf-dump .sf-dump-key, + .details pre.sf-dump .sf-dump-index { + color: #463C54; + } + +.left-panel { + width: 30%; + background: #ded8d8; +} + + .frames-description { + background: rgba(0, 0, 0, .05); + padding: 8px 15px; + color: #a29d9d; + font-size: 11px; + } + + .frames-description.frames-description-application { + text-align: center; + font-size: 12px; + } + .frames-container.frames-container-application .frame:not(.frame-application) { + display: none; + } + + .frames-tab { + color: #a29d9d; + display: inline-block; + padding: 4px 8px; + margin: 0 2px; + border-radius: 3px; + } + + .frames-tab.frames-tab-active { + background-color: #2a2a2a; + color: #bebebe; + } + + .frame { + padding: 14px; + cursor: pointer; + transition: all 0.1s ease; + background: #eeeeee; + } + .frame:not(:last-child) { + border-bottom: 1px solid rgba(0, 0, 0, .05); + } + + .frame.active { + box-shadow: inset -5px 0 0 0 #4288CE; + color: #4288CE; + } + + .frame:not(.active):hover { + background: #BEE9EA; + } + + .frame-method-info { + margin-bottom: 10px; + } + + .frame-class, .frame-function, .frame-index { + font-size: 14px; + } + + .frame-index { + float: left; + } + + .frame-method-info { + margin-left: 24px; + } + + .frame-index { + font-size: 11px; + color: #a29d9d; + background-color: rgba(0, 0, 0, .05); + height: 18px; + width: 18px; + line-height: 18px; + border-radius: 5px; + padding: 0 1px 0 1px; + text-align: center; + display: inline-block; + } + + .frame-application .frame-index { + background-color: #2a2a2a; + color: #bebebe; + } + + .frame-file { + font-family: "Inconsolata", "Fira Mono", "Source Code Pro", Monaco, Consolas, "Lucida Console", monospace; + color: #a29d9d; + } + + .frame-file .editor-link { + color: #a29d9d; + } + + .frame-line { + font-weight: bold; + } + + .frame-code { + padding: 5px; + background: #303030; + display: none; + } + + .frame-code.active { + display: block; + } + + .frame-code .frame-file { + color: #a29d9d; + padding: 12px 6px; + + border-bottom: none; + } + + .code-block { + padding: 10px; + margin: 0; + border-radius: 6px; + box-shadow: 0 3px 0 rgba(0, 0, 0, .05), + 0 10px 30px rgba(0, 0, 0, .05), + inset 0 0 1px 0 rgba(255, 255, 255, .07); + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + } + + .linenums { + margin: 0; + margin-left: 10px; + } + + .frame-comments { + border-top: none; + margin-top: 15px; + + font-size: 12px; + } + + .frame-comments.empty { + } + + .frame-comments.empty:before { + content: "No comments for this stack frame."; + font-weight: 300; + color: #a29d9d; + } + + .frame-comment { + padding: 10px; + color: #e3e3e3; + border-radius: 6px; + background-color: rgba(255, 255, 255, .05); + } + + .frame-comment a { + font-weight: bold; + text-decoration: underline; + color: #c6c6c6; + } + + .frame-comment:not(:last-child) { + border-bottom: 1px dotted rgba(0, 0, 0, .3); + } + + .frame-comment-context { + font-size: 10px; + color: white; + } + +.delimiter { + display: inline-block; +} + +.data-table-container label { + font-size: 16px; + color: #303030; + font-weight: bold; + margin: 10px 0; + + display: block; + + margin-bottom: 5px; + padding-bottom: 5px; +} + .data-table { + width: 100%; + margin-bottom: 10px; + } + + .data-table tbody { + font: 13px "Inconsolata", "Fira Mono", "Source Code Pro", Monaco, Consolas, "Lucida Console", monospace; + } + + .data-table thead { + display: none; + } + + .data-table tr { + padding: 5px 0; + } + + .data-table td:first-child { + width: 20%; + min-width: 130px; + overflow: hidden; + font-weight: bold; + color: #463C54; + padding-right: 5px; + + } + + .data-table td:last-child { + width: 80%; + -ms-word-break: break-all; + word-break: break-all; + word-break: break-word; + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; + } + + .data-table span.empty { + color: rgba(0, 0, 0, .3); + font-weight: 300; + } + .data-table label.empty { + display: inline; + } + +.handler { + padding: 4px 0; + font: 14px "Inconsolata", "Fira Mono", "Source Code Pro", Monaco, Consolas, "Lucida Console", monospace; +} + +#plain-exception { + display: none; +} + +.rightButton { + cursor: pointer; + border: 0; + opacity: .8; + background: none; + + color: rgba(255, 255, 255, 0.1); + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.1); + + border-radius: 3px; + + outline: none !important; +} + + .rightButton:hover { + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.3); + } + +/* inspired by githubs kbd styles */ +kbd { + -moz-border-bottom-colors: none; + -moz-border-left-colors: none; + -moz-border-right-colors: none; + -moz-border-top-colors: none; + background-color: #fcfcfc; + border-color: #ccc #ccc #bbb; + border-image: none; + border-style: solid; + border-width: 1px; + color: #555; + display: inline-block; + font-size: 11px; + line-height: 10px; + padding: 3px 5px; + vertical-align: middle; +} + + +/* == Media queries */ + +/* Expand the spacing in the details section */ +@media (min-width: 1000px) { + .details, .frame-code { + padding: 20px 40px; + } + + .details-container { + left: 32%; + width: 68%; + } + + .frames-container { + margin: 5px; + } + + .left-panel { + width: 32%; + } +} + +/* Stack panels */ +@media (max-width: 600px) { + .panel { + position: static; + width: 100%; + } +} + +/* Stack details tables */ +@media (max-width: 400px) { + .data-table, + .data-table tbody, + .data-table tbody tr, + .data-table tbody td { + display: block; + width: 100%; + } + + .data-table tbody tr:first-child { + padding-top: 0; + } + + .data-table tbody td:first-child, + .data-table tbody td:last-child { + padding-left: 0; + padding-right: 0; + } + + .data-table tbody td:last-child { + padding-top: 3px; + } +} + +.tooltipped { + position: relative +} +.tooltipped:after { + position: absolute; + z-index: 1000000; + display: none; + padding: 5px 8px; + color: #fff; + text-align: center; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-wrap: break-word; + white-space: pre; + pointer-events: none; + content: attr(aria-label); + background: rgba(0, 0, 0, 0.8); + border-radius: 3px; + -webkit-font-smoothing: subpixel-antialiased +} +.tooltipped:before { + position: absolute; + z-index: 1000001; + display: none; + width: 0; + height: 0; + color: rgba(0, 0, 0, 0.8); + pointer-events: none; + content: ""; + border: 5px solid transparent +} +.tooltipped:hover:before, +.tooltipped:hover:after, +.tooltipped:active:before, +.tooltipped:active:after, +.tooltipped:focus:before, +.tooltipped:focus:after { + display: inline-block; + text-decoration: none +} +.tooltipped-s:after { + top: 100%; + right: 50%; + margin-top: 5px +} +.tooltipped-s:before { + top: auto; + right: 50%; + bottom: -5px; + margin-right: -5px; + border-bottom-color: rgba(0, 0, 0, 0.8) +} + +pre.sf-dump { + padding: 0px !important; + margin: 0px !important; +} + +.search-for-help { + width: 85%; + padding: 0; + margin: 10px 0; + list-style-type: none; + display: inline-block; +} + .search-for-help li { + display: inline-block; + margin-right: 5px; + } + .search-for-help li:last-child { + margin-right: 0; + } + .search-for-help li a { + + } + .search-for-help li a i { + width: 16px; + height: 16px; + overflow: hidden; + display: block; + } + .search-for-help li a svg { + fill: #fff; + } + .search-for-help li a svg path { + background-size: contain; + } + +.line-numbers-rows span { + pointer-events: auto; + cursor: pointer; +} +.line-numbers-rows span:hover { + text-decoration: underline; +} diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/js/clipboard.min.js b/public/vendor/filp/whoops/src/Whoops/Resources/js/clipboard.min.js new file mode 100644 index 0000000..1103f81 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/js/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return b}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),r=n.n(e);function c(t){try{return document.execCommand(t)}catch(t){return}}var a=function(t){t=r()(t);return c("cut"),t};function o(t,e){var n,o,t=(n=t,o="rtl"===document.documentElement.getAttribute("dir"),(t=document.createElement("textarea")).style.fontSize="12pt",t.style.border="0",t.style.padding="0",t.style.margin="0",t.style.position="absolute",t.style[o?"right":"left"]="-9999px",o=window.pageYOffset||document.documentElement.scrollTop,t.style.top="".concat(o,"px"),t.setAttribute("readonly",""),t.value=n,t);return e.container.appendChild(t),e=r()(t),c("copy"),t.remove(),e}var f=function(t){var e=1=g.reach);A+=w.value.length,w=w.next){var P=w.value;if(n.length>e.length)return;if(!(P instanceof i)){var E,S=1;if(y){if(!(E=l(b,A,e,m))||E.index>=e.length)break;var L=E.index,O=E.index+E[0].length,C=A;for(C+=w.value.length;L>=C;)C+=(w=w.next).value.length;if(A=C-=w.value.length,w.value instanceof i)continue;for(var j=w;j!==n.tail&&(Cg.reach&&(g.reach=W);var I=w.prev;if(_&&(I=u(n,I,_),A+=_.length),c(n,I,S),w=u(n,I,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),S>1){var T={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,T),g&&T.reach>g.reach&&(g.reach=T.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); +Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; +!function(e){function n(e,n){return"___"+e.toUpperCase()+n+"___"}Object.defineProperties(e.languages["markup-templating"]={},{buildPlaceholders:{value:function(t,a,r,o){if(t.language===a){var c=t.tokenStack=[];t.code=t.code.replace(r,(function(e){if("function"==typeof o&&!o(e))return e;for(var r,i=c.length;-1!==t.code.indexOf(r=n(a,i));)++i;return c[i]=e,r})),t.grammar=e.languages.markup}}},tokenizePlaceholders:{value:function(t,a){if(t.language===a&&t.tokenStack){t.grammar=e.languages[a];var r=0,o=Object.keys(t.tokenStack);!function c(i){for(var u=0;u=o.length);u++){var g=i[u];if("string"==typeof g||g.content&&"string"==typeof g.content){var l=o[r],s=t.tokenStack[l],f="string"==typeof g?g:g.content,p=n(a,l),k=f.indexOf(p);if(k>-1){++r;var m=f.substring(0,k),d=new e.Token(a,e.tokenize(s,t.grammar),"language-"+a,s),h=f.substring(k+p.length),v=[];m&&v.push.apply(v,c([m])),v.push(d),h&&v.push.apply(v,c([h])),"string"==typeof g?i.splice.apply(i,[u,1].concat(v)):g.content=v}}else g.content&&c(g.content)}return i}(t.tokens)}}}})}(Prism); +!function(e){var a=/\/\*[\s\S]*?\*\/|\/\/.*|#(?!\[).*/,t=[{pattern:/\b(?:false|true)\b/i,alias:"boolean"},{pattern:/(::\s*)\b[a-z_]\w*\b(?!\s*\()/i,greedy:!0,lookbehind:!0},{pattern:/(\b(?:case|const)\s+)\b[a-z_]\w*(?=\s*[;=])/i,greedy:!0,lookbehind:!0},/\b(?:null)\b/i,/\b[A-Z_][A-Z0-9_]*\b(?!\s*\()/],i=/\b0b[01]+(?:_[01]+)*\b|\b0o[0-7]+(?:_[0-7]+)*\b|\b0x[\da-f]+(?:_[\da-f]+)*\b|(?:\b\d+(?:_\d+)*\.?(?:\d+(?:_\d+)*)?|\B\.\d+)(?:e[+-]?\d+)?/i,n=/|\?\?=?|\.{3}|\??->|[!=]=?=?|::|\*\*=?|--|\+\+|&&|\|\||<<|>>|[?~]|[/^|%*&<>.+-]=?/,s=/[{}\[\](),:;]/;e.languages.php={delimiter:{pattern:/\?>$|^<\?(?:php(?=\s)|=)?/i,alias:"important"},comment:a,variable:/\$+(?:\w+\b|(?=\{))/,package:{pattern:/(namespace\s+|use\s+(?:function\s+)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,lookbehind:!0,inside:{punctuation:/\\/}},"class-name-definition":{pattern:/(\b(?:class|enum|interface|trait)\s+)\b[a-z_]\w*(?!\\)\b/i,lookbehind:!0,alias:"class-name"},"function-definition":{pattern:/(\bfunction\s+)[a-z_]\w*(?=\s*\()/i,lookbehind:!0,alias:"function"},keyword:[{pattern:/(\(\s*)\b(?:array|bool|boolean|float|int|integer|object|string)\b(?=\s*\))/i,alias:"type-casting",greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|object|self|static|string)\b(?=\s*\$)/i,alias:"type-hint",greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b(?:array(?!\s*\()|bool|callable|(?:false|null)(?=\s*\|)|float|int|iterable|mixed|never|object|self|static|string|void)\b/i,alias:"return-type",greedy:!0,lookbehind:!0},{pattern:/\b(?:array(?!\s*\()|bool|float|int|iterable|mixed|object|string|void)\b/i,alias:"type-declaration",greedy:!0},{pattern:/(\|\s*)(?:false|null)\b|\b(?:false|null)(?=\s*\|)/i,alias:"type-declaration",greedy:!0,lookbehind:!0},{pattern:/\b(?:parent|self|static)(?=\s*::)/i,alias:"static-context",greedy:!0},{pattern:/(\byield\s+)from\b/i,lookbehind:!0},/\bclass\b/i,{pattern:/((?:^|[^\s>:]|(?:^|[^-])>|(?:^|[^:]):)\s*)\b(?:abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|enum|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|never|new|or|parent|print|private|protected|public|readonly|require|require_once|return|self|static|switch|throw|trait|try|unset|use|var|while|xor|yield|__halt_compiler)\b/i,lookbehind:!0}],"argument-name":{pattern:/([(,]\s*)\b[a-z_]\w*(?=\s*:(?!:))/i,lookbehind:!0},"class-name":[{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self|\s+static))\s+|\bcatch\s*\()\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/(\|\s*)\b[a-z_]\w*(?!\\)\b/i,greedy:!0,lookbehind:!0},{pattern:/\b[a-z_]\w*(?!\\)\b(?=\s*\|)/i,greedy:!0},{pattern:/(\|\s*)(?:\\?\b[a-z_]\w*)+\b/i,alias:"class-name-fully-qualified",greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(?:\\?\b[a-z_]\w*)+\b(?=\s*\|)/i,alias:"class-name-fully-qualified",greedy:!0,inside:{punctuation:/\\/}},{pattern:/(\b(?:extends|implements|instanceof|new(?!\s+self\b|\s+static\b))\s+|\bcatch\s*\()(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:"class-name-fully-qualified",greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*\$)/i,alias:"type-declaration",greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:["class-name-fully-qualified","type-declaration"],greedy:!0,inside:{punctuation:/\\/}},{pattern:/\b[a-z_]\w*(?=\s*::)/i,alias:"static-context",greedy:!0},{pattern:/(?:\\?\b[a-z_]\w*)+(?=\s*::)/i,alias:["class-name-fully-qualified","static-context"],greedy:!0,inside:{punctuation:/\\/}},{pattern:/([(,?]\s*)[a-z_]\w*(?=\s*\$)/i,alias:"type-hint",greedy:!0,lookbehind:!0},{pattern:/([(,?]\s*)(?:\\?\b[a-z_]\w*)+(?=\s*\$)/i,alias:["class-name-fully-qualified","type-hint"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}},{pattern:/(\)\s*:\s*(?:\?\s*)?)\b[a-z_]\w*(?!\\)\b/i,alias:"return-type",greedy:!0,lookbehind:!0},{pattern:/(\)\s*:\s*(?:\?\s*)?)(?:\\?\b[a-z_]\w*)+\b(?!\\)/i,alias:["class-name-fully-qualified","return-type"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:t,function:{pattern:/(^|[^\\\w])\\?[a-z_](?:[\w\\]*\w)?(?=\s*\()/i,lookbehind:!0,inside:{punctuation:/\\/}},property:{pattern:/(->\s*)\w+/,lookbehind:!0},number:i,operator:n,punctuation:s};var l={pattern:/\{\$(?:\{(?:\{[^{}]+\}|[^{}]+)\}|[^{}])+\}|(^|[^\\{])\$+(?:\w+(?:\[[^\r\n\[\]]+\]|->\w+)?)/,lookbehind:!0,inside:e.languages.php},r=[{pattern:/<<<'([^']+)'[\r\n](?:.*[\r\n])*?\1;/,alias:"nowdoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<<'[^']+'|[a-z_]\w*;$/i,alias:"symbol",inside:{punctuation:/^<<<'?|[';]$/}}}},{pattern:/<<<(?:"([^"]+)"[\r\n](?:.*[\r\n])*?\1;|([a-z_]\w*)[\r\n](?:.*[\r\n])*?\2;)/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<<(?:"[^"]+"|[a-z_]\w*)|[a-z_]\w*;$/i,alias:"symbol",inside:{punctuation:/^<<<"?|[";]$/}},interpolation:l}},{pattern:/`(?:\\[\s\S]|[^\\`])*`/,alias:"backtick-quoted-string",greedy:!0},{pattern:/'(?:\\[\s\S]|[^\\'])*'/,alias:"single-quoted-string",greedy:!0},{pattern:/"(?:\\[\s\S]|[^\\"])*"/,alias:"double-quoted-string",greedy:!0,inside:{interpolation:l}}];e.languages.insertBefore("php","variable",{string:r,attribute:{pattern:/#\[(?:[^"'\/#]|\/(?![*/])|\/\/.*$|#(?!\[).*$|\/\*(?:[^*]|\*(?!\/))*\*\/|"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*')+\](?=\s*[a-z$#])/im,greedy:!0,inside:{"attribute-content":{pattern:/^(#\[)[\s\S]+(?=\]$)/,lookbehind:!0,inside:{comment:a,string:r,"attribute-class-name":[{pattern:/([^:]|^)\b[a-z_]\w*(?!\\)\b/i,alias:"class-name",greedy:!0,lookbehind:!0},{pattern:/([^:]|^)(?:\\?\b[a-z_]\w*)+/i,alias:["class-name","class-name-fully-qualified"],greedy:!0,lookbehind:!0,inside:{punctuation:/\\/}}],constant:t,number:i,operator:n,punctuation:s}},delimiter:{pattern:/^#\[|\]$/,alias:"punctuation"}}}}),e.hooks.add("before-tokenize",(function(a){/<\?/.test(a.code)&&e.languages["markup-templating"].buildPlaceholders(a,"php",/<\?(?:[^"'/#]|\/(?![*/])|("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|(?:\/\/|#(?!\[))(?:[^?\n\r]|\?(?!>))*(?=$|\?>|[\r\n])|#\[|\/\*(?:[^*]|\*(?!\/))*(?:\*\/|$))*?(?:\?>|$)/g)})),e.hooks.add("after-tokenize",(function(a){e.languages["markup-templating"].tokenizePlaceholders(a,"php")}))}(Prism); +!function(){if("undefined"!=typeof Prism&&"undefined"!=typeof document&&document.querySelector){var e,t="line-numbers",i="linkable-line-numbers",n=/\n(?!$)/g,r=!0;Prism.plugins.lineHighlight={highlightLines:function(o,u,c){var h=(u="string"==typeof u?u:o.getAttribute("data-line")||"").replace(/\s+/g,"").split(",").filter(Boolean),d=+o.getAttribute("data-line-offset")||0,f=(function(){if(void 0===e){var t=document.createElement("div");t.style.fontSize="13px",t.style.lineHeight="1.5",t.style.padding="0",t.style.border="0",t.innerHTML=" 
     ",document.body.appendChild(t),e=38===t.offsetHeight,document.body.removeChild(t)}return e}()?parseInt:parseFloat)(getComputedStyle(o).lineHeight),p=Prism.util.isActive(o,t),g=o.querySelector("code"),m=p?o:g||o,v=[],y=g.textContent.match(n),b=y?y.length+1:1,A=g&&m!=g?function(e,t){var i=getComputedStyle(e),n=getComputedStyle(t);function r(e){return+e.substr(0,e.length-2)}return t.offsetTop+r(n.borderTopWidth)+r(n.paddingTop)-r(i.paddingTop)}(o,g):0;h.forEach((function(e){var t=e.split("-"),i=+t[0],n=+t[1]||i;if(!((n=Math.min(b+d,n))i&&r.setAttribute("data-end",String(n)),r.style.top=(i-d-1)*f+A+"px",r.textContent=new Array(n-i+2).join(" \n")}));v.push((function(){r.style.width=o.scrollWidth+"px"})),v.push((function(){m.appendChild(r)}))}}));var P=o.id;if(p&&Prism.util.isActive(o,i)&&P){l(o,i)||v.push((function(){o.classList.add(i)}));var E=parseInt(o.getAttribute("data-start")||"1");s(".line-numbers-rows > span",o).forEach((function(e,t){var i=t+E;e.onclick=function(){var e=P+"."+i;r=!1,location.hash=e,setTimeout((function(){r=!0}),1)}}))}return function(){v.forEach(a)}}};var o=0;Prism.hooks.add("before-sanity-check",(function(e){var t=e.element.parentElement;if(u(t)){var i=0;s(".line-highlight",t).forEach((function(e){i+=e.textContent.length,e.parentNode.removeChild(e)})),i&&/^(?: \n)+$/.test(e.code.slice(-i))&&(e.code=e.code.slice(0,-i))}})),Prism.hooks.add("complete",(function e(i){var n=i.element.parentElement;if(u(n)){clearTimeout(o);var r=Prism.plugins.lineNumbers,s=i.plugins&&i.plugins.lineNumbers;l(n,t)&&r&&!s?Prism.hooks.add("line-numbers",e):(Prism.plugins.lineHighlight.highlightLines(n)(),o=setTimeout(c,1))}})),window.addEventListener("hashchange",c),window.addEventListener("resize",(function(){s("pre").filter(u).map((function(e){return Prism.plugins.lineHighlight.highlightLines(e)})).forEach(a)}))}function s(e,t){return Array.prototype.slice.call((t||document).querySelectorAll(e))}function l(e,t){return e.classList.contains(t)}function a(e){e()}function u(e){return!!(e&&/pre/i.test(e.nodeName)&&(e.hasAttribute("data-line")||e.id&&Prism.util.isActive(e,i)))}function c(){var e=location.hash.slice(1);s(".temporary.line-highlight").forEach((function(e){e.parentNode.removeChild(e)}));var t=(e.match(/\.([\d,-]+)$/)||[,""])[1];if(t&&!document.getElementById(e)){var i=e.slice(0,e.lastIndexOf(".")),n=document.getElementById(i);n&&(n.hasAttribute("data-line")||n.setAttribute("data-line",""),Prism.plugins.lineHighlight.highlightLines(n,t,"temporary ")(),r&&document.querySelector(".temporary.line-highlight").scrollIntoView())}}}(); +!function(){if("undefined"!=typeof Prism&&"undefined"!=typeof document){var e="line-numbers",n=/\n(?!$)/g,t=Prism.plugins.lineNumbers={getLine:function(n,t){if("PRE"===n.tagName&&n.classList.contains(e)){var i=n.querySelector(".line-numbers-rows");if(i){var r=parseInt(n.getAttribute("data-start"),10)||1,s=r+(i.children.length-1);ts&&(t=s);var l=t-r;return i.children[l]}}},resize:function(e){r([e])},assumeViewportIndependence:!0},i=void 0;window.addEventListener("resize",(function(){t.assumeViewportIndependence&&i===window.innerWidth||(i=window.innerWidth,r(Array.prototype.slice.call(document.querySelectorAll("pre.line-numbers"))))})),Prism.hooks.add("complete",(function(t){if(t.code){var i=t.element,s=i.parentNode;if(s&&/pre/i.test(s.nodeName)&&!i.querySelector(".line-numbers-rows")&&Prism.util.isActive(i,e)){i.classList.remove(e),s.classList.add(e);var l,o=t.code.match(n),a=o?o.length+1:1,u=new Array(a+1).join("");(l=document.createElement("span")).setAttribute("aria-hidden","true"),l.className="line-numbers-rows",l.innerHTML=u,s.hasAttribute("data-start")&&(s.style.counterReset="linenumber "+(parseInt(s.getAttribute("data-start"),10)-1)),t.element.appendChild(l),r([s]),Prism.hooks.run("line-numbers",t)}}})),Prism.hooks.add("line-numbers",(function(e){e.plugins=e.plugins||{},e.plugins.lineNumbers=!0}))}function r(e){if(0!=(e=e.filter((function(e){var n,t=(n=e,n?window.getComputedStyle?getComputedStyle(n):n.currentStyle||null:null)["white-space"];return"pre-wrap"===t||"pre-line"===t}))).length){var t=e.map((function(e){var t=e.querySelector("code"),i=e.querySelector(".line-numbers-rows");if(t&&i){var r=e.querySelector(".line-numbers-sizer"),s=t.textContent.split(n);r||((r=document.createElement("span")).className="line-numbers-sizer",t.appendChild(r)),r.innerHTML="0",r.style.display="block";var l=r.getBoundingClientRect().height;return r.innerHTML="",{element:e,lines:s,lineHeights:[],oneLinerHeight:l,sizer:r}}})).filter(Boolean);t.forEach((function(e){var n=e.sizer,t=e.lines,i=e.lineHeights,r=e.oneLinerHeight;i[t.length-1]=void 0,t.forEach((function(e,t){if(e&&e.length>1){var s=n.appendChild(document.createElement("span"));s.style.display="block",s.textContent=e}else i[t]=r}))})),t.forEach((function(e){for(var n=e.sizer,t=e.lineHeights,i=0,r=0;r= 145) { + $header.addClass('header-expand'); + } + }); + $header.on('mouseleave', function () { + $header.removeClass('header-expand'); + }); + + /* + * add prettyprint classes to our current active codeblock + * run prettyPrint() to highlight the active code + * scroll to the line when prettyprint is done + * highlight the current line + */ + var renderCurrentCodeblock = function(id) { + Prism.highlightAllUnder(document.querySelector('.frame-code-container .frame-code.active')); + highlightCurrentLine(); + } + + /* + * Highlight the active and neighboring lines for the current frame + * Adjust the offset to make sure that line is veritcally centered + */ + + var highlightCurrentLine = function() { + // We show more code than needed, purely for proper syntax highlighting + // Let’s hide a big chunk of that code and then scroll the remaining block + $activeFrame.find('.code-block').first().css({ + maxHeight: 345, + overflow: 'hidden', + }); + + var line = $activeFrame.find('.code-block .line-highlight').first()[0]; + // [internal] frames might not contain a code-block + if (line) { + line.scrollIntoView(); + line.parentElement.scrollTop -= 180; + } + + $container.scrollTop(0); + } + + /* + * click handler for loading codeblocks + */ + + $frameContainer.on('click', '.frame', function() { + + var $this = $(this); + var id = /frame\-line\-([\d]*)/.exec($this.attr('id'))[1]; + var $codeFrame = $('#frame-code-' + id); + + if ($codeFrame) { + + $activeLine.removeClass('active'); + $activeFrame.removeClass('active'); + + $this.addClass('active'); + $codeFrame.addClass('active'); + + $activeLine = $this; + $activeFrame = $codeFrame; + + renderCurrentCodeblock(id); + + } + + }); + + var clipboard = new ClipboardJS('.clipboard'); + var showTooltip = function(elem, msg) { + elem.classList.add('tooltipped', 'tooltipped-s'); + elem.setAttribute('aria-label', msg); + }; + + clipboard.on('success', function(e) { + e.clearSelection(); + + showTooltip(e.trigger, 'Copied!'); + }); + + clipboard.on('error', function(e) { + showTooltip(e.trigger, fallbackMessage(e.action)); + }); + + var btn = document.querySelector('.clipboard'); + + btn.addEventListener('mouseleave', function(e) { + e.currentTarget.classList.remove('tooltipped', 'tooltipped-s'); + e.currentTarget.removeAttribute('aria-label'); + }); + + function fallbackMessage(action) { + var actionMsg = ''; + var actionKey = (action === 'cut' ? 'X' : 'C'); + + if (/Mac/i.test(navigator.userAgent)) { + actionMsg = 'Press ⌘-' + actionKey + ' to ' + action; + } else { + actionMsg = 'Press Ctrl-' + actionKey + ' to ' + action; + } + + return actionMsg; + } + + function scrollIntoView($node, $parent) { + var nodeOffset = $node.offset(); + var nodeTop = nodeOffset.top; + var nodeBottom = nodeTop + nodeOffset.height; + var parentScrollTop = $parent.scrollTop(); + var parentHeight = $parent.height(); + + if (nodeTop < 0) { + $parent.scrollTop(parentScrollTop + nodeTop); + } else if (nodeBottom > parentHeight) { + $parent.scrollTop(parentScrollTop + nodeBottom - parentHeight); + } + } + + $(document).on('keydown', function(e) { + var applicationFrames = $frameContainer.hasClass('frames-container-application'), + frameClass = applicationFrames ? '.frame.frame-application' : '.frame'; + + if(e.ctrlKey || e.which === 74 || e.which === 75) { + // CTRL+Arrow-UP/k and Arrow-Down/j support: + // 1) select the next/prev element + // 2) make sure the newly selected element is within the view-scope + // 3) focus the (right) container, so arrow-up/down (without ctrl) scroll the details + if (e.which === 38 /* arrow up */ || e.which === 75 /* k */) { + $activeLine.prev(frameClass).click(); + scrollIntoView($activeLine, $leftPanel); + $container.focus(); + e.preventDefault(); + } else if (e.which === 40 /* arrow down */ || e.which === 74 /* j */) { + $activeLine.next(frameClass).click(); + scrollIntoView($activeLine, $leftPanel); + $container.focus(); + e.preventDefault(); + } + } else if (e.which == 78 /* n */) { + if ($appFramesTab.length) { + setActiveFramesTab($('.frames-tab:not(.frames-tab-active)')); + } + } + }); + + // Avoid to quit the page with some protocol (e.g. IntelliJ Platform REST API) + $ajaxEditors.on('click', function(e){ + e.preventDefault(); + $.get(this.href); + }); + + // Symfony VarDumper: Close the by default expanded objects + $('.sf-dump-expanded') + .removeClass('sf-dump-expanded') + .addClass('sf-dump-compact'); + $('.sf-dump-toggle span').html('▶'); + + // Make the given frames-tab active + function setActiveFramesTab($tab) { + $tab.addClass('frames-tab-active'); + + if ($tab.attr('id') == 'application-frames-tab') { + $frameContainer.addClass('frames-container-application'); + $allFramesTab.removeClass('frames-tab-active'); + } else { + $frameContainer.removeClass('frames-container-application'); + $appFramesTab.removeClass('frames-tab-active'); + } + } + + $('a.frames-tab').on('click', function(e) { + e.preventDefault(); + setActiveFramesTab($(this)); + }); + + // Open editor from code block rows number + $(document).delegate('.line-numbers-rows > span', 'click', function(e) { + var linkTag = $(this).closest('.frame-code').find('.editor-link'); + if (!linkTag) return; + var editorUrl = linkTag.attr('href'); + var requiresAjax = linkTag.data('ajax'); + + var lineOffset = $(this).closest('[data-line-offset]').data('line-offset'); + var lineNumber = lineOffset + $(this).index(); + + var realLine = $(this).closest('[data-line]').data('line'); + if (!realLine) return; + var fileUrl = editorUrl.replace( + new RegExp('([:=])' + realLine), + '$1' + lineNumber + ); + + if (requiresAjax) { + $.get(fileUrl); + } else { + $('
    ').attr('href', fileUrl).trigger('click'); + } + }); + + // Render late enough for highlightCurrentLine to be ready + renderCurrentCodeblock(); +}); diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/js/zepto.min.js b/public/vendor/filp/whoops/src/Whoops/Resources/js/zepto.min.js new file mode 100644 index 0000000..4821a1c --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/js/zepto.min.js @@ -0,0 +1,2 @@ +/* Zepto v1.2.0 - zepto event ajax form ie - zeptojs.com/license */ +!function(t,e){"function"==typeof define&&define.amd?define(function(){return e(t)}):e(t)}(this,function(t){var e=function(){function $(t){return null==t?String(t):S[C.call(t)]||"object"}function F(t){return"function"==$(t)}function k(t){return null!=t&&t==t.window}function M(t){return null!=t&&t.nodeType==t.DOCUMENT_NODE}function R(t){return"object"==$(t)}function Z(t){return R(t)&&!k(t)&&Object.getPrototypeOf(t)==Object.prototype}function z(t){var e=!!t&&"length"in t&&t.length,n=r.type(t);return"function"!=n&&!k(t)&&("array"==n||0===e||"number"==typeof e&&e>0&&e-1 in t)}function q(t){return a.call(t,function(t){return null!=t})}function H(t){return t.length>0?r.fn.concat.apply([],t):t}function I(t){return t.replace(/::/g,"/").replace(/([A-Z]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").replace(/_/g,"-").toLowerCase()}function V(t){return t in l?l[t]:l[t]=new RegExp("(^|\\s)"+t+"(\\s|$)")}function _(t,e){return"number"!=typeof e||h[I(t)]?e:e+"px"}function B(t){var e,n;return c[t]||(e=f.createElement(t),f.body.appendChild(e),n=getComputedStyle(e,"").getPropertyValue("display"),e.parentNode.removeChild(e),"none"==n&&(n="block"),c[t]=n),c[t]}function U(t){return"children"in t?u.call(t.children):r.map(t.childNodes,function(t){return 1==t.nodeType?t:void 0})}function X(t,e){var n,r=t?t.length:0;for(n=0;r>n;n++)this[n]=t[n];this.length=r,this.selector=e||""}function J(t,r,i){for(n in r)i&&(Z(r[n])||L(r[n]))?(Z(r[n])&&!Z(t[n])&&(t[n]={}),L(r[n])&&!L(t[n])&&(t[n]=[]),J(t[n],r[n],i)):r[n]!==e&&(t[n]=r[n])}function W(t,e){return null==e?r(t):r(t).filter(e)}function Y(t,e,n,r){return F(e)?e.call(t,n,r):e}function G(t,e,n){null==n?t.removeAttribute(e):t.setAttribute(e,n)}function K(t,n){var r=t.className||"",i=r&&r.baseVal!==e;return n===e?i?r.baseVal:r:void(i?r.baseVal=n:t.className=n)}function Q(t){try{return t?"true"==t||("false"==t?!1:"null"==t?null:+t+""==t?+t:/^[\[\{]/.test(t)?r.parseJSON(t):t):t}catch(e){return t}}function tt(t,e){e(t);for(var n=0,r=t.childNodes.length;r>n;n++)tt(t.childNodes[n],e)}var e,n,r,i,O,P,o=[],s=o.concat,a=o.filter,u=o.slice,f=t.document,c={},l={},h={"column-count":1,columns:1,"font-weight":1,"line-height":1,opacity:1,"z-index":1,zoom:1},p=/^\s*<(\w+|!)[^>]*>/,d=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,m=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,g=/^(?:body|html)$/i,v=/([A-Z])/g,y=["val","css","html","text","data","width","height","offset"],x=["after","prepend","before","append"],b=f.createElement("table"),E=f.createElement("tr"),j={tr:f.createElement("tbody"),tbody:b,thead:b,tfoot:b,td:E,th:E,"*":f.createElement("div")},w=/complete|loaded|interactive/,T=/^[\w-]*$/,S={},C=S.toString,N={},A=f.createElement("div"),D={tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},L=Array.isArray||function(t){return t instanceof Array};return N.matches=function(t,e){if(!e||!t||1!==t.nodeType)return!1;var n=t.matches||t.webkitMatchesSelector||t.mozMatchesSelector||t.oMatchesSelector||t.matchesSelector;if(n)return n.call(t,e);var r,i=t.parentNode,o=!i;return o&&(i=A).appendChild(t),r=~N.qsa(i,e).indexOf(t),o&&A.removeChild(t),r},O=function(t){return t.replace(/-+(.)?/g,function(t,e){return e?e.toUpperCase():""})},P=function(t){return a.call(t,function(e,n){return t.indexOf(e)==n})},N.fragment=function(t,n,i){var o,s,a;return d.test(t)&&(o=r(f.createElement(RegExp.$1))),o||(t.replace&&(t=t.replace(m,"<$1>")),n===e&&(n=p.test(t)&&RegExp.$1),n in j||(n="*"),a=j[n],a.innerHTML=""+t,o=r.each(u.call(a.childNodes),function(){a.removeChild(this)})),Z(i)&&(s=r(o),r.each(i,function(t,e){y.indexOf(t)>-1?s[t](e):s.attr(t,e)})),o},N.Z=function(t,e){return new X(t,e)},N.isZ=function(t){return t instanceof N.Z},N.init=function(t,n){var i;if(!t)return N.Z();if("string"==typeof t)if(t=t.trim(),"<"==t[0]&&p.test(t))i=N.fragment(t,RegExp.$1,n),t=null;else{if(n!==e)return r(n).find(t);i=N.qsa(f,t)}else{if(F(t))return r(f).ready(t);if(N.isZ(t))return t;if(L(t))i=q(t);else if(R(t))i=[t],t=null;else if(p.test(t))i=N.fragment(t.trim(),RegExp.$1,n),t=null;else{if(n!==e)return r(n).find(t);i=N.qsa(f,t)}}return N.Z(i,t)},r=function(t,e){return N.init(t,e)},r.extend=function(t){var e,n=u.call(arguments,1);return"boolean"==typeof t&&(e=t,t=n.shift()),n.forEach(function(n){J(t,n,e)}),t},N.qsa=function(t,e){var n,r="#"==e[0],i=!r&&"."==e[0],o=r||i?e.slice(1):e,s=T.test(o);return t.getElementById&&s&&r?(n=t.getElementById(o))?[n]:[]:1!==t.nodeType&&9!==t.nodeType&&11!==t.nodeType?[]:u.call(s&&!r&&t.getElementsByClassName?i?t.getElementsByClassName(o):t.getElementsByTagName(e):t.querySelectorAll(e))},r.contains=f.documentElement.contains?function(t,e){return t!==e&&t.contains(e)}:function(t,e){for(;e&&(e=e.parentNode);)if(e===t)return!0;return!1},r.type=$,r.isFunction=F,r.isWindow=k,r.isArray=L,r.isPlainObject=Z,r.isEmptyObject=function(t){var e;for(e in t)return!1;return!0},r.isNumeric=function(t){var e=Number(t),n=typeof t;return null!=t&&"boolean"!=n&&("string"!=n||t.length)&&!isNaN(e)&&isFinite(e)||!1},r.inArray=function(t,e,n){return o.indexOf.call(e,t,n)},r.camelCase=O,r.trim=function(t){return null==t?"":String.prototype.trim.call(t)},r.uuid=0,r.support={},r.expr={},r.noop=function(){},r.map=function(t,e){var n,i,o,r=[];if(z(t))for(i=0;i=0?t:t+this.length]},toArray:function(){return this.get()},size:function(){return this.length},remove:function(){return this.each(function(){null!=this.parentNode&&this.parentNode.removeChild(this)})},each:function(t){return o.every.call(this,function(e,n){return t.call(e,n,e)!==!1}),this},filter:function(t){return F(t)?this.not(this.not(t)):r(a.call(this,function(e){return N.matches(e,t)}))},add:function(t,e){return r(P(this.concat(r(t,e))))},is:function(t){return this.length>0&&N.matches(this[0],t)},not:function(t){var n=[];if(F(t)&&t.call!==e)this.each(function(e){t.call(this,e)||n.push(this)});else{var i="string"==typeof t?this.filter(t):z(t)&&F(t.item)?u.call(t):r(t);this.forEach(function(t){i.indexOf(t)<0&&n.push(t)})}return r(n)},has:function(t){return this.filter(function(){return R(t)?r.contains(this,t):r(this).find(t).size()})},eq:function(t){return-1===t?this.slice(t):this.slice(t,+t+1)},first:function(){var t=this[0];return t&&!R(t)?t:r(t)},last:function(){var t=this[this.length-1];return t&&!R(t)?t:r(t)},find:function(t){var e,n=this;return e=t?"object"==typeof t?r(t).filter(function(){var t=this;return o.some.call(n,function(e){return r.contains(e,t)})}):1==this.length?r(N.qsa(this[0],t)):this.map(function(){return N.qsa(this,t)}):r()},closest:function(t,e){var n=[],i="object"==typeof t&&r(t);return this.each(function(r,o){for(;o&&!(i?i.indexOf(o)>=0:N.matches(o,t));)o=o!==e&&!M(o)&&o.parentNode;o&&n.indexOf(o)<0&&n.push(o)}),r(n)},parents:function(t){for(var e=[],n=this;n.length>0;)n=r.map(n,function(t){return(t=t.parentNode)&&!M(t)&&e.indexOf(t)<0?(e.push(t),t):void 0});return W(e,t)},parent:function(t){return W(P(this.pluck("parentNode")),t)},children:function(t){return W(this.map(function(){return U(this)}),t)},contents:function(){return this.map(function(){return this.contentDocument||u.call(this.childNodes)})},siblings:function(t){return W(this.map(function(t,e){return a.call(U(e.parentNode),function(t){return t!==e})}),t)},empty:function(){return this.each(function(){this.innerHTML=""})},pluck:function(t){return r.map(this,function(e){return e[t]})},show:function(){return this.each(function(){"none"==this.style.display&&(this.style.display=""),"none"==getComputedStyle(this,"").getPropertyValue("display")&&(this.style.display=B(this.nodeName))})},replaceWith:function(t){return this.before(t).remove()},wrap:function(t){var e=F(t);if(this[0]&&!e)var n=r(t).get(0),i=n.parentNode||this.length>1;return this.each(function(o){r(this).wrapAll(e?t.call(this,o):i?n.cloneNode(!0):n)})},wrapAll:function(t){if(this[0]){r(this[0]).before(t=r(t));for(var e;(e=t.children()).length;)t=e.first();r(t).append(this)}return this},wrapInner:function(t){var e=F(t);return this.each(function(n){var i=r(this),o=i.contents(),s=e?t.call(this,n):t;o.length?o.wrapAll(s):i.append(s)})},unwrap:function(){return this.parent().each(function(){r(this).replaceWith(r(this).children())}),this},clone:function(){return this.map(function(){return this.cloneNode(!0)})},hide:function(){return this.css("display","none")},toggle:function(t){return this.each(function(){var n=r(this);(t===e?"none"==n.css("display"):t)?n.show():n.hide()})},prev:function(t){return r(this.pluck("previousElementSibling")).filter(t||"*")},next:function(t){return r(this.pluck("nextElementSibling")).filter(t||"*")},html:function(t){return 0 in arguments?this.each(function(e){var n=this.innerHTML;r(this).empty().append(Y(this,t,e,n))}):0 in this?this[0].innerHTML:null},text:function(t){return 0 in arguments?this.each(function(e){var n=Y(this,t,e,this.textContent);this.textContent=null==n?"":""+n}):0 in this?this.pluck("textContent").join(""):null},attr:function(t,r){var i;return"string"!=typeof t||1 in arguments?this.each(function(e){if(1===this.nodeType)if(R(t))for(n in t)G(this,n,t[n]);else G(this,t,Y(this,r,e,this.getAttribute(t)))}):0 in this&&1==this[0].nodeType&&null!=(i=this[0].getAttribute(t))?i:e},removeAttr:function(t){return this.each(function(){1===this.nodeType&&t.split(" ").forEach(function(t){G(this,t)},this)})},prop:function(t,e){return t=D[t]||t,1 in arguments?this.each(function(n){this[t]=Y(this,e,n,this[t])}):this[0]&&this[0][t]},removeProp:function(t){return t=D[t]||t,this.each(function(){delete this[t]})},data:function(t,n){var r="data-"+t.replace(v,"-$1").toLowerCase(),i=1 in arguments?this.attr(r,n):this.attr(r);return null!==i?Q(i):e},val:function(t){return 0 in arguments?(null==t&&(t=""),this.each(function(e){this.value=Y(this,t,e,this.value)})):this[0]&&(this[0].multiple?r(this[0]).find("option").filter(function(){return this.selected}).pluck("value"):this[0].value)},offset:function(e){if(e)return this.each(function(t){var n=r(this),i=Y(this,e,t,n.offset()),o=n.offsetParent().offset(),s={top:i.top-o.top,left:i.left-o.left};"static"==n.css("position")&&(s.position="relative"),n.css(s)});if(!this.length)return null;if(f.documentElement!==this[0]&&!r.contains(f.documentElement,this[0]))return{top:0,left:0};var n=this[0].getBoundingClientRect();return{left:n.left+t.pageXOffset,top:n.top+t.pageYOffset,width:Math.round(n.width),height:Math.round(n.height)}},css:function(t,e){if(arguments.length<2){var i=this[0];if("string"==typeof t){if(!i)return;return i.style[O(t)]||getComputedStyle(i,"").getPropertyValue(t)}if(L(t)){if(!i)return;var o={},s=getComputedStyle(i,"");return r.each(t,function(t,e){o[e]=i.style[O(e)]||s.getPropertyValue(e)}),o}}var a="";if("string"==$(t))e||0===e?a=I(t)+":"+_(t,e):this.each(function(){this.style.removeProperty(I(t))});else for(n in t)t[n]||0===t[n]?a+=I(n)+":"+_(n,t[n])+";":this.each(function(){this.style.removeProperty(I(n))});return this.each(function(){this.style.cssText+=";"+a})},index:function(t){return t?this.indexOf(r(t)[0]):this.parent().children().indexOf(this[0])},hasClass:function(t){return t?o.some.call(this,function(t){return this.test(K(t))},V(t)):!1},addClass:function(t){return t?this.each(function(e){if("className"in this){i=[];var n=K(this),o=Y(this,t,e,n);o.split(/\s+/g).forEach(function(t){r(this).hasClass(t)||i.push(t)},this),i.length&&K(this,n+(n?" ":"")+i.join(" "))}}):this},removeClass:function(t){return this.each(function(n){if("className"in this){if(t===e)return K(this,"");i=K(this),Y(this,t,n,i).split(/\s+/g).forEach(function(t){i=i.replace(V(t)," ")}),K(this,i.trim())}})},toggleClass:function(t,n){return t?this.each(function(i){var o=r(this),s=Y(this,t,i,K(this));s.split(/\s+/g).forEach(function(t){(n===e?!o.hasClass(t):n)?o.addClass(t):o.removeClass(t)})}):this},scrollTop:function(t){if(this.length){var n="scrollTop"in this[0];return t===e?n?this[0].scrollTop:this[0].pageYOffset:this.each(n?function(){this.scrollTop=t}:function(){this.scrollTo(this.scrollX,t)})}},scrollLeft:function(t){if(this.length){var n="scrollLeft"in this[0];return t===e?n?this[0].scrollLeft:this[0].pageXOffset:this.each(n?function(){this.scrollLeft=t}:function(){this.scrollTo(t,this.scrollY)})}},position:function(){if(this.length){var t=this[0],e=this.offsetParent(),n=this.offset(),i=g.test(e[0].nodeName)?{top:0,left:0}:e.offset();return n.top-=parseFloat(r(t).css("margin-top"))||0,n.left-=parseFloat(r(t).css("margin-left"))||0,i.top+=parseFloat(r(e[0]).css("border-top-width"))||0,i.left+=parseFloat(r(e[0]).css("border-left-width"))||0,{top:n.top-i.top,left:n.left-i.left}}},offsetParent:function(){return this.map(function(){for(var t=this.offsetParent||f.body;t&&!g.test(t.nodeName)&&"static"==r(t).css("position");)t=t.offsetParent;return t})}},r.fn.detach=r.fn.remove,["width","height"].forEach(function(t){var n=t.replace(/./,function(t){return t[0].toUpperCase()});r.fn[t]=function(i){var o,s=this[0];return i===e?k(s)?s["inner"+n]:M(s)?s.documentElement["scroll"+n]:(o=this.offset())&&o[t]:this.each(function(e){s=r(this),s.css(t,Y(this,i,e,s[t]()))})}}),x.forEach(function(n,i){var o=i%2;r.fn[n]=function(){var n,a,s=r.map(arguments,function(t){var i=[];return n=$(t),"array"==n?(t.forEach(function(t){return t.nodeType!==e?i.push(t):r.zepto.isZ(t)?i=i.concat(t.get()):void(i=i.concat(N.fragment(t)))}),i):"object"==n||null==t?t:N.fragment(t)}),u=this.length>1;return s.length<1?this:this.each(function(e,n){a=o?n:n.parentNode,n=0==i?n.nextSibling:1==i?n.firstChild:2==i?n:null;var c=r.contains(f.documentElement,a);s.forEach(function(e){if(u)e=e.cloneNode(!0);else if(!a)return r(e).remove();a.insertBefore(e,n),c&&tt(e,function(e){if(!(null==e.nodeName||"SCRIPT"!==e.nodeName.toUpperCase()||e.type&&"text/javascript"!==e.type||e.src)){var n=e.ownerDocument?e.ownerDocument.defaultView:t;n.eval.call(n,e.innerHTML)}})})})},r.fn[o?n+"To":"insert"+(i?"Before":"After")]=function(t){return r(t)[n](this),this}}),N.Z.prototype=X.prototype=r.fn,N.uniq=P,N.deserializeValue=Q,r.zepto=N,r}();return t.Zepto=e,void 0===t.$&&(t.$=e),function(e){function h(t){return t._zid||(t._zid=n++)}function p(t,e,n,r){if(e=d(e),e.ns)var i=m(e.ns);return(a[h(t)]||[]).filter(function(t){return t&&(!e.e||t.e==e.e)&&(!e.ns||i.test(t.ns))&&(!n||h(t.fn)===h(n))&&(!r||t.sel==r)})}function d(t){var e=(""+t).split(".");return{e:e[0],ns:e.slice(1).sort().join(" ")}}function m(t){return new RegExp("(?:^| )"+t.replace(" "," .* ?")+"(?: |$)")}function g(t,e){return t.del&&!f&&t.e in c||!!e}function v(t){return l[t]||f&&c[t]||t}function y(t,n,i,o,s,u,f){var c=h(t),p=a[c]||(a[c]=[]);n.split(/\s/).forEach(function(n){if("ready"==n)return e(document).ready(i);var a=d(n);a.fn=i,a.sel=s,a.e in l&&(i=function(t){var n=t.relatedTarget;return!n||n!==this&&!e.contains(this,n)?a.fn.apply(this,arguments):void 0}),a.del=u;var c=u||i;a.proxy=function(e){if(e=T(e),!e.isImmediatePropagationStopped()){e.data=o;var n=c.apply(t,e._args==r?[e]:[e].concat(e._args));return n===!1&&(e.preventDefault(),e.stopPropagation()),n}},a.i=p.length,p.push(a),"addEventListener"in t&&t.addEventListener(v(a.e),a.proxy,g(a,f))})}function x(t,e,n,r,i){var o=h(t);(e||"").split(/\s/).forEach(function(e){p(t,e,n,r).forEach(function(e){delete a[o][e.i],"removeEventListener"in t&&t.removeEventListener(v(e.e),e.proxy,g(e,i))})})}function T(t,n){return(n||!t.isDefaultPrevented)&&(n||(n=t),e.each(w,function(e,r){var i=n[e];t[e]=function(){return this[r]=b,i&&i.apply(n,arguments)},t[r]=E}),t.timeStamp||(t.timeStamp=Date.now()),(n.defaultPrevented!==r?n.defaultPrevented:"returnValue"in n?n.returnValue===!1:n.getPreventDefault&&n.getPreventDefault())&&(t.isDefaultPrevented=b)),t}function S(t){var e,n={originalEvent:t};for(e in t)j.test(e)||t[e]===r||(n[e]=t[e]);return T(n,t)}var r,n=1,i=Array.prototype.slice,o=e.isFunction,s=function(t){return"string"==typeof t},a={},u={},f="onfocusin"in t,c={focus:"focusin",blur:"focusout"},l={mouseenter:"mouseover",mouseleave:"mouseout"};u.click=u.mousedown=u.mouseup=u.mousemove="MouseEvents",e.event={add:y,remove:x},e.proxy=function(t,n){var r=2 in arguments&&i.call(arguments,2);if(o(t)){var a=function(){return t.apply(n,r?r.concat(i.call(arguments)):arguments)};return a._zid=h(t),a}if(s(n))return r?(r.unshift(t[n],t),e.proxy.apply(null,r)):e.proxy(t[n],t);throw new TypeError("expected function")},e.fn.bind=function(t,e,n){return this.on(t,e,n)},e.fn.unbind=function(t,e){return this.off(t,e)},e.fn.one=function(t,e,n,r){return this.on(t,e,n,r,1)};var b=function(){return!0},E=function(){return!1},j=/^([A-Z]|returnValue$|layer[XY]$|webkitMovement[XY]$)/,w={preventDefault:"isDefaultPrevented",stopImmediatePropagation:"isImmediatePropagationStopped",stopPropagation:"isPropagationStopped"};e.fn.delegate=function(t,e,n){return this.on(e,t,n)},e.fn.undelegate=function(t,e,n){return this.off(e,t,n)},e.fn.live=function(t,n){return e(document.body).delegate(this.selector,t,n),this},e.fn.die=function(t,n){return e(document.body).undelegate(this.selector,t,n),this},e.fn.on=function(t,n,a,u,f){var c,l,h=this;return t&&!s(t)?(e.each(t,function(t,e){h.on(t,n,a,e,f)}),h):(s(n)||o(u)||u===!1||(u=a,a=n,n=r),(u===r||a===!1)&&(u=a,a=r),u===!1&&(u=E),h.each(function(r,o){f&&(c=function(t){return x(o,t.type,u),u.apply(this,arguments)}),n&&(l=function(t){var r,s=e(t.target).closest(n,o).get(0);return s&&s!==o?(r=e.extend(S(t),{currentTarget:s,liveFired:o}),(c||u).apply(s,[r].concat(i.call(arguments,1)))):void 0}),y(o,t,u,a,n,l||c)}))},e.fn.off=function(t,n,i){var a=this;return t&&!s(t)?(e.each(t,function(t,e){a.off(t,n,e)}),a):(s(n)||o(i)||i===!1||(i=n,n=r),i===!1&&(i=E),a.each(function(){x(this,t,i,n)}))},e.fn.trigger=function(t,n){return t=s(t)||e.isPlainObject(t)?e.Event(t):T(t),t._args=n,this.each(function(){t.type in c&&"function"==typeof this[t.type]?this[t.type]():"dispatchEvent"in this?this.dispatchEvent(t):e(this).triggerHandler(t,n)})},e.fn.triggerHandler=function(t,n){var r,i;return this.each(function(o,a){r=S(s(t)?e.Event(t):t),r._args=n,r.target=a,e.each(p(a,t.type||t),function(t,e){return i=e.proxy(r),r.isImmediatePropagationStopped()?!1:void 0})}),i},"focusin focusout focus blur load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select keydown keypress keyup error".split(" ").forEach(function(t){e.fn[t]=function(e){return 0 in arguments?this.bind(t,e):this.trigger(t)}}),e.Event=function(t,e){s(t)||(e=t,t=e.type);var n=document.createEvent(u[t]||"Events"),r=!0;if(e)for(var i in e)"bubbles"==i?r=!!e[i]:n[i]=e[i];return n.initEvent(t,r,!0),T(n)}}(e),function(e){function p(t,n,r){var i=e.Event(n);return e(t).trigger(i,r),!i.isDefaultPrevented()}function d(t,e,n,i){return t.global?p(e||r,n,i):void 0}function m(t){t.global&&0===e.active++&&d(t,null,"ajaxStart")}function g(t){t.global&&!--e.active&&d(t,null,"ajaxStop")}function v(t,e){var n=e.context;return e.beforeSend.call(n,t,e)===!1||d(e,n,"ajaxBeforeSend",[t,e])===!1?!1:void d(e,n,"ajaxSend",[t,e])}function y(t,e,n,r){var i=n.context,o="success";n.success.call(i,t,o,e),r&&r.resolveWith(i,[t,o,e]),d(n,i,"ajaxSuccess",[e,n,t]),b(o,e,n)}function x(t,e,n,r,i){var o=r.context;r.error.call(o,n,e,t),i&&i.rejectWith(o,[n,e,t]),d(r,o,"ajaxError",[n,r,t||e]),b(e,n,r)}function b(t,e,n){var r=n.context;n.complete.call(r,e,t),d(n,r,"ajaxComplete",[e,n]),g(n)}function E(t,e,n){if(n.dataFilter==j)return t;var r=n.context;return n.dataFilter.call(r,t,e)}function j(){}function w(t){return t&&(t=t.split(";",2)[0]),t&&(t==c?"html":t==f?"json":a.test(t)?"script":u.test(t)&&"xml")||"text"}function T(t,e){return""==e?t:(t+"&"+e).replace(/[&?]{1,2}/,"?")}function S(t){t.processData&&t.data&&"string"!=e.type(t.data)&&(t.data=e.param(t.data,t.traditional)),!t.data||t.type&&"GET"!=t.type.toUpperCase()&&"jsonp"!=t.dataType||(t.url=T(t.url,t.data),t.data=void 0)}function C(t,n,r,i){return e.isFunction(n)&&(i=r,r=n,n=void 0),e.isFunction(r)||(i=r,r=void 0),{url:t,data:n,success:r,dataType:i}}function O(t,n,r,i){var o,s=e.isArray(n),a=e.isPlainObject(n);e.each(n,function(n,u){o=e.type(u),i&&(n=r?i:i+"["+(a||"object"==o||"array"==o?n:"")+"]"),!i&&s?t.add(u.name,u.value):"array"==o||!r&&"object"==o?O(t,u,r,n):t.add(n,u)})}var i,o,n=+new Date,r=t.document,s=/)<[^<]*)*<\/script>/gi,a=/^(?:text|application)\/javascript/i,u=/^(?:text|application)\/xml/i,f="application/json",c="text/html",l=/^\s*$/,h=r.createElement("a");h.href=t.location.href,e.active=0,e.ajaxJSONP=function(i,o){if(!("type"in i))return e.ajax(i);var c,p,s=i.jsonpCallback,a=(e.isFunction(s)?s():s)||"Zepto"+n++,u=r.createElement("script"),f=t[a],l=function(t){e(u).triggerHandler("error",t||"abort")},h={abort:l};return o&&o.promise(h),e(u).on("load error",function(n,r){clearTimeout(p),e(u).off().remove(),"error"!=n.type&&c?y(c[0],h,i,o):x(null,r||"error",h,i,o),t[a]=f,c&&e.isFunction(f)&&f(c[0]),f=c=void 0}),v(h,i)===!1?(l("abort"),h):(t[a]=function(){c=arguments},u.src=i.url.replace(/\?(.+)=\?/,"?$1="+a),r.head.appendChild(u),i.timeout>0&&(p=setTimeout(function(){l("timeout")},i.timeout)),h)},e.ajaxSettings={type:"GET",beforeSend:j,success:j,error:j,complete:j,context:null,global:!0,xhr:function(){return new t.XMLHttpRequest},accepts:{script:"text/javascript, application/javascript, application/x-javascript",json:f,xml:"application/xml, text/xml",html:c,text:"text/plain"},crossDomain:!1,timeout:0,processData:!0,cache:!0,dataFilter:j},e.ajax=function(n){var u,f,s=e.extend({},n||{}),a=e.Deferred&&e.Deferred();for(i in e.ajaxSettings)void 0===s[i]&&(s[i]=e.ajaxSettings[i]);m(s),s.crossDomain||(u=r.createElement("a"),u.href=s.url,u.href=u.href,s.crossDomain=h.protocol+"//"+h.host!=u.protocol+"//"+u.host),s.url||(s.url=t.location.toString()),(f=s.url.indexOf("#"))>-1&&(s.url=s.url.slice(0,f)),S(s);var c=s.dataType,p=/\?.+=\?/.test(s.url);if(p&&(c="jsonp"),s.cache!==!1&&(n&&n.cache===!0||"script"!=c&&"jsonp"!=c)||(s.url=T(s.url,"_="+Date.now())),"jsonp"==c)return p||(s.url=T(s.url,s.jsonp?s.jsonp+"=?":s.jsonp===!1?"":"callback=?")),e.ajaxJSONP(s,a);var P,d=s.accepts[c],g={},b=function(t,e){g[t.toLowerCase()]=[t,e]},C=/^([\w-]+:)\/\//.test(s.url)?RegExp.$1:t.location.protocol,N=s.xhr(),O=N.setRequestHeader;if(a&&a.promise(N),s.crossDomain||b("X-Requested-With","XMLHttpRequest"),b("Accept",d||"*/*"),(d=s.mimeType||d)&&(d.indexOf(",")>-1&&(d=d.split(",",2)[0]),N.overrideMimeType&&N.overrideMimeType(d)),(s.contentType||s.contentType!==!1&&s.data&&"GET"!=s.type.toUpperCase())&&b("Content-Type",s.contentType||"application/x-www-form-urlencoded"),s.headers)for(o in s.headers)b(o,s.headers[o]);if(N.setRequestHeader=b,N.onreadystatechange=function(){if(4==N.readyState){N.onreadystatechange=j,clearTimeout(P);var t,n=!1;if(N.status>=200&&N.status<300||304==N.status||0==N.status&&"file:"==C){if(c=c||w(s.mimeType||N.getResponseHeader("content-type")),"arraybuffer"==N.responseType||"blob"==N.responseType)t=N.response;else{t=N.responseText;try{t=E(t,c,s),"script"==c?(1,eval)(t):"xml"==c?t=N.responseXML:"json"==c&&(t=l.test(t)?null:e.parseJSON(t))}catch(r){n=r}if(n)return x(n,"parsererror",N,s,a)}y(t,N,s,a)}else x(N.statusText||null,N.status?"error":"abort",N,s,a)}},v(N,s)===!1)return N.abort(),x(null,"abort",N,s,a),N;var A="async"in s?s.async:!0;if(N.open(s.type,s.url,A,s.username,s.password),s.xhrFields)for(o in s.xhrFields)N[o]=s.xhrFields[o];for(o in g)O.apply(N,g[o]);return s.timeout>0&&(P=setTimeout(function(){N.onreadystatechange=j,N.abort(),x(null,"timeout",N,s,a)},s.timeout)),N.send(s.data?s.data:null),N},e.get=function(){return e.ajax(C.apply(null,arguments))},e.post=function(){var t=C.apply(null,arguments);return t.type="POST",e.ajax(t)},e.getJSON=function(){var t=C.apply(null,arguments);return t.dataType="json",e.ajax(t)},e.fn.load=function(t,n,r){if(!this.length)return this;var a,i=this,o=t.split(/\s/),u=C(t,n,r),f=u.success;return o.length>1&&(u.url=o[0],a=o[1]),u.success=function(t){i.html(a?e("
    ").html(t.replace(s,"")).find(a):t),f&&f.apply(i,arguments)},e.ajax(u),this};var N=encodeURIComponent;e.param=function(t,n){var r=[];return r.add=function(t,n){e.isFunction(n)&&(n=n()),null==n&&(n=""),this.push(N(t)+"="+N(n))},O(r,t,n),r.join("&").replace(/%20/g,"+")}}(e),function(t){t.fn.serializeArray=function(){var e,n,r=[],i=function(t){return t.forEach?t.forEach(i):void r.push({name:e,value:t})};return this[0]&&t.each(this[0].elements,function(r,o){n=o.type,e=o.name,e&&"fieldset"!=o.nodeName.toLowerCase()&&!o.disabled&&"submit"!=n&&"reset"!=n&&"button"!=n&&"file"!=n&&("radio"!=n&&"checkbox"!=n||o.checked)&&i(t(o).val())}),r},t.fn.serialize=function(){var t=[];return this.serializeArray().forEach(function(e){t.push(encodeURIComponent(e.name)+"="+encodeURIComponent(e.value))}),t.join("&")},t.fn.submit=function(e){if(0 in arguments)this.bind("submit",e);else if(this.length){var n=t.Event("submit");this.eq(0).trigger(n),n.isDefaultPrevented()||this.get(0).submit()}return this}}(e),function(){try{getComputedStyle(void 0)}catch(e){var n=getComputedStyle;t.getComputedStyle=function(t,e){try{return n(t,e)}catch(r){return null}}}}(),e}); \ No newline at end of file diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/env_details.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/env_details.html.php new file mode 100644 index 0000000..30fcb9c --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/env_details.html.php @@ -0,0 +1,42 @@ + +
    +

    Environment & details:

    + +
    + $data): ?> +
    + + + + + + + + + + $value): ?> + + + + + +
    KeyValue
    escape($k) ?>dump($value) ?>
    + + + empty + +
    + +
    + + +
    + + $h): ?> +
    + . escape(get_class($h)) ?> +
    + +
    + +
    diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/frame_code.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/frame_code.html.php new file mode 100644 index 0000000..fd3d930 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/frame_code.html.php @@ -0,0 +1,67 @@ + +
    + $frame): ?> + getLine(); ?> +
    + + getFileLines($line - 20, 40); + + // getFileLines can return null if there is no source code + if ($range): + $range = array_map(function ($line) { return empty($line) ? ' ' : $line;}, $range); + $start = key($range) + 1; + $code = join("\n", $range); + ?> +
    escape($code) ?>
    + + + + + dumpArgs($frame); ?> + +
    + Arguments +
    +
    + +
    + + + getComments(); + ?> +
    + $comment): ?> + +
    + escape($context) ?> + escapeButPreserveUris($comment) ?> +
    + +
    + +
    + +
    diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/frame_list.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/frame_list.html.php new file mode 100644 index 0000000..deb202d --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/frame_list.html.php @@ -0,0 +1,17 @@ + + $frame): ?> +
    + +
    + breakOnDelimiter('\\', $tpl->escape($frame->getClass() ?: '')) ?> + breakOnDelimiter('\\', $tpl->escape($frame->getFunction() ?: '')) ?> +
    + +
    + getFile() ? $tpl->breakOnDelimiter('/', $tpl->shorten($tpl->escape($frame->getFile()))) : '<#unknown>' ?>:getLine() ?> +
    +
    +"> + render($frame_list) ?> +
    \ No newline at end of file diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/frames_description.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/frames_description.html.php new file mode 100644 index 0000000..5d32f71 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/frames_description.html.php @@ -0,0 +1,14 @@ + diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/header.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/header.html.php new file mode 100644 index 0000000..2f2d90f --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/header.html.php @@ -0,0 +1,96 @@ +
    +
    + $nameSection): ?> + + escape($nameSection) ?> + + escape($nameSection) . ' \\' ?> + + + + (escape($code) ?>) + +
    + +
    + + escape($message) ?> + + + +
    + Previous exceptions +
    + +
      + $previousMessage): ?> +
    • + escape($previousMessage) ?> + () +
    • + +
    + + + + + + No message + + + + + escape($plain_exception) ?> + + +
    +
    diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/header_outer.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/header_outer.html.php new file mode 100644 index 0000000..f682cbb --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/header_outer.html.php @@ -0,0 +1,3 @@ +
    + render($header) ?> +
    diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/layout.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/layout.html.php new file mode 100644 index 0000000..7ad15ea --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/layout.html.php @@ -0,0 +1,34 @@ + + + + + + + + <?php echo $tpl->escape($page_title) ?> + + + + + + +
    +
    + + render($panel_left_outer) ?> + + render($panel_details_outer) ?> + +
    +
    + + + + + + + diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_details.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_details.html.php new file mode 100644 index 0000000..a85e451 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_details.html.php @@ -0,0 +1,2 @@ +render($frame_code) ?> +render($env_details) ?> \ No newline at end of file diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_details_outer.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_details_outer.html.php new file mode 100644 index 0000000..8162d8c --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_details_outer.html.php @@ -0,0 +1,3 @@ +
    + render($panel_details) ?> +
    \ No newline at end of file diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_left.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_left.html.php new file mode 100644 index 0000000..7e652e4 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_left.html.php @@ -0,0 +1,4 @@ +render($header_outer); +$tpl->render($frames_description); +$tpl->render($frames_container); diff --git a/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_left_outer.html.php b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_left_outer.html.php new file mode 100644 index 0000000..77b575c --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Resources/views/panel_left_outer.html.php @@ -0,0 +1,3 @@ +
    + render($panel_left) ?> +
    \ No newline at end of file diff --git a/public/vendor/filp/whoops/src/Whoops/Run.php b/public/vendor/filp/whoops/src/Whoops/Run.php new file mode 100644 index 0000000..b6eee28 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Run.php @@ -0,0 +1,607 @@ + + */ + +namespace Whoops; + +use InvalidArgumentException; +use Throwable; +use Whoops\Exception\ErrorException; +use Whoops\Handler\CallbackHandler; +use Whoops\Handler\Handler; +use Whoops\Handler\HandlerInterface; +use Whoops\Inspector\CallableInspectorFactory; +use Whoops\Inspector\InspectorFactory; +use Whoops\Inspector\InspectorFactoryInterface; +use Whoops\Inspector\InspectorInterface; +use Whoops\Util\Misc; +use Whoops\Util\SystemFacade; + +final class Run implements RunInterface +{ + /** + * @var bool + */ + private $isRegistered; + + /** + * @var bool + */ + private $allowQuit = true; + + /** + * @var bool + */ + private $sendOutput = true; + + /** + * @var integer|false + */ + private $sendHttpCode = 500; + + /** + * @var integer|false + */ + private $sendExitCode = 1; + + /** + * @var HandlerInterface[] + */ + private $handlerStack = []; + + /** + * @var array + * @psalm-var list + */ + private $silencedPatterns = []; + + /** + * @var SystemFacade + */ + private $system; + + /** + * In certain scenarios, like in shutdown handler, we can not throw exceptions. + * + * @var bool + */ + private $canThrowExceptions = true; + + /** + * The inspector factory to create inspectors. + * + * @var InspectorFactoryInterface + */ + private $inspectorFactory; + + /** + * @var array + */ + private $frameFilters = []; + + public function __construct(?SystemFacade $system = null) + { + $this->system = $system ?: new SystemFacade; + $this->inspectorFactory = new InspectorFactory(); + } + + public function __destruct() + { + $this->unregister(); + } + + /** + * Explicitly request your handler runs as the last of all currently registered handlers. + * + * @param callable|HandlerInterface $handler + * + * @return Run + */ + public function appendHandler($handler) + { + array_unshift($this->handlerStack, $this->resolveHandler($handler)); + return $this; + } + + /** + * Explicitly request your handler runs as the first of all currently registered handlers. + * + * @param callable|HandlerInterface $handler + * + * @return Run + */ + public function prependHandler($handler) + { + return $this->pushHandler($handler); + } + + /** + * Register your handler as the last of all currently registered handlers (to be executed first). + * Prefer using appendHandler and prependHandler for clarity. + * + * @param callable|HandlerInterface $handler + * + * @return Run + * + * @throws InvalidArgumentException If argument is not callable or instance of HandlerInterface. + */ + public function pushHandler($handler) + { + $this->handlerStack[] = $this->resolveHandler($handler); + return $this; + } + + /** + * Removes and returns the last handler pushed to the handler stack. + * + * @see Run::removeFirstHandler(), Run::removeLastHandler() + * + * @return HandlerInterface|null + */ + public function popHandler() + { + return array_pop($this->handlerStack); + } + + /** + * Removes the first handler. + * + * @return void + */ + public function removeFirstHandler() + { + array_pop($this->handlerStack); + } + + /** + * Removes the last handler. + * + * @return void + */ + public function removeLastHandler() + { + array_shift($this->handlerStack); + } + + /** + * Returns an array with all handlers, in the order they were added to the stack. + * + * @return array + */ + public function getHandlers() + { + return $this->handlerStack; + } + + /** + * Clears all handlers in the handlerStack, including the default PrettyPage handler. + * + * @return Run + */ + public function clearHandlers() + { + $this->handlerStack = []; + return $this; + } + + public function getFrameFilters() + { + return $this->frameFilters; + } + + public function clearFrameFilters() + { + $this->frameFilters = []; + return $this; + } + + /** + * Registers this instance as an error handler. + * + * @return Run + */ + public function register() + { + if (!$this->isRegistered) { + // Workaround PHP bug 42098 + // https://bugs.php.net/bug.php?id=42098 + class_exists("\\Whoops\\Exception\\ErrorException"); + class_exists("\\Whoops\\Exception\\FrameCollection"); + class_exists("\\Whoops\\Exception\\Frame"); + class_exists("\\Whoops\\Exception\\Inspector"); + class_exists("\\Whoops\\Inspector\\InspectorFactory"); + + $this->system->setErrorHandler([$this, self::ERROR_HANDLER]); + $this->system->setExceptionHandler([$this, self::EXCEPTION_HANDLER]); + $this->system->registerShutdownFunction([$this, self::SHUTDOWN_HANDLER]); + + $this->isRegistered = true; + } + + return $this; + } + + /** + * Unregisters all handlers registered by this Whoops\Run instance. + * + * @return Run + */ + public function unregister() + { + if ($this->isRegistered) { + $this->system->restoreExceptionHandler(); + $this->system->restoreErrorHandler(); + + $this->isRegistered = false; + } + + return $this; + } + + /** + * Should Whoops allow Handlers to force the script to quit? + * + * @param bool|int $exit + * + * @return bool + */ + public function allowQuit($exit = null) + { + if (func_num_args() == 0) { + return $this->allowQuit; + } + + return $this->allowQuit = (bool) $exit; + } + + /** + * Silence particular errors in particular files. + * + * @param array|string $patterns List or a single regex pattern to match. + * @param int $levels Defaults to E_STRICT | E_DEPRECATED. + * + * @return Run + */ + public function silenceErrorsInPaths($patterns, $levels = 10240) + { + $this->silencedPatterns = array_merge( + $this->silencedPatterns, + array_map( + function ($pattern) use ($levels) { + return [ + "pattern" => $pattern, + "levels" => $levels, + ]; + }, + (array) $patterns + ) + ); + + return $this; + } + + /** + * Returns an array with silent errors in path configuration. + * + * @return array + */ + public function getSilenceErrorsInPaths() + { + return $this->silencedPatterns; + } + + /** + * Should Whoops send HTTP error code to the browser if possible? + * Whoops will by default send HTTP code 500, but you may wish to + * use 502, 503, or another 5xx family code. + * + * @param bool|int $code + * + * @return int|false + * + * @throws InvalidArgumentException + */ + public function sendHttpCode($code = null) + { + if (func_num_args() == 0) { + return $this->sendHttpCode; + } + + if (!$code) { + return $this->sendHttpCode = false; + } + + if ($code === true) { + $code = 500; + } + + if ($code < 400 || 600 <= $code) { + throw new InvalidArgumentException( + "Invalid status code '$code', must be 4xx or 5xx" + ); + } + + return $this->sendHttpCode = $code; + } + + /** + * Should Whoops exit with a specific code on the CLI if possible? + * Whoops will exit with 1 by default, but you can specify something else. + * + * @param int $code + * + * @return int + * + * @throws InvalidArgumentException + */ + public function sendExitCode($code = null) + { + if (func_num_args() == 0) { + return $this->sendExitCode; + } + + if ($code < 0 || 255 <= $code) { + throw new InvalidArgumentException( + "Invalid status code '$code', must be between 0 and 254" + ); + } + + return $this->sendExitCode = (int) $code; + } + + /** + * Should Whoops push output directly to the client? + * If this is false, output will be returned by handleException. + * + * @param bool|int $send + * + * @return bool + */ + public function writeToOutput($send = null) + { + if (func_num_args() == 0) { + return $this->sendOutput; + } + + return $this->sendOutput = (bool) $send; + } + + /** + * Handles an exception, ultimately generating a Whoops error page. + * + * @param Throwable $exception + * + * @return string Output generated by handlers. + */ + public function handleException($exception) + { + // Walk the registered handlers in the reverse order + // they were registered, and pass off the exception + $inspector = $this->getInspector($exception); + + // Capture output produced while handling the exception, + // we might want to send it straight away to the client, + // or return it silently. + $this->system->startOutputBuffering(); + + // Just in case there are no handlers: + $handlerResponse = null; + $handlerContentType = null; + + try { + foreach (array_reverse($this->handlerStack) as $handler) { + $handler->setRun($this); + $handler->setInspector($inspector); + $handler->setException($exception); + + // The HandlerInterface does not require an Exception passed to handle() + // and neither of our bundled handlers use it. + // However, 3rd party handlers may have already relied on this parameter, + // and removing it would be possibly breaking for users. + $handlerResponse = $handler->handle($exception); + + // Collect the content type for possible sending in the headers. + $handlerContentType = method_exists($handler, 'contentType') ? $handler->contentType() : null; + + if (in_array($handlerResponse, [Handler::LAST_HANDLER, Handler::QUIT])) { + // The Handler has handled the exception in some way, and + // wishes to quit execution (Handler::QUIT), or skip any + // other handlers (Handler::LAST_HANDLER). If $this->allowQuit + // is false, Handler::QUIT behaves like Handler::LAST_HANDLER + break; + } + } + + $willQuit = $handlerResponse == Handler::QUIT && $this->allowQuit(); + } finally { + $output = $this->system->cleanOutputBuffer(); + } + + // If we're allowed to, send output generated by handlers directly + // to the output, otherwise, and if the script doesn't quit, return + // it so that it may be used by the caller + if ($this->writeToOutput()) { + // @todo Might be able to clean this up a bit better + if ($willQuit) { + // Cleanup all other output buffers before sending our output: + while ($this->system->getOutputBufferLevel() > 0) { + $this->system->endOutputBuffering(); + } + + // Send any headers if needed: + if (Misc::canSendHeaders() && $handlerContentType) { + header("Content-Type: {$handlerContentType}"); + } + } + + $this->writeToOutputNow($output); + } + + if ($willQuit) { + // HHVM fix for https://github.com/facebook/hhvm/issues/4055 + $this->system->flushOutputBuffer(); + + $this->system->stopExecution( + $this->sendExitCode() + ); + } + + return $output; + } + + /** + * Converts generic PHP errors to \ErrorException instances, before passing them off to be handled. + * + * This method MUST be compatible with set_error_handler. + * + * @param int $level + * @param string $message + * @param string|null $file + * @param int|null $line + * + * @return bool + * + * @throws ErrorException + */ + public function handleError($level, $message, $file = null, $line = null) + { + if ($level & $this->system->getErrorReportingLevel()) { + foreach ($this->silencedPatterns as $entry) { + $pathMatches = (bool) preg_match($entry["pattern"], $file); + $levelMatches = $level & $entry["levels"]; + if ($pathMatches && $levelMatches) { + // Ignore the error, abort handling + // See https://github.com/filp/whoops/issues/418 + return true; + } + } + + // XXX we pass $level for the "code" param only for BC reasons. + // see https://github.com/filp/whoops/issues/267 + $exception = new ErrorException($message, /*code*/ $level, /*severity*/ $level, $file, $line); + if ($this->canThrowExceptions) { + throw $exception; + } else { + $this->handleException($exception); + } + // Do not propagate errors which were already handled by Whoops. + return true; + } + + // Propagate error to the next handler, allows error_get_last() to + // work on silenced errors. + return false; + } + + /** + * Special case to deal with Fatal errors and the like. + * + * @return void + */ + public function handleShutdown() + { + // If we reached this step, we are in shutdown handler. + // An exception thrown in a shutdown handler will not be propagated + // to the exception handler. Pass that information along. + $this->canThrowExceptions = false; + + // If we are not currently registered, we should not do anything + if (!$this->isRegistered) { + return; + } + + $error = $this->system->getLastError(); + if ($error && Misc::isLevelFatal($error['type'])) { + // If there was a fatal error, + // it was not handled in handleError yet. + $this->allowQuit = false; + $this->handleError( + $error['type'], + $error['message'], + $error['file'], + $error['line'] + ); + } + } + + + /** + * @param InspectorFactoryInterface $factory + * + * @return void + */ + public function setInspectorFactory(InspectorFactoryInterface $factory) + { + $this->inspectorFactory = $factory; + } + + public function addFrameFilter($filterCallback) + { + if (!is_callable($filterCallback)) { + throw new \InvalidArgumentException(sprintf( + "A frame filter must be of type callable, %s type given.", + gettype($filterCallback) + )); + } + + $this->frameFilters[] = $filterCallback; + return $this; + } + + /** + * @param Throwable $exception + * + * @return InspectorInterface + */ + private function getInspector($exception) + { + return $this->inspectorFactory->create($exception); + } + + /** + * Resolves the giving handler. + * + * @param callable|HandlerInterface $handler + * + * @return HandlerInterface + * + * @throws InvalidArgumentException + */ + private function resolveHandler($handler) + { + if (is_callable($handler)) { + $handler = new CallbackHandler($handler); + } + + if (!$handler instanceof HandlerInterface) { + throw new InvalidArgumentException( + "Handler must be a callable, or instance of " + . "Whoops\\Handler\\HandlerInterface" + ); + } + + return $handler; + } + + /** + * Echo something to the browser. + * + * @param string $output + * + * @return Run + */ + private function writeToOutputNow($output) + { + if ($this->sendHttpCode() && Misc::canSendHeaders()) { + $this->system->setHttpResponseCode( + $this->sendHttpCode() + ); + } + + echo $output; + + return $this; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/RunInterface.php b/public/vendor/filp/whoops/src/Whoops/RunInterface.php new file mode 100644 index 0000000..0ef3e3f --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/RunInterface.php @@ -0,0 +1,158 @@ + + */ + +namespace Whoops; + +use InvalidArgumentException; +use Whoops\Exception\ErrorException; +use Whoops\Handler\HandlerInterface; + +interface RunInterface +{ + const EXCEPTION_HANDLER = "handleException"; + const ERROR_HANDLER = "handleError"; + const SHUTDOWN_HANDLER = "handleShutdown"; + + /** + * Pushes a handler to the end of the stack + * + * @throws InvalidArgumentException If argument is not callable or instance of HandlerInterface + * @param Callable|HandlerInterface $handler + * @return Run + */ + public function pushHandler($handler); + + /** + * Removes the last handler in the stack and returns it. + * Returns null if there"s nothing else to pop. + * + * @return null|HandlerInterface + */ + public function popHandler(); + + /** + * Returns an array with all handlers, in the + * order they were added to the stack. + * + * @return array + */ + public function getHandlers(); + + /** + * Clears all handlers in the handlerStack, including + * the default PrettyPage handler. + * + * @return Run + */ + public function clearHandlers(); + + /** + * @return array + */ + public function getFrameFilters(); + + /** + * @return Run + */ + public function clearFrameFilters(); + + /** + * Registers this instance as an error handler. + * + * @return Run + */ + public function register(); + + /** + * Unregisters all handlers registered by this Whoops\Run instance + * + * @return Run + */ + public function unregister(); + + /** + * Should Whoops allow Handlers to force the script to quit? + * + * @param bool|int $exit + * @return bool + */ + public function allowQuit($exit = null); + + /** + * Silence particular errors in particular files + * + * @param array|string $patterns List or a single regex pattern to match + * @param int $levels Defaults to E_STRICT | E_DEPRECATED + * @return \Whoops\Run + */ + public function silenceErrorsInPaths($patterns, $levels = 10240); + + /** + * Should Whoops send HTTP error code to the browser if possible? + * Whoops will by default send HTTP code 500, but you may wish to + * use 502, 503, or another 5xx family code. + * + * @param bool|int $code + * @return int|false + */ + public function sendHttpCode($code = null); + + /** + * Should Whoops exit with a specific code on the CLI if possible? + * Whoops will exit with 1 by default, but you can specify something else. + * + * @param int $code + * @return int + */ + public function sendExitCode($code = null); + + /** + * Should Whoops push output directly to the client? + * If this is false, output will be returned by handleException + * + * @param bool|int $send + * @return bool + */ + public function writeToOutput($send = null); + + /** + * Handles an exception, ultimately generating a Whoops error + * page. + * + * @param \Throwable $exception + * @return string Output generated by handlers + */ + public function handleException($exception); + + /** + * Converts generic PHP errors to \ErrorException + * instances, before passing them off to be handled. + * + * This method MUST be compatible with set_error_handler. + * + * @param int $level + * @param string $message + * @param string $file + * @param int $line + * + * @return bool + * @throws ErrorException + */ + public function handleError($level, $message, $file = null, $line = null); + + /** + * Special case to deal with Fatal errors and the like. + */ + public function handleShutdown(); + + /** + * Registers a filter callback in the frame filters stack. + * + * @param callable $filterCallback + * @return \Whoops\Run + */ + public function addFrameFilter($filterCallback); +} diff --git a/public/vendor/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php b/public/vendor/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php new file mode 100644 index 0000000..8c828fd --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php @@ -0,0 +1,36 @@ + + */ + +namespace Whoops\Util; + +/** + * Used as output callable for Symfony\Component\VarDumper\Dumper\HtmlDumper::dump() + * + * @see TemplateHelper::dump() + */ +class HtmlDumperOutput +{ + private $output; + + public function __invoke($line, $depth) + { + // A negative depth means "end of dump" + if ($depth >= 0) { + // Adds a two spaces indentation to the line + $this->output .= str_repeat(' ', $depth) . $line . "\n"; + } + } + + public function getOutput() + { + return $this->output; + } + + public function clear() + { + $this->output = null; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Util/Misc.php b/public/vendor/filp/whoops/src/Whoops/Util/Misc.php new file mode 100644 index 0000000..001a687 --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Util/Misc.php @@ -0,0 +1,77 @@ + + */ + +namespace Whoops\Util; + +class Misc +{ + /** + * Can we at this point in time send HTTP headers? + * + * Currently this checks if we are even serving an HTTP request, + * as opposed to running from a command line. + * + * If we are serving an HTTP request, we check if it's not too late. + * + * @return bool + */ + public static function canSendHeaders() + { + return isset($_SERVER["REQUEST_URI"]) && !headers_sent(); + } + + public static function isAjaxRequest() + { + return ( + !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'); + } + + /** + * Check, if possible, that this execution was triggered by a command line. + * @return bool + */ + public static function isCommandLine() + { + return PHP_SAPI == 'cli'; + } + + /** + * Translate ErrorException code into the represented constant. + * + * @param int $error_code + * @return string + */ + public static function translateErrorCode($error_code) + { + $constants = get_defined_constants(true); + if (array_key_exists('Core', $constants)) { + foreach ($constants['Core'] as $constant => $value) { + if (substr($constant, 0, 2) == 'E_' && $value == $error_code) { + return $constant; + } + } + } + return "E_UNKNOWN"; + } + + /** + * Determine if an error level is fatal (halts execution) + * + * @param int $level + * @return bool + */ + public static function isLevelFatal($level) + { + $errors = E_ERROR; + $errors |= E_PARSE; + $errors |= E_CORE_ERROR; + $errors |= E_CORE_WARNING; + $errors |= E_COMPILE_ERROR; + $errors |= E_COMPILE_WARNING; + return ($level & $errors) > 0; + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Util/SystemFacade.php b/public/vendor/filp/whoops/src/Whoops/Util/SystemFacade.php new file mode 100644 index 0000000..9eb0acf --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Util/SystemFacade.php @@ -0,0 +1,144 @@ + + */ + +namespace Whoops\Util; + +class SystemFacade +{ + /** + * Turns on output buffering. + * + * @return bool + */ + public function startOutputBuffering() + { + return ob_start(); + } + + /** + * @param callable $handler + * @param int $types + * + * @return callable|null + */ + public function setErrorHandler(callable $handler, $types = 'use-php-defaults') + { + // Since PHP 5.4 the constant E_ALL contains all errors (even E_STRICT) + if ($types === 'use-php-defaults') { + $types = E_ALL; + } + return set_error_handler($handler, $types); + } + + /** + * @param callable $handler + * + * @return callable|null + */ + public function setExceptionHandler(callable $handler) + { + return set_exception_handler($handler); + } + + /** + * @return void + */ + public function restoreExceptionHandler() + { + restore_exception_handler(); + } + + /** + * @return void + */ + public function restoreErrorHandler() + { + restore_error_handler(); + } + + /** + * @param callable $function + * + * @return void + */ + public function registerShutdownFunction(callable $function) + { + register_shutdown_function($function); + } + + /** + * @return string|false + */ + public function cleanOutputBuffer() + { + return ob_get_clean(); + } + + /** + * @return int + */ + public function getOutputBufferLevel() + { + return ob_get_level(); + } + + /** + * @return bool + */ + public function endOutputBuffering() + { + return ob_end_clean(); + } + + /** + * @return void + */ + public function flushOutputBuffer() + { + flush(); + } + + /** + * @return int + */ + public function getErrorReportingLevel() + { + return error_reporting(); + } + + /** + * @return array|null + */ + public function getLastError() + { + return error_get_last(); + } + + /** + * @param int $httpCode + * + * @return int + */ + public function setHttpResponseCode($httpCode) + { + if (!headers_sent()) { + // Ensure that no 'location' header is present as otherwise this + // will override the HTTP code being set here, and mask the + // expected error page. + header_remove('location'); + } + + return http_response_code($httpCode); + } + + /** + * @param int $exitStatus + */ + public function stopExecution($exitStatus) + { + exit($exitStatus); + } +} diff --git a/public/vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php b/public/vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php new file mode 100644 index 0000000..5612c0b --- /dev/null +++ b/public/vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php @@ -0,0 +1,349 @@ + + */ + +namespace Whoops\Util; + +use Symfony\Component\VarDumper\Caster\Caster; +use Symfony\Component\VarDumper\Cloner\AbstractCloner; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use Symfony\Component\VarDumper\Dumper\HtmlDumper; +use Whoops\Exception\Frame; + +/** + * Exposes useful tools for working with/in templates + */ +class TemplateHelper +{ + /** + * An array of variables to be passed to all templates + * @var array + */ + private $variables = []; + + /** + * @var HtmlDumper + */ + private $htmlDumper; + + /** + * @var HtmlDumperOutput + */ + private $htmlDumperOutput; + + /** + * @var AbstractCloner + */ + private $cloner; + + /** + * @var string + */ + private $applicationRootPath; + + public function __construct() + { + // root path for ordinary composer projects + $this->applicationRootPath = dirname(dirname(dirname(dirname(dirname(dirname(__DIR__)))))); + } + + /** + * Escapes a string for output in an HTML document + * + * @param string $raw + * @return string + */ + public function escape($raw) + { + $flags = ENT_QUOTES; + + // HHVM has all constants defined, but only ENT_IGNORE + // works at the moment + if (defined("ENT_SUBSTITUTE") && !defined("HHVM_VERSION")) { + $flags |= ENT_SUBSTITUTE; + } else { + // This is for 5.3. + // The documentation warns of a potential security issue, + // but it seems it does not apply in our case, because + // we do not blacklist anything anywhere. + $flags |= ENT_IGNORE; + } + + $raw = str_replace(chr(9), ' ', $raw); + + return htmlspecialchars($raw, $flags, "UTF-8"); + } + + /** + * Escapes a string for output in an HTML document, but preserves + * URIs within it, and converts them to clickable anchor elements. + * + * @param string $raw + * @return string + */ + public function escapeButPreserveUris($raw) + { + $escaped = $this->escape($raw); + return preg_replace( + "@([A-z]+?://([-\w\.]+[-\w])+(:\d+)?(/([\w/_\.#-]*(\?\S+)?[^\.\s])?)?)@", + "$1", + $escaped + ); + } + + /** + * Makes sure that the given string breaks on the delimiter. + * + * @param string $delimiter + * @param string $s + * @return string + */ + public function breakOnDelimiter($delimiter, $s) + { + $parts = explode($delimiter, $s); + foreach ($parts as &$part) { + $part = '' . $part . ''; + } + + return implode($delimiter, $parts); + } + + /** + * Replace the part of the path that all files have in common. + * + * @param string $path + * @return string + */ + public function shorten($path) + { + if ($this->applicationRootPath != "/") { + $path = str_replace($this->applicationRootPath, '…', $path); + } + + return $path; + } + + private function getDumper() + { + if (!$this->htmlDumper && class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) { + $this->htmlDumperOutput = new HtmlDumperOutput(); + // re-use the same var-dumper instance, so it won't re-render the global styles/scripts on each dump. + $this->htmlDumper = new HtmlDumper($this->htmlDumperOutput); + + $styles = [ + 'default' => 'color:#FFFFFF; line-height:normal; font:12px "Inconsolata", "Fira Mono", "Source Code Pro", Monaco, Consolas, "Lucida Console", monospace !important; word-wrap: break-word; white-space: pre-wrap; position:relative; z-index:99999; word-break: normal', + 'num' => 'color:#BCD42A', + 'const' => 'color: #4bb1b1;', + 'str' => 'color:#BCD42A', + 'note' => 'color:#ef7c61', + 'ref' => 'color:#A0A0A0', + 'public' => 'color:#FFFFFF', + 'protected' => 'color:#FFFFFF', + 'private' => 'color:#FFFFFF', + 'meta' => 'color:#FFFFFF', + 'key' => 'color:#BCD42A', + 'index' => 'color:#ef7c61', + ]; + $this->htmlDumper->setStyles($styles); + } + + return $this->htmlDumper; + } + + /** + * Format the given value into a human readable string. + * + * @param mixed $value + * @return string + */ + public function dump($value) + { + $dumper = $this->getDumper(); + + if ($dumper) { + // re-use the same DumpOutput instance, so it won't re-render the global styles/scripts on each dump. + // exclude verbose information (e.g. exception stack traces) + if (class_exists('Symfony\Component\VarDumper\Caster\Caster')) { + $cloneVar = $this->getCloner()->cloneVar($value, Caster::EXCLUDE_VERBOSE); + // Symfony VarDumper 2.6 Caster class dont exist. + } else { + $cloneVar = $this->getCloner()->cloneVar($value); + } + + $dumper->dump( + $cloneVar, + $this->htmlDumperOutput + ); + + $output = $this->htmlDumperOutput->getOutput(); + $this->htmlDumperOutput->clear(); + + return $output; + } + + return htmlspecialchars(print_r($value, true)); + } + + /** + * Format the args of the given Frame as a human readable html string + * + * @param Frame $frame + * @return string the rendered html + */ + public function dumpArgs(Frame $frame) + { + // we support frame args only when the optional dumper is available + if (!$this->getDumper()) { + return ''; + } + + $html = ''; + $numFrames = count($frame->getArgs()); + + if ($numFrames > 0) { + $html = '
      '; + foreach ($frame->getArgs() as $j => $frameArg) { + $html .= '
    1. '. $this->dump($frameArg) .'
    2. '; + } + $html .= '
    '; + } + + return $html; + } + + /** + * Convert a string to a slug version of itself + * + * @param string $original + * @return string + */ + public function slug($original) + { + $slug = str_replace(" ", "-", $original); + $slug = preg_replace('/[^\w\d\-\_]/i', '', $slug); + return strtolower($slug); + } + + /** + * Given a template path, render it within its own scope. This + * method also accepts an array of additional variables to be + * passed to the template. + * + * @param string $template + */ + public function render($template, ?array $additionalVariables = null) + { + $variables = $this->getVariables(); + + // Pass the helper to the template: + $variables["tpl"] = $this; + + if ($additionalVariables !== null) { + $variables = array_replace($variables, $additionalVariables); + } + + call_user_func(function () { + extract(func_get_arg(1)); + require func_get_arg(0); + }, $template, $variables); + } + + /** + * Sets the variables to be passed to all templates rendered + * by this template helper. + */ + public function setVariables(array $variables) + { + $this->variables = $variables; + } + + /** + * Sets a single template variable, by its name: + * + * @param string $variableName + * @param mixed $variableValue + */ + public function setVariable($variableName, $variableValue) + { + $this->variables[$variableName] = $variableValue; + } + + /** + * Gets a single template variable, by its name, or + * $defaultValue if the variable does not exist + * + * @param string $variableName + * @param mixed $defaultValue + * @return mixed + */ + public function getVariable($variableName, $defaultValue = null) + { + return isset($this->variables[$variableName]) ? + $this->variables[$variableName] : $defaultValue; + } + + /** + * Unsets a single template variable, by its name + * + * @param string $variableName + */ + public function delVariable($variableName) + { + unset($this->variables[$variableName]); + } + + /** + * Returns all variables for this helper + * + * @return array + */ + public function getVariables() + { + return $this->variables; + } + + /** + * Set the cloner used for dumping variables. + * + * @param AbstractCloner $cloner + */ + public function setCloner($cloner) + { + $this->cloner = $cloner; + } + + /** + * Get the cloner used for dumping variables. + * + * @return AbstractCloner + */ + public function getCloner() + { + if (!$this->cloner) { + $this->cloner = new VarCloner(); + } + return $this->cloner; + } + + /** + * Set the application root path. + * + * @param string $applicationRootPath + */ + public function setApplicationRootPath($applicationRootPath) + { + $this->applicationRootPath = $applicationRootPath; + } + + /** + * Return the application root path. + * + * @return string + */ + public function getApplicationRootPath() + { + return $this->applicationRootPath; + } +} diff --git a/public/vendor/getkirby/composer-installer/composer.json b/public/vendor/getkirby/composer-installer/composer.json new file mode 100644 index 0000000..e817b35 --- /dev/null +++ b/public/vendor/getkirby/composer-installer/composer.json @@ -0,0 +1,30 @@ +{ + "name": "getkirby/composer-installer", + "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins", + "type": "composer-plugin", + "license": "MIT", + "homepage": "https://getkirby.com", + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^1.8 || ^2.0" + }, + "autoload": { + "psr-4": { + "Kirby\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Kirby\\": "tests/" + } + }, + "scripts": { + "fix": "php-cs-fixer fix --config .php_cs", + "test": "--stderr --coverage-html=tests/coverage" + }, + "extra": { + "class": "Kirby\\ComposerInstaller\\Plugin" + } +} diff --git a/public/vendor/getkirby/composer-installer/composer.lock b/public/vendor/getkirby/composer-installer/composer.lock new file mode 100644 index 0000000..8461817 --- /dev/null +++ b/public/vendor/getkirby/composer-installer/composer.lock @@ -0,0 +1,1680 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "981db668fb0d4f37f7b64daf03b5f131", + "packages": [], + "packages-dev": [ + { + "name": "composer/ca-bundle", + "version": "1.2.8", + "source": { + "type": "git", + "url": "https://github.com/composer/ca-bundle.git", + "reference": "8a7ecad675253e4654ea05505233285377405215" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/8a7ecad675253e4654ea05505233285377405215", + "reference": "8a7ecad675253e4654ea05505233285377405215", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-pcre": "*", + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8", + "psr/log": "^1.0", + "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\CaBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "keywords": [ + "cabundle", + "cacert", + "certificate", + "ssl", + "tls" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/ca-bundle/issues", + "source": "https://github.com/composer/ca-bundle/tree/1.2.8" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-08-23T12:54:47+00:00" + }, + { + "name": "composer/composer", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/composer/composer.git", + "reference": "62139b2806178adb979d76bd3437534a1a9fd490" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/composer/zipball/62139b2806178adb979d76bd3437534a1a9fd490", + "reference": "62139b2806178adb979d76bd3437534a1a9fd490", + "shasum": "" + }, + "require": { + "composer/ca-bundle": "^1.0", + "composer/semver": "^3.0", + "composer/spdx-licenses": "^1.2", + "composer/xdebug-handler": "^1.1", + "justinrainbow/json-schema": "^5.2.10", + "php": "^5.3.2 || ^7.0 || ^8.0", + "psr/log": "^1.0", + "react/promise": "^1.2 || ^2.7", + "seld/jsonlint": "^1.4", + "seld/phar-utils": "^1.0", + "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0", + "symfony/filesystem": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0", + "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0", + "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0" + }, + "require-dev": { + "phpspec/prophecy": "^1.10", + "symfony/phpunit-bridge": "^4.2 || ^5.0" + }, + "suggest": { + "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", + "ext-zip": "Enabling the zip extension allows you to unzip archives", + "ext-zlib": "Allow gzip compression of HTTP requests" + }, + "bin": [ + "bin/composer" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\": "src/Composer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "https://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", + "homepage": "https://getcomposer.org/", + "keywords": [ + "autoload", + "dependency", + "package" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/composer/issues", + "source": "https://github.com/composer/composer/tree/2.0.8" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-12-03T16:20:39+00:00" + }, + { + "name": "composer/semver", + "version": "3.2.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "a02fdf930a3c1c3ed3a49b5f63859c0c20e10464" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/a02fdf930a3c1c3ed3a49b5f63859c0c20e10464", + "reference": "a02fdf930a3c1c3ed3a49b5f63859c0c20e10464", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.54", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.2.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-11-13T08:59:24+00:00" + }, + { + "name": "composer/spdx-licenses", + "version": "1.5.5", + "source": { + "type": "git", + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "de30328a7af8680efdc03e396aad24befd513200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/de30328a7af8680efdc03e396aad24befd513200", + "reference": "de30328a7af8680efdc03e396aad24befd513200", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Spdx\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "SPDX licenses list and validation library.", + "keywords": [ + "license", + "spdx", + "validator" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/spdx-licenses/issues", + "source": "https://github.com/composer/spdx-licenses/tree/1.5.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-12-03T16:04:16+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "1.4.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "f28d44c286812c714741478d968104c5e604a1d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/f28d44c286812c714741478d968104c5e604a1d4", + "reference": "f28d44c286812c714741478d968104c5e604a1d4", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0", + "psr/log": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || 6.5 - 8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/1.4.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2020-11-13T08:04:11+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.10", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b", + "reference": "2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/justinrainbow/json-schema/issues", + "source": "https://github.com/justinrainbow/json-schema/tree/5.2.10" + }, + "time": "2020-05-27T16:41:55+00:00" + }, + { + "name": "psr/container", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/master" + }, + "time": "2017-02-14T16:28:37+00:00" + }, + { + "name": "psr/log", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", + "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.3" + }, + "time": "2020-03-23T09:12:05+00:00" + }, + { + "name": "react/promise", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/f3cff96a19736714524ca0dd1d4130de73dbbbc4", + "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0 || ^6.5 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v2.8.0" + }, + "time": "2020-05-12T15:16:56+00:00" + }, + { + "name": "seld/jsonlint", + "version": "1.8.3", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9ad6ce79c342fbd44df10ea95511a1b24dee5b57", + "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" + }, + "bin": [ + "bin/jsonlint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Seld\\JsonLint\\": "src/Seld/JsonLint/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "support": { + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.8.3" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2020-11-11T09:19:24+00:00" + }, + { + "name": "seld/phar-utils", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "8674b1d84ffb47cc59a101f5d5a3b61e87d23796" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/8674b1d84ffb47cc59a101f5d5a3b61e87d23796", + "reference": "8674b1d84ffb47cc59a101f5d5a3b61e87d23796", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\PharUtils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "PHAR file format utilities, for when PHP phars you up", + "keywords": [ + "phar" + ], + "support": { + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/master" + }, + "time": "2020-07-07T18:42:57+00:00" + }, + { + "name": "symfony/console", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "47c02526c532fb381374dab26df05e7313978976" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/47c02526c532fb381374dab26df05e7313978976", + "reference": "47c02526c532fb381374dab26df05e7313978976", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2", + "symfony/string": "^5.1" + }, + "conflict": { + "symfony/dependency-injection": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/event-dispatcher": "<4.4", + "symfony/lock": "<4.4", + "symfony/process": "<4.4" + }, + "provide": { + "psr/log-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/lock": "^4.4|^5.0", + "symfony/process": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-18T08:03:05+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fa8f8cab6b65e2d99a118e082935344c5ba8c60d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fa8f8cab6b65e2d99a118e082935344c5ba8c60d", + "reference": "fa8f8cab6b65e2d99a118e082935344c5ba8c60d", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Filesystem Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-11-30T17:05:38+00:00" + }, + { + "name": "symfony/finder", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/0b9231a5922fd7287ba5b411893c0ecd2733e5ba", + "reference": "0b9231a5922fd7287ba5b411893c0ecd2733e5ba", + "shasum": "" + }, + "require": { + "php": ">=7.2.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Finder Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-08T17:02:38+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41", + "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c", + "reference": "c7cf3f858ec7d70b89559d6e6eb1f7c2517d479c", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "727d1096295d807c309fb01a851577302394c897" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/727d1096295d807c309fb01a851577302394c897", + "reference": "727d1096295d807c309fb01a851577302394c897", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "39d483bdf39be819deabf04ec872eb0b2410b531" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531", + "reference": "39d483bdf39be819deabf04ec872eb0b2410b531", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/8ff431c517be11c78c48a39a66d37431e26a6bed", + "reference": "8ff431c517be11c78c48a39a66d37431e26a6bed", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/e70aa8b064c5b72d3df2abd5ab1e90464ad009de", + "reference": "e70aa8b064c5b72d3df2abd5ab1e90464ad009de", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.20-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.20.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-23T14:02:19+00:00" + }, + { + "name": "symfony/process", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/bd8815b8b6705298beaa384f04fabd459c10bedd", + "reference": "bd8815b8b6705298beaa384f04fabd459c10bedd", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.15" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-08T17:03:37+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "reference": "d15da7ba4957ffb8f1747218be9e1a121fd298a1", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.0" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/master" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-09-07T11:33:47+00:00" + }, + { + "name": "symfony/string", + "version": "v5.2.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed", + "reference": "5bd67751d2e3f7d6f770c9154b8fbcb2aa05f7ed", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "~1.15" + }, + "require-dev": { + "symfony/error-handler": "^4.4|^5.0", + "symfony/http-client": "^4.4|^5.0", + "symfony/translation-contracts": "^1.1|^2", + "symfony/var-exporter": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "files": [ + "Resources/functions.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony String component", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v5.2.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-12-05T07:33:16+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "platform-dev": [], + "plugin-api-version": "2.0.0" +} diff --git a/public/vendor/getkirby/composer-installer/readme.md b/public/vendor/getkirby/composer-installer/readme.md new file mode 100644 index 0000000..c5dab97 --- /dev/null +++ b/public/vendor/getkirby/composer-installer/readme.md @@ -0,0 +1,104 @@ +# Kirby Composer Installer + +[![CI Status](https://flat.badgen.net/github/checks/getkirby/composer-installer/master)](https://github.com/getkirby/composer-installer/actions?query=workflow%3ACI) +[![Coverage Status](https://flat.badgen.net/coveralls/c/github/getkirby/composer-installer)](https://coveralls.io/github/getkirby/composer-installer) + +This is Kirby's custom [Composer installer](https://getcomposer.org/doc/articles/custom-installers.md) for the Kirby CMS. +It is responsible for automatically choosing the correct installation paths if you install the CMS via Composer. + +It can also be used to automatically install Kirby plugins to the `site/plugins` directory. + +## Installing the CMS + +### Default configuration + +If you `require` the `getkirby/cms` package in your own `composer.json`, there is nothing else you need to do: + +```js +{ + "require": { + "getkirby/cms": "^3.0" + } +} +``` + +Kirby's Composer installer (this repo) will run automatically and will install the CMS to the `kirby` directory. + +### Custom installation path + +You might want to use a different installation path. The path can be configured like this in your `composer.json`: + +```js +{ + "require": { + "getkirby/cms": "^3.0" + }, + "extra": { + "kirby-cms-path": "kirby" // change this to your custom path + } +} +``` + +### Disable the installer for the CMS + +If you prefer to have the CMS installed to the `vendor` directory, you can disable the custom path entirely: + +```js +{ + "require": { + "getkirby/cms": "^3.0" + }, + "extra": { + "kirby-cms-path": false + } +} +``` + +Please note that you will need to modify your site's `index.php` to load the `vendor/autoload.php` file instead of Kirby's `bootstrap.php`. + +## Installing plugins + +### Support in published plugins + +Plugins need to require this installer as a Composer dependency to make use of the automatic installation to the `site/plugins` directory. + +You can find out more about this in our [plugin documentation](https://getkirby.com/docs/guide/plugins/plugin-setup-basic). + +### Usage for plugin users + +As a user of Kirby plugins that support this installer, you only need to `require` the plugins in your site's `composer.json`: + +```js +{ + "require": { + "getkirby/cms": "^3.0", + "superwoman/superplugin": "^1.0" + } +} +``` + +The installer (this repo) will run automatically, as the plugin dev added it to the plugin's `composer.json`. + +### Custom installation path + +If your `site/plugins` directory is at a custom path, you can configure the installation path like this in your `composer.json`: + +```js +{ + "require": { + "getkirby/cms": "^3.0", + "superwoman/superplugin": "^1.0" + }, + "extra": { + "kirby-plugin-path": "site/plugins" // change this to your custom path + } +} +``` + +## License + + + +## Author + +Lukas Bestle diff --git a/public/vendor/getkirby/composer-installer/src/ComposerInstaller/CmsInstaller.php b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/CmsInstaller.php new file mode 100644 index 0000000..5dc9481 --- /dev/null +++ b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/CmsInstaller.php @@ -0,0 +1,64 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier GmbH + * @license https://opensource.org/licenses/MIT + */ +class CmsInstaller extends Installer +{ + /** + * Decides if the installer supports the given type + * + * @param string $packageType + * @return bool + */ + public function supports($packageType): bool + { + return $packageType === 'kirby-cms'; + } + + /** + * Returns the installation path of a package + * + * @param \Composer\Package\PackageInterface $package + * @return string + */ + public function getInstallPath(PackageInterface $package): string + { + // get the extra configuration of the top-level package + if ($rootPackage = $this->composer->getPackage()) { + $extra = $rootPackage->getExtra(); + } else { + $extra = []; + } + + // use path from configuration, otherwise fall back to default + if (isset($extra['kirby-cms-path']) === true) { + $path = $extra['kirby-cms-path']; + } else { + $path = 'kirby'; + } + + // if explicitly set to something invalid (e.g. `false`), install to vendor dir + if (is_string($path) !== true) { + return parent::getInstallPath($package); + } + + // don't allow unsafe directories + $vendorDir = $this->composer->getConfig()->get('vendor-dir', Config::RELATIVE_PATHS) ?? 'vendor'; + if ($path === $vendorDir || $path === '.') { + throw new InvalidArgumentException('The path ' . $path . ' is an unsafe installation directory for ' . $package->getPrettyName() . '.'); + } + + return $path; + } +} diff --git a/public/vendor/getkirby/composer-installer/src/ComposerInstaller/Installer.php b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/Installer.php new file mode 100644 index 0000000..34371dc --- /dev/null +++ b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/Installer.php @@ -0,0 +1,105 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier GmbH + * @license https://opensource.org/licenses/MIT + */ +class Installer extends LibraryInstaller +{ + /** + * Decides if the installer supports the given type + * + * @param string $packageType + * @return bool + */ + public function supports($packageType): bool + { + throw new RuntimeException('This method needs to be overridden.'); // @codeCoverageIgnore + } + + /** + * Installs a specific package + * + * @param \Composer\Repository\InstalledRepositoryInterface $repo Repository in which to check + * @param \Composer\Package\PackageInterface $package Package instance to install + * @return \React\Promise\PromiseInterface|null + */ + public function install(InstalledRepositoryInterface $repo, PackageInterface $package) + { + // first install the package normally... + $promise = parent::install($repo, $package); + + // ...then run custom code + $postInstall = function () use ($package): void { + $this->postInstall($package); + }; + + // Composer 2 in async mode + if ($promise instanceof PromiseInterface) { + return $promise->then($postInstall); + } + + // Composer 1 or Composer 2 without async + $postInstall(); + } + + /** + * Updates a specific package + * + * @param \Composer\Repository\InstalledRepositoryInterface $repo Repository in which to check + * @param \Composer\Package\PackageInterface $initial Already installed package version + * @param \Composer\Package\PackageInterface $target Updated version + * @return \React\Promise\PromiseInterface|null + * + * @throws \InvalidArgumentException if $initial package is not installed + */ + public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target) + { + // first update the package normally... + $promise = parent::update($repo, $initial, $target); + + // ...then run custom code + $postInstall = function () use ($target): void { + $this->postInstall($target); + }; + + // Composer 2 in async mode + if ($promise instanceof PromiseInterface) { + return $promise->then($postInstall); + } + + // Composer 1 or Composer 2 without async + $postInstall(); + } + + /** + * Custom handler that will be called after each package + * installation or update + * + * @param \Composer\Package\PackageInterface $package + * @return void + */ + protected function postInstall(PackageInterface $package) + { + // remove the package's `vendor` directory to avoid duplicated autoloader and vendor code + $packageVendorDir = $this->getInstallPath($package) . '/vendor'; + if (is_dir($packageVendorDir) === true) { + $success = $this->filesystem->removeDirectory($packageVendorDir); + + if ($success !== true) { + throw new RuntimeException('Could not completely delete ' . $packageVendorDir . ', aborting.'); // @codeCoverageIgnore + } + } + } +} diff --git a/public/vendor/getkirby/composer-installer/src/ComposerInstaller/Plugin.php b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/Plugin.php new file mode 100644 index 0000000..033cbc2 --- /dev/null +++ b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/Plugin.php @@ -0,0 +1,59 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier GmbH + * @license https://opensource.org/licenses/MIT + */ +class Plugin implements PluginInterface +{ + /** + * Apply plugin modifications to Composer + * + * @param \Composer\Composer $composer + * @param \Composer\IO\IOInterface $io + * @return void + */ + public function activate(Composer $composer, IOInterface $io): void + { + $installationManager = $composer->getInstallationManager(); + $installationManager->addInstaller(new CmsInstaller($io, $composer)); + $installationManager->addInstaller(new PluginInstaller($io, $composer)); + } + + /** + * Remove any hooks from Composer + * + * @codeCoverageIgnore + * + * @param \Composer\Composer $composer + * @param \Composer\IO\IOInterface $io + * @return void + */ + public function deactivate(Composer $composer, IOInterface $io): void + { + // nothing to do + } + + /** + * Prepare the plugin to be uninstalled + * + * @codeCoverageIgnore + * + * @param Composer $composer + * @param IOInterface $io + * @return void + */ + public function uninstall(Composer $composer, IOInterface $io): void + { + // nothing to do + } +} diff --git a/public/vendor/getkirby/composer-installer/src/ComposerInstaller/PluginInstaller.php b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/PluginInstaller.php new file mode 100644 index 0000000..ccdd188 --- /dev/null +++ b/public/vendor/getkirby/composer-installer/src/ComposerInstaller/PluginInstaller.php @@ -0,0 +1,112 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier GmbH + * @license https://opensource.org/licenses/MIT + */ +class PluginInstaller extends Installer +{ + /** + * Decides if the installer supports the given type + * + * @param string $packageType + * @return bool + */ + public function supports($packageType): bool + { + return $packageType === 'kirby-plugin'; + } + + /** + * Returns the installation path of a package + * + * @param \Composer\Package\PackageInterface $package + * @return string path + */ + public function getInstallPath(PackageInterface $package): string + { + // place into `vendor` directory as usual if Pluginkit is not supported + if ($this->supportsPluginkit($package) !== true) { + return parent::getInstallPath($package); + } + + // get the extra configuration of the top-level package + if ($rootPackage = $this->composer->getPackage()) { + $extra = $rootPackage->getExtra(); + } else { + $extra = []; + } + + // use base path from configuration, otherwise fall back to default + $basePath = $extra['kirby-plugin-path'] ?? 'site/plugins'; + + if (is_string($basePath) !== true) { + throw new InvalidArgumentException('Invalid "kirby-plugin-path" option'); + } + + // determine the plugin name from its package name; + // can be overridden in the plugin's `composer.json` + $prettyName = $package->getPrettyName(); + $pluginExtra = $package->getExtra(); + if (empty($pluginExtra['installer-name']) === false) { + $name = $pluginExtra['installer-name']; + + if (is_string($name) !== true) { + throw new InvalidArgumentException('Invalid "installer-name" option in plugin ' . $prettyName); + } + } elseif (strpos($prettyName, '/') !== false) { + // use name after the slash + $name = explode('/', $prettyName)[1]; + } else { + $name = $prettyName; + } + + // build destination path from base path and plugin name + return $basePath . '/' . $name; + } + + /** + * Custom handler that will be called after each package + * installation or update + * + * @param \Composer\Package\PackageInterface $package + * @return void + */ + protected function postInstall(PackageInterface $package): void + { + // only continue if Pluginkit is supported + if ($this->supportsPluginkit($package) !== true) { + return; + } + + parent::postInstall($package); + } + + /** + * Checks if the package has explicitly required this installer; + * otherwise (if the Pluginkit is not yet supported by the plugin) + * the installer will fall back to the behavior of the LibraryInstaller + * + * @param \Composer\Package\PackageInterface $package + * @return bool + */ + protected function supportsPluginkit(PackageInterface $package): bool + { + foreach ($package->getRequires() as $link) { + if ($link->getTarget() === 'getkirby/composer-installer') { + return true; + } + } + + // no required package is the installer + return false; + } +} diff --git a/public/vendor/laminas/laminas-escaper/COPYRIGHT.md b/public/vendor/laminas/laminas-escaper/COPYRIGHT.md new file mode 100644 index 0000000..0a8cccc --- /dev/null +++ b/public/vendor/laminas/laminas-escaper/COPYRIGHT.md @@ -0,0 +1 @@ +Copyright (c) 2020 Laminas Project a Series of LF Projects, LLC. (https://getlaminas.org/) diff --git a/public/vendor/laminas/laminas-escaper/LICENSE.md b/public/vendor/laminas/laminas-escaper/LICENSE.md new file mode 100644 index 0000000..10b40f1 --- /dev/null +++ b/public/vendor/laminas/laminas-escaper/LICENSE.md @@ -0,0 +1,26 @@ +Copyright (c) 2020 Laminas Project a Series of LF Projects, LLC. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of Laminas Foundation nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/public/vendor/laminas/laminas-escaper/README.md b/public/vendor/laminas/laminas-escaper/README.md new file mode 100644 index 0000000..2383208 --- /dev/null +++ b/public/vendor/laminas/laminas-escaper/README.md @@ -0,0 +1,43 @@ +# laminas-escaper + +[![Build Status](https://github.com/laminas/laminas-escaper/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/laminas/laminas-escaper/actions/workflows/continuous-integration.yml) + +> ## 🇷🇺 Русским гражданам +> +> Мы, участники Laminas, родились и живем в разных странах. У многих из нас есть друзья, родственники и коллеги как в России, так и в Украине. Некоторые из нас родились в России. Некоторые из нас живут в России. У некоторых бабушки и дедушки сражались с фашистами во Второй мировой войне. Здесь никто не поддерживает фашизм. +> +> У одного из нас есть украинская родственница, которая спаслась из дома вместе с сыном. Поезд задержался из-за бомбежки на дороге впереди. У нас есть друзья, которые прячутся в бомбоубежищах. Мы с тревогой ждем весточки от них после воздушных налетов, которые беспорядочно наносят удары и попадают по больницам, школам, детским садам и домам. Мы не берем это из каких-либо СМИ. Мы наблюдаем это напрямую. +> +> Вы доверяете нам достаточно, чтоб использовать наши программы, и мы просим вас довериться нам вновь. Мы нуждаемся в помощи. Выходите и протестуйте против этой бесполезной войны. Остановите кровопролитие. Скажите "Нет войне!" +> +> ## 🇺🇸 To Citizens of Russia +> +> We at Laminas come from all over the world. Many of us have friends, family and colleagues in both Russia and Ukraine. Some of us were born in Russia. Some of us currently live in Russia. Some have grandparents who fought Nazis in World War II. Nobody here supports fascism. +> +> One team member has a Ukrainian relative who fled her home with her son. The train was delayed due to bombing on the road ahead. We have friends who are hiding in bomb shelters. We anxiously follow up on them after the air raids, which indiscriminately fire at hospitals, schools, kindergartens and houses. We're not taking this from any media. These are our actual experiences. +> +> You trust us enough to use our software. We ask that you trust us to say the truth on this. We need your help. Go out and protest this unnecessary war. Stop the bloodshed. Say "stop the war!" + +The OWASP Top 10 web security risks study lists Cross-Site Scripting (XSS) in +second place. PHP’s sole functionality against XSS is limited to two functions +of which one is commonly misapplied. Thus, the laminas-escaper component was written. +It offers developers a way to escape output and defend from XSS and related +vulnerabilities by introducing contextual escaping based on peer-reviewed rules. + +## Installation + +Run the following to install this library: + +```bash +$ composer require laminas/laminas-escaper +``` + +## Documentation + +Browse the documentation online at https://docs.laminas.dev/laminas-escaper/ + +## Support + +* [Issues](https://github.com/laminas/laminas-escaper/issues/) +* [Chat](https://laminas.dev/chat/) +* [Forum](https://discourse.laminas.dev/) diff --git a/public/vendor/laminas/laminas-escaper/composer.json b/public/vendor/laminas/laminas-escaper/composer.json new file mode 100644 index 0000000..1c59f36 --- /dev/null +++ b/public/vendor/laminas/laminas-escaper/composer.json @@ -0,0 +1,67 @@ +{ + "name": "laminas/laminas-escaper", + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "license": "BSD-3-Clause", + "keywords": [ + "laminas", + "escaper" + ], + "homepage": "https://laminas.dev", + "support": { + "docs": "https://docs.laminas.dev/laminas-escaper/", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "source": "https://github.com/laminas/laminas-escaper", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "chat": "https://laminas.dev/chat", + "forum": "https://discourse.laminas.dev" + }, + "config": { + "sort-packages": true, + "platform": { + "php": "8.2.99" + }, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "composer/package-versions-deprecated": true, + "infection/extension-installer": true + } + }, + "extra": { + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "ext-ctype": "*", + "ext-mbstring": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LaminasTest\\Escaper\\": "test/" + } + }, + "scripts": { + "check": [ + "@cs-check", + "@test" + ], + "cs-check": "phpcs", + "cs-fix": "phpcbf", + "static-analysis": "psalm --shepherd --stats", + "test": "phpunit --colors=always", + "test-coverage": "phpunit --colors=always --coverage-clover clover.xml" + }, + "conflict": { + "zendframework/zend-escaper": "*" + } +} diff --git a/public/vendor/laminas/laminas-escaper/src/Escaper.php b/public/vendor/laminas/laminas-escaper/src/Escaper.php new file mode 100644 index 0000000..25119a0 --- /dev/null +++ b/public/vendor/laminas/laminas-escaper/src/Escaper.php @@ -0,0 +1,397 @@ + + */ + protected static $htmlNamedEntityMap = [ + 34 => 'quot', // quotation mark + 38 => 'amp', // ampersand + 60 => 'lt', // less-than sign + 62 => 'gt', // greater-than sign + ]; + + /** + * Current encoding for escaping. If not UTF-8, we convert strings from this encoding + * pre-escaping and back to this encoding post-escaping. + * + * @var non-empty-string + */ + protected $encoding = 'utf-8'; + + /** + * Holds the value of the special flags passed as second parameter to + * htmlspecialchars(). + * + * @var int + */ + protected $htmlSpecialCharsFlags; + + /** + * Static Matcher which escapes characters for HTML Attribute contexts + * + * @var callable + * @psalm-var callable(array):string + */ + protected $htmlAttrMatcher; + + /** + * Static Matcher which escapes characters for Javascript contexts + * + * @var callable + * @psalm-var callable(array):string + */ + protected $jsMatcher; + + /** + * Static Matcher which escapes characters for CSS Attribute contexts + * + * @var callable + * @psalm-var callable(array):string + */ + protected $cssMatcher; + + /** + * List of all encoding supported by this class + * + * @var list + */ + protected $supportedEncodings = [ + 'iso-8859-1', + 'iso8859-1', + 'iso-8859-5', + 'iso8859-5', + 'iso-8859-15', + 'iso8859-15', + 'utf-8', + 'cp866', + 'ibm866', + '866', + 'cp1251', + 'windows-1251', + 'win-1251', + '1251', + 'cp1252', + 'windows-1252', + '1252', + 'koi8-r', + 'koi8-ru', + 'koi8r', + 'big5', + '950', + 'gb2312', + '936', + 'big5-hkscs', + 'shift_jis', + 'sjis', + 'sjis-win', + 'cp932', + '932', + 'euc-jp', + 'eucjp', + 'eucjp-win', + 'macroman', + ]; + + /** + * Constructor: Single parameter allows setting of global encoding for use by + * the current object. + * + * @param non-empty-string|null $encoding + * @throws Exception\InvalidArgumentException + */ + public function __construct(?string $encoding = null) + { + if ($encoding !== null) { + if ($encoding === '') { + throw new Exception\InvalidArgumentException( + static::class . ' constructor parameter does not allow a blank value' + ); + } + + $encoding = strtolower($encoding); + if (! in_array($encoding, $this->supportedEncodings)) { + throw new Exception\InvalidArgumentException( + 'Value of \'' . $encoding . '\' passed to ' . static::class + . ' constructor parameter is invalid. Provide an encoding supported by htmlspecialchars()' + ); + } + + $this->encoding = $encoding; + } + + // We take advantage of ENT_SUBSTITUTE flag to correctly deal with invalid UTF-8 sequences. + $this->htmlSpecialCharsFlags = ENT_QUOTES | ENT_SUBSTITUTE; + + // set matcher callbacks + $this->htmlAttrMatcher = + /** @param array $matches */ + fn(array $matches): string => $this->htmlAttrMatcher($matches); + $this->jsMatcher = + /** @param array $matches */ + fn(array $matches): string => $this->jsMatcher($matches); + $this->cssMatcher = + /** @param array $matches */ + fn(array $matches): string => $this->cssMatcher($matches); + } + + /** + * Return the encoding that all output/input is expected to be encoded in. + * + * @return non-empty-string + */ + public function getEncoding() + { + return $this->encoding; + } + + /** @inheritDoc */ + public function escapeHtml(string $string) + { + return htmlspecialchars($string, $this->htmlSpecialCharsFlags, $this->encoding); + } + + /** @inheritDoc */ + public function escapeHtmlAttr(string $string) + { + $string = $this->toUtf8($string); + if ($string === '' || ctype_digit($string)) { + return $string; + } + + $result = preg_replace_callback('/[^a-z0-9,\.\-_]/iSu', $this->htmlAttrMatcher, $string); + assert(is_string($result)); + + return $this->fromUtf8($result); + } + + /** @inheritDoc */ + public function escapeJs(string $string) + { + $string = $this->toUtf8($string); + if ($string === '' || ctype_digit($string)) { + return $string; + } + + $result = preg_replace_callback('/[^a-z0-9,\._]/iSu', $this->jsMatcher, $string); + assert(is_string($result)); + + return $this->fromUtf8($result); + } + + /** @inheritDoc */ + public function escapeUrl(string $string) + { + return rawurlencode($string); + } + + /** @inheritDoc */ + public function escapeCss(string $string) + { + $string = $this->toUtf8($string); + if ($string === '' || ctype_digit($string)) { + return $string; + } + + $result = preg_replace_callback('/[^a-z0-9]/iSu', $this->cssMatcher, $string); + assert(is_string($result)); + + return $this->fromUtf8($result); + } + + /** + * Callback function for preg_replace_callback that applies HTML Attribute + * escaping to all matches. + * + * @param array $matches + * @return string + */ + protected function htmlAttrMatcher($matches) + { + $chr = $matches[0]; + $ord = ord($chr[0]); + + /** + * The following replaces characters undefined in HTML with the + * hex entity for the Unicode replacement character. + */ + if ( + ($ord <= 0x1f && $chr !== "\t" && $chr !== "\n" && $chr !== "\r") + || ($ord >= 0x7f && $ord <= 0x9f) + ) { + return '�'; + } + + /** + * Check if the current character to escape has a name entity we should + * replace it with while grabbing the integer value of the character. + */ + if (strlen($chr) > 1) { + $chr = $this->convertEncoding($chr, 'UTF-32BE', 'UTF-8'); + } + + $hex = bin2hex($chr); + $ord = hexdec($hex); + if (isset(static::$htmlNamedEntityMap[$ord])) { + return '&' . static::$htmlNamedEntityMap[$ord] . ';'; + } + + /** + * Per OWASP recommendations, we'll use upper hex entities + * for any other characters where a named entity does not exist. + */ + if ($ord > 255) { + return sprintf('&#x%04X;', $ord); + } + return sprintf('&#x%02X;', $ord); + } + + /** + * Callback function for preg_replace_callback that applies Javascript + * escaping to all matches. + * + * @param array $matches + * @return string + */ + protected function jsMatcher($matches) + { + $chr = $matches[0]; + if (strlen($chr) === 1) { + return sprintf('\\x%02X', ord($chr)); + } + $chr = $this->convertEncoding($chr, 'UTF-16BE', 'UTF-8'); + $hex = strtoupper(bin2hex($chr)); + if (strlen($hex) <= 4) { + return sprintf('\\u%04s', $hex); + } + $highSurrogate = substr($hex, 0, 4); + $lowSurrogate = substr($hex, 4, 4); + return sprintf('\\u%04s\\u%04s', $highSurrogate, $lowSurrogate); + } + + /** + * Callback function for preg_replace_callback that applies CSS + * escaping to all matches. + * + * @param array $matches + * @return string + */ + protected function cssMatcher($matches) + { + $chr = $matches[0]; + if (strlen($chr) === 1) { + $ord = ord($chr); + } else { + $chr = $this->convertEncoding($chr, 'UTF-32BE', 'UTF-8'); + $ord = hexdec(bin2hex($chr)); + } + return sprintf('\\%X ', $ord); + } + + /** + * Converts a string to UTF-8 from the base encoding. The base encoding is set via this + * + * @param string $string + * @throws Exception\RuntimeException + * @return string + */ + protected function toUtf8($string) + { + if ($this->getEncoding() === 'utf-8') { + $result = $string; + } else { + $result = $this->convertEncoding($string, 'UTF-8', $this->getEncoding()); + } + + if (! $this->isUtf8($result)) { + throw new Exception\RuntimeException( + sprintf('String to be escaped was not valid UTF-8 or could not be converted: %s', $result) + ); + } + + return $result; + } + + /** + * Converts a string from UTF-8 to the base encoding. The base encoding is set via this + * + * @param string $string + * @return string + */ + protected function fromUtf8($string) + { + if ($this->getEncoding() === 'utf-8') { + return $string; + } + + return $this->convertEncoding($string, $this->getEncoding(), 'UTF-8'); + } + + /** + * Checks if a given string appears to be valid UTF-8 or not. + * + * @param string $string + * @return bool + */ + protected function isUtf8($string) + { + return $string === '' || preg_match('/^./su', $string); + } + + /** + * Encoding conversion helper which wraps mb_convert_encoding + * + * @param string $string + * @param string $to + * @param array|string $from + * @return string + */ + protected function convertEncoding($string, $to, $from) + { + $result = mb_convert_encoding($string, $to, $from); + + if ($result === false) { + return ''; // return non-fatal blank string on encoding errors from users + } + + return $result; + } +} diff --git a/public/vendor/laminas/laminas-escaper/src/EscaperInterface.php b/public/vendor/laminas/laminas-escaper/src/EscaperInterface.php new file mode 100644 index 0000000..3930db8 --- /dev/null +++ b/public/vendor/laminas/laminas-escaper/src/EscaperInterface.php @@ -0,0 +1,58 @@ +files() + ->name('*.php') + ->in(array('src', 'tests')); + +return PhpCsFixer\Config::create() + ->setFinder($finder) + ->setRules([ + '@Symfony' => true, + 'array_syntax' => ['syntax' => 'short'], + ]); \ No newline at end of file diff --git a/public/vendor/league/color-extractor/CONTRIBUTING.md b/public/vendor/league/color-extractor/CONTRIBUTING.md new file mode 100644 index 0000000..102987b --- /dev/null +++ b/public/vendor/league/color-extractor/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/php-loep/statsd). + + +## Pull Requests + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the README and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow semver. Randomly breaking public APIs is not an option. + +- **Create topic branches** - Don't ask us to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. + + +## Running Tests + +``` bash +$ phpunit +``` + + +**Happy coding**! \ No newline at end of file diff --git a/public/vendor/league/color-extractor/LICENSE b/public/vendor/league/color-extractor/LICENSE new file mode 100644 index 0000000..e43d05b --- /dev/null +++ b/public/vendor/league/color-extractor/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Mathieu Lechat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/public/vendor/league/color-extractor/README.md b/public/vendor/league/color-extractor/README.md new file mode 100644 index 0000000..cc1fed6 --- /dev/null +++ b/public/vendor/league/color-extractor/README.md @@ -0,0 +1,79 @@ +ColorExtractor +============== + +![Build Status](https://github.com/thephpleague/color-extractor/actions/workflows/run-tests.yml/badge.svg) +[![Total Downloads](https://poser.pugx.org/league/color-extractor/downloads.png)](https://packagist.org/packages/league/color-extractor) +[![Latest Stable Version](https://poser.pugx.org/league/color-extractor/v/stable.png)](https://packagist.org/packages/league/color-extractor) + +Extract colors from an image like a human would do. + +## Install + +Via Composer + +``` bash +$ composer require league/color-extractor:0.4.* +``` + +## Usage + +```php +require 'vendor/autoload.php'; + +use League\ColorExtractor\Color; +use League\ColorExtractor\ColorExtractor; +use League\ColorExtractor\Palette; + +$palette = Palette::fromFilename('./some/image.png'); + +// $palette is an iterator on colors sorted by pixel count +foreach($palette as $color => $count) { + // colors are represented by integers + echo Color::fromIntToHex($color), ': ', $count, "\n"; +} + +// it offers some helpers too +$topFive = $palette->getMostUsedColors(5); + +$colorCount = count($palette); + +$blackCount = $palette->getColorCount(Color::fromHexToInt('#000000')); + + +// an extractor is built from a palette +$extractor = new ColorExtractor($palette); + +// it defines an extract method which return the most “representative” colors +$colors = $extractor->extract(5); + +``` + +## Handling transparency + +By default **any pixel with alpha value greater than zero will be discarded**. This is because transparent colors are not perceived +as is. For example fully transparent black would be seen white on a white background. So if you want to take transparency into account +when building a palette you have to specify this background color. You can do this with the second argument of `Palette` constructors. +Its default value is `null`, meaning a color won't be added to the palette if its alpha component exists and is greater than zero. + +You can set it as an integer representing the color, then transparent colors will be blended before addition to the palette. + +```php +// we set a white background so fully transparent colors will be added as white in the palette +// pure red #FF0000 at 50% opacity will be stored as #FF8080 as it would be perceived +$palette = Palette::fromFilename('./some/image.png', Color::fromHexToInt('#FFFFFF')); +``` + +## Contributing + +Please see [CONTRIBUTING](https://github.com/thephpleague/color-extractor/blob/master/CONTRIBUTING.md) for details. + + +## Credits + +- [Mathieu Lechat](https://github.com/MatTheCat) +- [All Contributors](https://github.com/thephpleague/color-extractor/contributors) + + +## License + +The MIT License (MIT). Please see [License File](https://github.com/thephpleague/color-extractor/blob/master/LICENSE) for more information. diff --git a/public/vendor/league/color-extractor/composer.json b/public/vendor/league/color-extractor/composer.json new file mode 100644 index 0000000..d1132cf --- /dev/null +++ b/public/vendor/league/color-extractor/composer.json @@ -0,0 +1,40 @@ +{ + "name": "league/color-extractor", + "type": "library", + "description": "Extract colors from an image as a human would do.", + "keywords": ["image", "color", "extract", "palette", "human"], + "homepage": "https://github.com/thephpleague/color-extractor", + "license": "MIT", + "replace": { + "matthecat/colorextractor": "*" + }, + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "require": { + "php": "^7.3 || ^8.0", + "ext-gd": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons" + }, + "autoload": { + "psr-4": { + "League\\ColorExtractor\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "League\\ColorExtractor\\Tests\\": "tests" + } + } +} diff --git a/public/vendor/league/color-extractor/phpunit.xml.dist b/public/vendor/league/color-extractor/phpunit.xml.dist new file mode 100644 index 0000000..68a03cb --- /dev/null +++ b/public/vendor/league/color-extractor/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + src + + + + + tests + + + diff --git a/public/vendor/league/color-extractor/src/Color.php b/public/vendor/league/color-extractor/src/Color.php new file mode 100644 index 0000000..7b102c1 --- /dev/null +++ b/public/vendor/league/color-extractor/src/Color.php @@ -0,0 +1,51 @@ + $color >> 16 & 0xFF, + 'g' => $color >> 8 & 0xFF, + 'b' => $color & 0xFF, + ]; + } + + /** + * @param array $components + * + * @return int + */ + public static function fromRgbToInt(array $components) + { + return ($components['r'] * 65536) + ($components['g'] * 256) + ($components['b']); + } +} diff --git a/public/vendor/league/color-extractor/src/ColorExtractor.php b/public/vendor/league/color-extractor/src/ColorExtractor.php new file mode 100644 index 0000000..364a3bd --- /dev/null +++ b/public/vendor/league/color-extractor/src/ColorExtractor.php @@ -0,0 +1,282 @@ +palette = $palette; + } + + /** + * @param int $colorCount + * + * @return array + */ + public function extract($colorCount = 1) + { + if ($colorCount === 0) { + return []; + } + + if (!$this->isInitialized()) { + $this->initialize(); + } + + return self::mergeColors($this->sortedColors, $colorCount, 100 / $colorCount); + } + + /** + * @return bool + */ + protected function isInitialized() + { + return $this->sortedColors !== null; + } + + protected function initialize() + { + $queue = new \SplPriorityQueue(); + $this->sortedColors = new \SplFixedArray(count($this->palette)); + + $i = 0; + foreach ($this->palette as $color => $count) { + $labColor = self::intColorToLab($color); + $queue->insert( + $color, + (sqrt($labColor['a'] * $labColor['a'] + $labColor['b'] * $labColor['b']) ?: 1) * + (1 - $labColor['L'] / 200) * + sqrt($count) + ); + ++$i; + } + + $i = 0; + while ($queue->valid()) { + $this->sortedColors[$i] = $queue->current(); + $queue->next(); + ++$i; + } + } + + /** + * @param \SplFixedArray $colors + * @param int $limit + * @param int $maxDelta + * + * @return array + */ + protected static function mergeColors(\SplFixedArray $colors, $limit, $maxDelta) + { + $limit = min(count($colors), $limit); + if ($limit === 0) { + return []; + } + if ($limit === 1) { + return [$colors[0]]; + } + $labCache = new \SplFixedArray($limit - 1); + $mergedColors = []; + + foreach ($colors as $color) { + $hasColorBeenMerged = false; + + $colorLab = self::intColorToLab($color); + + foreach ($mergedColors as $i => $mergedColor) { + if (self::ciede2000DeltaE($colorLab, $labCache[$i]) < $maxDelta) { + $hasColorBeenMerged = true; + break; + } + } + + if ($hasColorBeenMerged) { + continue; + } + + $mergedColorCount = count($mergedColors); + $mergedColors[] = $color; + + if ($mergedColorCount + 1 == $limit) { + break; + } + + $labCache[$mergedColorCount] = $colorLab; + } + + return $mergedColors; + } + + /** + * @param array $firstLabColor + * @param array $secondLabColor + * + * @return float + */ + protected static function ciede2000DeltaE($firstLabColor, $secondLabColor) + { + $C1 = sqrt(pow($firstLabColor['a'], 2) + pow($firstLabColor['b'], 2)); + $C2 = sqrt(pow($secondLabColor['a'], 2) + pow($secondLabColor['b'], 2)); + $Cb = ($C1 + $C2) / 2; + + $G = .5 * (1 - sqrt(pow($Cb, 7) / (pow($Cb, 7) + pow(25, 7)))); + + $a1p = (1 + $G) * $firstLabColor['a']; + $a2p = (1 + $G) * $secondLabColor['a']; + + $C1p = sqrt(pow($a1p, 2) + pow($firstLabColor['b'], 2)); + $C2p = sqrt(pow($a2p, 2) + pow($secondLabColor['b'], 2)); + + $h1p = $a1p == 0 && $firstLabColor['b'] == 0 ? 0 : atan2($firstLabColor['b'], $a1p); + $h2p = $a2p == 0 && $secondLabColor['b'] == 0 ? 0 : atan2($secondLabColor['b'], $a2p); + + $LpDelta = $secondLabColor['L'] - $firstLabColor['L']; + $CpDelta = $C2p - $C1p; + + if ($C1p * $C2p == 0) { + $hpDelta = 0; + } elseif (abs($h2p - $h1p) <= 180) { + $hpDelta = $h2p - $h1p; + } elseif ($h2p - $h1p > 180) { + $hpDelta = $h2p - $h1p - 360; + } else { + $hpDelta = $h2p - $h1p + 360; + } + + $HpDelta = 2 * sqrt($C1p * $C2p) * sin($hpDelta / 2); + + $Lbp = ($firstLabColor['L'] + $secondLabColor['L']) / 2; + $Cbp = ($C1p + $C2p) / 2; + + if ($C1p * $C2p == 0) { + $hbp = $h1p + $h2p; + } elseif (abs($h1p - $h2p) <= 180) { + $hbp = ($h1p + $h2p) / 2; + } elseif ($h1p + $h2p < 360) { + $hbp = ($h1p + $h2p + 360) / 2; + } else { + $hbp = ($h1p + $h2p - 360) / 2; + } + + $T = 1 - .17 * cos($hbp - 30) + .24 * cos(2 * $hbp) + .32 * cos(3 * $hbp + 6) - .2 * cos(4 * $hbp - 63); + + $sigmaDelta = 30 * exp(-pow(($hbp - 275) / 25, 2)); + + $Rc = 2 * sqrt(pow($Cbp, 7) / (pow($Cbp, 7) + pow(25, 7))); + + $Sl = 1 + ((.015 * pow($Lbp - 50, 2)) / sqrt(20 + pow($Lbp - 50, 2))); + $Sc = 1 + .045 * $Cbp; + $Sh = 1 + .015 * $Cbp * $T; + + $Rt = -sin(2 * $sigmaDelta) * $Rc; + + return sqrt( + pow($LpDelta / $Sl, 2) + + pow($CpDelta / $Sc, 2) + + pow($HpDelta / $Sh, 2) + + $Rt * ($CpDelta / $Sc) * ($HpDelta / $Sh) + ); + } + + /** + * @param int $color + * + * @return array + */ + protected static function intColorToLab($color) + { + return self::xyzToLab( + self::srgbToXyz( + self::rgbToSrgb( + [ + 'R' => ($color >> 16) & 0xFF, + 'G' => ($color >> 8) & 0xFF, + 'B' => $color & 0xFF, + ] + ) + ) + ); + } + + /** + * @param int $value + * + * @return float + */ + protected static function rgbToSrgbStep($value) + { + $value /= 255; + + return $value <= .03928 ? + $value / 12.92 : + pow(($value + .055) / 1.055, 2.4); + } + + /** + * @param array $rgb + * + * @return array + */ + protected static function rgbToSrgb($rgb) + { + return [ + 'R' => self::rgbToSrgbStep($rgb['R']), + 'G' => self::rgbToSrgbStep($rgb['G']), + 'B' => self::rgbToSrgbStep($rgb['B']), + ]; + } + + /** + * @param array $rgb + * + * @return array + */ + protected static function srgbToXyz($rgb) + { + return [ + 'X' => (.4124564 * $rgb['R']) + (.3575761 * $rgb['G']) + (.1804375 * $rgb['B']), + 'Y' => (.2126729 * $rgb['R']) + (.7151522 * $rgb['G']) + (.0721750 * $rgb['B']), + 'Z' => (.0193339 * $rgb['R']) + (.1191920 * $rgb['G']) + (.9503041 * $rgb['B']), + ]; + } + + /** + * @param float $value + * + * @return float + */ + protected static function xyzToLabStep($value) + { + return $value > 216 / 24389 ? pow($value, 1 / 3) : 841 * $value / 108 + 4 / 29; + } + + /** + * @param array $xyz + * + * @return array + */ + protected static function xyzToLab($xyz) + { + //http://en.wikipedia.org/wiki/Illuminant_D65#Definition + $Xn = .95047; + $Yn = 1; + $Zn = 1.08883; + + // http://en.wikipedia.org/wiki/Lab_color_space#CIELAB-CIEXYZ_conversions + return [ + 'L' => 116 * self::xyzToLabStep($xyz['Y'] / $Yn) - 16, + 'a' => 500 * (self::xyzToLabStep($xyz['X'] / $Xn) - self::xyzToLabStep($xyz['Y'] / $Yn)), + 'b' => 200 * (self::xyzToLabStep($xyz['Y'] / $Yn) - self::xyzToLabStep($xyz['Z'] / $Zn)), + ]; + } +} diff --git a/public/vendor/league/color-extractor/src/Palette.php b/public/vendor/league/color-extractor/src/Palette.php new file mode 100644 index 0000000..5c5266f --- /dev/null +++ b/public/vendor/league/color-extractor/src/Palette.php @@ -0,0 +1,180 @@ +colors); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->colors); + } + + /** + * @return int + */ + public function getColorCount($color) + { + if (!array_key_exists($color, $this->colors)) { + return 0; + } + + return $this->colors[$color]; + } + + /** + * @param int $limit = null + * + * @return array + */ + public function getMostUsedColors($limit = null) + { + return array_slice($this->colors, 0, $limit, true); + } + + /** + * @param string $filename + * @param int|null $backgroundColor + * + * @return Palette + * + * @throws \InvalidArgumentException + */ + public static function fromFilename($filename, $backgroundColor = null) + { + if (!is_readable($filename)) { + throw new \InvalidArgumentException('Filename must be a valid path and should be readable'); + } + + return self::fromContents(file_get_contents($filename), $backgroundColor); + } + + /** + * @param string $url + * @param int|null $backgroundColor + * + * @return Palette + * + * @throws \RuntimeException + */ + public static function fromUrl($url, $backgroundColor = null) + { + if (!function_exists('curl_init')){ + return self::fromContents(file_get_contents($url)); + } + + $ch = curl_init(); + try { + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $contents = curl_exec($ch); + if ($contents === false) { + throw new \RuntimeException('Failed to fetch image from URL'); + } + } finally { + curl_close($ch); + } + + return self::fromContents($contents, $backgroundColor); + } + + /** + * Create instance with file contents + * + * @param string $contents + * @param int|null $backgroundColor + * + * @return Palette + */ + public static function fromContents($contents, $backgroundColor = null) { + $image = imagecreatefromstring($contents); + $palette = self::fromGD($image, $backgroundColor); + imagedestroy($image); + + return $palette; + } + + /** + * @param \GDImage|resource $image + * @param int|null $backgroundColor + * + * @return Palette + * + * @throws \InvalidArgumentException + */ + public static function fromGD($image, ?int $backgroundColor = null) + { + if (!$image instanceof \GDImage && (!is_resource($image) || get_resource_type($image) !== 'gd')) { + throw new \InvalidArgumentException('Image must be a gd resource'); + } + if ($backgroundColor !== null && (!is_numeric($backgroundColor) || $backgroundColor < 0 || $backgroundColor > 16777215)) { + throw new \InvalidArgumentException(sprintf('"%s" does not represent a valid color', $backgroundColor)); + } + + $palette = new self(); + + $areColorsIndexed = !imageistruecolor($image); + $imageWidth = imagesx($image); + $imageHeight = imagesy($image); + $palette->colors = []; + + $backgroundColorRed = ($backgroundColor >> 16) & 0xFF; + $backgroundColorGreen = ($backgroundColor >> 8) & 0xFF; + $backgroundColorBlue = $backgroundColor & 0xFF; + + for ($x = 0; $x < $imageWidth; ++$x) { + for ($y = 0; $y < $imageHeight; ++$y) { + $color = imagecolorat($image, $x, $y); + if ($areColorsIndexed) { + $colorComponents = imagecolorsforindex($image, $color); + $color = ($colorComponents['alpha'] * 16777216) + + ($colorComponents['red'] * 65536) + + ($colorComponents['green'] * 256) + + ($colorComponents['blue']); + } + + if ($alpha = $color >> 24) { + if ($backgroundColor === null) { + continue; + } + + $alpha /= 127; + $color = (int) (($color >> 16 & 0xFF) * (1 - $alpha) + $backgroundColorRed * $alpha) * 65536 + + (int) (($color >> 8 & 0xFF) * (1 - $alpha) + $backgroundColorGreen * $alpha) * 256 + + (int) (($color & 0xFF) * (1 - $alpha) + $backgroundColorBlue * $alpha); + } + + isset($palette->colors[$color]) ? + $palette->colors[$color] += 1 : + $palette->colors[$color] = 1; + } + } + + arsort($palette->colors); + + return $palette; + } + + protected function __construct() + { + $this->colors = []; + } +} diff --git a/public/vendor/league/color-extractor/tests/ColorExtractorTest.php b/public/vendor/league/color-extractor/tests/ColorExtractorTest.php new file mode 100644 index 0000000..922ba54 --- /dev/null +++ b/public/vendor/league/color-extractor/tests/ColorExtractorTest.php @@ -0,0 +1,40 @@ + $expectedColors + * + * @dataProvider dataForTestExtract + */ + public function testExtract(string $imagePath, int $colorCount, array $expectedColors): void + { + $palette = Palette::fromFilename($imagePath); + $extractor = new ColorExtractor($palette); + $colors = $extractor->extract($colorCount); + + self::assertSame($expectedColors, $colors); + } + + public function dataForTestExtract(): iterable + { + yield [__DIR__ . '/assets/google.png', 0, []]; + yield [__DIR__ . '/assets/google.png', 1, [18417]]; + yield [__DIR__ . '/assets/google.png', 2, [18417, 42259]]; + yield [__DIR__ . '/assets/google.png', 3, [18417, 15080241, 42259]]; + yield [__DIR__ . '/assets/google.png', 4, [18417, 15080241, 42259, 16360960]]; + yield [__DIR__ . '/assets/google.png', 5, [18417, 15080241, 42259, 16360960, 4753405]]; + yield [__DIR__ . '/assets/empty.png', 0, []]; + yield [__DIR__ . '/assets/empty.png', 1, []]; + } +} diff --git a/public/vendor/league/color-extractor/tests/PaletteTest.php b/public/vendor/league/color-extractor/tests/PaletteTest.php new file mode 100644 index 0000000..afd2b0e --- /dev/null +++ b/public/vendor/league/color-extractor/tests/PaletteTest.php @@ -0,0 +1,79 @@ +jpegPath)); + $colors = $extractor->extract(1); + + $this->assertIsArray($colors); + $this->assertCount(1, $colors); + $this->assertEquals(15985688, $colors[0]); + } + + public function testGifExtractSingleColor() + { + $extractor = new ColorExtractor(Palette::fromFilename($this->gifPath)); + $colors = $extractor->extract(1); + + $this->assertIsArray($colors); + $this->assertCount(1, $colors); + $this->assertEquals(12022491, $colors[0]); + } + + public function testPngExtractSingleColor() + { + $extractor = new ColorExtractor(Palette::fromFilename($this->pngPath)); + $colors = $extractor->extract(1); + + $this->assertIsArray($colors); + $this->assertCount(1, $colors); + $this->assertEquals(14024704, $colors[0]); + } + + public function testWebpExtractSingleColor() + { + $extractor = new ColorExtractor(Palette::fromFilename($this->webpPath)); + $colors = $extractor->extract(1); + + $this->assertIsArray($colors); + $this->assertCount(1, $colors); + $this->assertEquals(15008271, $colors[0]); + } + + public function testJpegExtractMultipleColors() + { + $extractor = new ColorExtractor(Palette::fromFilename($this->pngPath)); + $numColors = 3; + $colors = $extractor->extract($numColors); + + $this->assertIsArray($colors); + $this->assertCount($numColors, $colors); + $this->assertEquals($colors, [14024704, 3407872, 7111569]); + } + + public function testTransparencyHandling() + { + $this->assertCount(0, Palette::fromFilename($this->transparentPngPath)); + + $whiteBackgroundPalette = Palette::fromFilename($this->transparentPngPath, Color::fromHexToInt('#FFFFFF')); + $this->assertEquals(iterator_to_array($whiteBackgroundPalette), [Color::fromHexToInt('#FF8080') => 1]); + + $blackBackgroundPalette = Palette::fromFilename($this->transparentPngPath, Color::fromHexToInt('#000000')); + $this->assertEquals(iterator_to_array($blackBackgroundPalette), [Color::fromHexToInt('#7E0000') => 1]); + } +} diff --git a/public/vendor/league/color-extractor/tests/assets/empty.png b/public/vendor/league/color-extractor/tests/assets/empty.png new file mode 100644 index 0000000..96729e1 Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/empty.png differ diff --git a/public/vendor/league/color-extractor/tests/assets/google.png b/public/vendor/league/color-extractor/tests/assets/google.png new file mode 100644 index 0000000..c9a210d Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/google.png differ diff --git a/public/vendor/league/color-extractor/tests/assets/red-transparent-50.png b/public/vendor/league/color-extractor/tests/assets/red-transparent-50.png new file mode 100644 index 0000000..fbec7f3 Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/red-transparent-50.png differ diff --git a/public/vendor/league/color-extractor/tests/assets/test.gif b/public/vendor/league/color-extractor/tests/assets/test.gif new file mode 100644 index 0000000..5200bad Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/test.gif differ diff --git a/public/vendor/league/color-extractor/tests/assets/test.jpeg b/public/vendor/league/color-extractor/tests/assets/test.jpeg new file mode 100644 index 0000000..4f68aea Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/test.jpeg differ diff --git a/public/vendor/league/color-extractor/tests/assets/test.png b/public/vendor/league/color-extractor/tests/assets/test.png new file mode 100644 index 0000000..25c88fd Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/test.png differ diff --git a/public/vendor/league/color-extractor/tests/assets/test.webp b/public/vendor/league/color-extractor/tests/assets/test.webp new file mode 100644 index 0000000..bb68366 Binary files /dev/null and b/public/vendor/league/color-extractor/tests/assets/test.webp differ diff --git a/public/vendor/michelf/php-smartypants/License.md b/public/vendor/michelf/php-smartypants/License.md new file mode 100644 index 0000000..20aad72 --- /dev/null +++ b/public/vendor/michelf/php-smartypants/License.md @@ -0,0 +1,36 @@ +PHP SmartyPants Lib +Copyright (c) 2005-2016 Michel Fortin + +All rights reserved. + +Original SmartyPants +Copyright (c) 2003-2004 John Gruber + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name "SmartyPants" nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +This software is provided by the copyright holders and contributors "as +is" and any express or implied warranties, including, but not limited +to, the implied warranties of merchantability and fitness for a +particular purpose are disclaimed. In no event shall the copyright owner +or contributors be liable for any direct, indirect, incidental, special, +exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or +profits; or business interruption) however caused and on any theory of +liability, whether in contract, strict liability, or tort (including +negligence or otherwise) arising in any way out of the use of this +software, even if advised of the possibility of such damage. diff --git a/public/vendor/michelf/php-smartypants/Michelf/SmartyPants.inc.php b/public/vendor/michelf/php-smartypants/Michelf/SmartyPants.inc.php new file mode 100644 index 0000000..b4ee661 --- /dev/null +++ b/public/vendor/michelf/php-smartypants/Michelf/SmartyPants.inc.php @@ -0,0 +1,9 @@ + +# +# Original SmartyPants +# Copyright (c) 2003-2004 John Gruber +# +# +namespace Michelf; + + +# +# SmartyPants Parser Class +# + +class SmartyPants { + + ### Version ### + + const SMARTYPANTSLIB_VERSION = "1.8.1"; + + + ### Presets + + # SmartyPants does nothing at all + const ATTR_DO_NOTHING = 0; + # "--" for em-dashes; no en-dash support + const ATTR_EM_DASH = 1; + # "---" for em-dashes; "--" for en-dashes + const ATTR_LONG_EM_DASH_SHORT_EN = 2; + # "--" for em-dashes; "---" for en-dashes + const ATTR_SHORT_EM_DASH_LONG_EN = 3; + # "--" for em-dashes; "---" for en-dashes + const ATTR_STUPEFY = -1; + + # The default preset: ATTR_EM_DASH + const ATTR_DEFAULT = SmartyPants::ATTR_EM_DASH; + + + ### Standard Function Interface ### + + public static function defaultTransform($text, $attr = SmartyPants::ATTR_DEFAULT) { + # + # Initialize the parser and return the result of its transform method. + # This will work fine for derived classes too. + # + # Take parser class on which this function was called. + $parser_class = \get_called_class(); + + # try to take parser from the static parser list + static $parser_list; + $parser =& $parser_list[$parser_class][$attr]; + + # create the parser if not already set + if (!$parser) + $parser = new $parser_class($attr); + + # Transform text using parser. + return $parser->transform($text); + } + + + ### Configuration Variables ### + + # Partial regex for matching tags to skip + public $tags_to_skip = 'pre|code|kbd|script|style|math'; + + # Options to specify which transformations to make: + public $do_nothing = 0; # disable all transforms + public $do_quotes = 0; + public $do_backticks = 0; # 1 => double only, 2 => double & single + public $do_dashes = 0; # 1, 2, or 3 for the three modes described above + public $do_ellipses = 0; + public $do_stupefy = 0; + public $convert_quot = 0; # should we translate " entities into normal quotes? + + # Smart quote characters: + # Opening and closing smart double-quotes. + public $smart_doublequote_open = '“'; + public $smart_doublequote_close = '”'; + public $smart_singlequote_open = '‘'; + public $smart_singlequote_close = '’'; # Also apostrophe. + + # ``Backtick quotes'' + public $backtick_doublequote_open = '“'; // replacement for `` + public $backtick_doublequote_close = '”'; // replacement for '' + public $backtick_singlequote_open = '‘'; // replacement for ` + public $backtick_singlequote_close = '’'; // replacement for ' (also apostrophe) + + # Other punctuation + public $em_dash = '—'; + public $en_dash = '–'; + public $ellipsis = '…'; + + ### Parser Implementation ### + + public function __construct($attr = SmartyPants::ATTR_DEFAULT) { + # + # Initialize a parser with certain attributes. + # + # Parser attributes: + # 0 : do nothing + # 1 : set all + # 2 : set all, using old school en- and em- dash shortcuts + # 3 : set all, using inverted old school en and em- dash shortcuts + # + # q : quotes + # b : backtick quotes (``double'' only) + # B : backtick quotes (``double'' and `single') + # d : dashes + # D : old school dashes + # i : inverted old school dashes + # e : ellipses + # w : convert " entities to " for Dreamweaver users + # + if ($attr == "0") { + $this->do_nothing = 1; + } + else if ($attr == "1") { + # Do everything, turn all options on. + $this->do_quotes = 1; + $this->do_backticks = 1; + $this->do_dashes = 1; + $this->do_ellipses = 1; + } + else if ($attr == "2") { + # Do everything, turn all options on, use old school dash shorthand. + $this->do_quotes = 1; + $this->do_backticks = 1; + $this->do_dashes = 2; + $this->do_ellipses = 1; + } + else if ($attr == "3") { + # Do everything, turn all options on, use inverted old school dash shorthand. + $this->do_quotes = 1; + $this->do_backticks = 1; + $this->do_dashes = 3; + $this->do_ellipses = 1; + } + else if ($attr == "-1") { + # Special "stupefy" mode. + $this->do_stupefy = 1; + } + else { + $chars = preg_split('//', $attr); + foreach ($chars as $c){ + if ($c == "q") { $this->do_quotes = 1; } + else if ($c == "b") { $this->do_backticks = 1; } + else if ($c == "B") { $this->do_backticks = 2; } + else if ($c == "d") { $this->do_dashes = 1; } + else if ($c == "D") { $this->do_dashes = 2; } + else if ($c == "i") { $this->do_dashes = 3; } + else if ($c == "e") { $this->do_ellipses = 1; } + else if ($c == "w") { $this->convert_quot = 1; } + else { + # Unknown attribute option, ignore. + } + } + } + } + + public function transform($text) { + + if ($this->do_nothing) { + return $text; + } + + $tokens = $this->tokenizeHTML($text); + $result = ''; + $in_pre = 0; # Keep track of when we're inside
     or  tags.
    +
    +		$prev_token_last_char = ""; # This is a cheat, used to get some context
    +									# for one-character tokens that consist of 
    +									# just a quote char. What we do is remember
    +									# the last character of the previous text
    +									# token, to use as context to curl single-
    +									# character quote tokens correctly.
    +
    +		foreach ($tokens as $cur_token) {
    +			if ($cur_token[0] == "tag") {
    +				# Don't mess with quotes inside tags.
    +				$result .= $cur_token[1];
    +				if (preg_match('@<(/?)(?:'.$this->tags_to_skip.')[\s>]@', $cur_token[1], $matches)) {
    +					$in_pre = isset($matches[1]) && $matches[1] == '/' ? 0 : 1;
    +				}
    +			} else {
    +				$t = $cur_token[1];
    +				$last_char = substr($t, -1); # Remember last char of this token before processing.
    +				if (! $in_pre) {
    +					$t = $this->educate($t, $prev_token_last_char);
    +				}
    +				$prev_token_last_char = $last_char;
    +				$result .= $t;
    +			}
    +		}
    +
    +		return $result;
    +	}
    +
    +
    +	function decodeEntitiesInConfiguration() {
    +	#
    +	#   Utility function that converts entities in configuration variables to
    +	#   UTF-8 characters.
    +	#
    +		$output_config_vars = array(
    +			'smart_doublequote_open',
    +			'smart_doublequote_close',
    +			'smart_singlequote_open',
    +			'smart_singlequote_close',
    +			'backtick_doublequote_open',
    +			'backtick_doublequote_close',
    +			'backtick_singlequote_open',
    +			'backtick_singlequote_close',
    +			'em_dash',
    +			'en_dash',
    +			'ellipsis',
    +		);
    +		foreach ($output_config_vars as $var) {
    +			$this->$var = html_entity_decode($this->$var);
    +		}
    +	}
    +
    +
    +	protected function educate($t, $prev_token_last_char) {
    +		$t = $this->processEscapes($t);
    +
    +		if ($this->convert_quot) {
    +			$t = preg_replace('/"/', '"', $t);
    +		}
    +
    +		if ($this->do_dashes) {
    +			if ($this->do_dashes == 1) $t = $this->educateDashes($t);
    +			if ($this->do_dashes == 2) $t = $this->educateDashesOldSchool($t);
    +			if ($this->do_dashes == 3) $t = $this->educateDashesOldSchoolInverted($t);
    +		}
    +
    +		if ($this->do_ellipses) $t = $this->educateEllipses($t);
    +
    +		# Note: backticks need to be processed before quotes.
    +		if ($this->do_backticks) {
    +			$t = $this->educateBackticks($t);
    +			if ($this->do_backticks == 2) $t = $this->educateSingleBackticks($t);
    +		}
    +
    +		if ($this->do_quotes) {
    +			if ($t == "'") {
    +				# Special case: single-character ' token
    +				if (preg_match('/\S/', $prev_token_last_char)) {
    +					$t = $this->smart_singlequote_close;
    +				}
    +				else {
    +					$t = $this->smart_singlequote_open;
    +				}
    +			}
    +			else if ($t == '"') {
    +				# Special case: single-character " token
    +				if (preg_match('/\S/', $prev_token_last_char)) {
    +					$t = $this->smart_doublequote_close;
    +				}
    +				else {
    +					$t = $this->smart_doublequote_open;
    +				}
    +			}
    +			else {
    +				# Normal case:
    +				$t = $this->educateQuotes($t);
    +			}
    +		}
    +
    +		if ($this->do_stupefy) $t = $this->stupefyEntities($t);
    +		
    +		return $t;
    +	}
    +
    +
    +	protected function educateQuotes($_) {
    +	#
    +	#   Parameter:  String.
    +	#
    +	#   Returns:    The string, with "educated" curly quote HTML entities.
    +	#
    +	#   Example input:  "Isn't this fun?"
    +	#   Example output: “Isn’t this fun?”
    +	#
    +		$dq_open  = $this->smart_doublequote_open;
    +		$dq_close = $this->smart_doublequote_close;
    +		$sq_open  = $this->smart_singlequote_open;
    +		$sq_close = $this->smart_singlequote_close;
    +	
    +		# Make our own "punctuation" character class, because the POSIX-style
    +		# [:PUNCT:] is only available in Perl 5.6 or later:
    +		$punct_class = "[!\"#\\$\\%'()*+,-.\\/:;<=>?\\@\\[\\\\\]\\^_`{|}~]";
    +
    +		# Special case if the very first character is a quote
    +		# followed by punctuation at a non-word-break. Close the quotes by brute force:
    +		$_ = preg_replace(
    +			array("/^'(?=$punct_class\\B)/", "/^\"(?=$punct_class\\B)/"),
    +			array($sq_close,                 $dq_close), $_);
    +
    +		# Special case for double sets of quotes, e.g.:
    +		#   

    He said, "'Quoted' words in a larger quote."

    + $_ = preg_replace( + array("/\"'(?=\w)/", "/'\"(?=\w)/"), + array($dq_open.$sq_open, $sq_open.$dq_open), $_); + + # Special case for decade abbreviations (the '80s): + $_ = preg_replace("/'(?=\\d{2}s)/", $sq_close, $_); + + $close_class = '[^\ \t\r\n\[\{\(\-]'; + $dec_dashes = '&\#8211;|&\#8212;'; + + # Get most opening single quotes: + $_ = preg_replace("{ + ( + \\s | # a whitespace char, or +   | # a non-breaking space entity, or + -- | # dashes, or + &[mn]dash; | # named dash entities + $dec_dashes | # or decimal entities + &\\#x201[34]; # or hex + ) + ' # the quote + (?=\\w) # followed by a word character + }x", '\1'.$sq_open, $_); + # Single closing quotes: + $_ = preg_replace("{ + ($close_class)? + ' + (?(1)| # If $1 captured, then do nothing; + (?=\\s | s\\b) # otherwise, positive lookahead for a whitespace + ) # char or an 's' at a word ending position. This + # is a special case to handle something like: + # \"Custer's Last Stand.\" + }xi", '\1'.$sq_close, $_); + + # Any remaining single quotes should be opening ones: + $_ = str_replace("'", $sq_open, $_); + + + # Get most opening double quotes: + $_ = preg_replace("{ + ( + \\s | # a whitespace char, or +   | # a non-breaking space entity, or + -- | # dashes, or + &[mn]dash; | # named dash entities + $dec_dashes | # or decimal entities + &\\#x201[34]; # or hex + ) + \" # the quote + (?=\\w) # followed by a word character + }x", '\1'.$dq_open, $_); + + # Double closing quotes: + $_ = preg_replace("{ + ($close_class)? + \" + (?(1)|(?=\\s)) # If $1 captured, then do nothing; + # if not, then make sure the next char is whitespace. + }x", '\1'.$dq_close, $_); + + # Any remaining quotes should be opening ones. + $_ = str_replace('"', $dq_open, $_); + + return $_; + } + + + protected function educateBackticks($_) { + # + # Parameter: String. + # Returns: The string, with ``backticks'' -style double quotes + # translated into HTML curly quote entities. + # + # Example input: ``Isn't this fun?'' + # Example output: “Isn't this fun?” + # + + $_ = str_replace(array("``", "''",), + array($this->backtick_doublequote_open, + $this->backtick_doublequote_close), $_); + return $_; + } + + + protected function educateSingleBackticks($_) { + # + # Parameter: String. + # Returns: The string, with `backticks' -style single quotes + # translated into HTML curly quote entities. + # + # Example input: `Isn't this fun?' + # Example output: ‘Isn’t this fun?’ + # + + $_ = str_replace(array("`", "'",), + array($this->backtick_singlequote_open, + $this->backtick_singlequote_close), $_); + return $_; + } + + + protected function educateDashes($_) { + # + # Parameter: String. + # + # Returns: The string, with each instance of "--" translated to + # an em-dash HTML entity. + # + + $_ = str_replace('--', $this->em_dash, $_); + return $_; + } + + + protected function educateDashesOldSchool($_) { + # + # Parameter: String. + # + # Returns: The string, with each instance of "--" translated to + # an en-dash HTML entity, and each "---" translated to + # an em-dash HTML entity. + # + + # em en + $_ = str_replace(array("---", "--",), + array($this->em_dash, $this->en_dash), $_); + return $_; + } + + + protected function educateDashesOldSchoolInverted($_) { + # + # Parameter: String. + # + # Returns: The string, with each instance of "--" translated to + # an em-dash HTML entity, and each "---" translated to + # an en-dash HTML entity. Two reasons why: First, unlike the + # en- and em-dash syntax supported by + # EducateDashesOldSchool(), it's compatible with existing + # entries written before SmartyPants 1.1, back when "--" was + # only used for em-dashes. Second, em-dashes are more + # common than en-dashes, and so it sort of makes sense that + # the shortcut should be shorter to type. (Thanks to Aaron + # Swartz for the idea.) + # + + # en em + $_ = str_replace(array("---", "--",), + array($this->en_dash, $this->em_dash), $_); + return $_; + } + + + protected function educateEllipses($_) { + # + # Parameter: String. + # Returns: The string, with each instance of "..." translated to + # an ellipsis HTML entity. Also converts the case where + # there are spaces between the dots. + # + # Example input: Huh...? + # Example output: Huh…? + # + + $_ = str_replace(array("...", ". . .",), $this->ellipsis, $_); + return $_; + } + + + protected function stupefyEntities($_) { + # + # Parameter: String. + # Returns: The string, with each SmartyPants HTML entity translated to + # its ASCII counterpart. + # + # Example input: “Hello — world.” + # Example output: "Hello -- world." + # + + # en-dash em-dash + $_ = str_replace(array('–', '—'), + array('-', '--'), $_); + + # single quote open close + $_ = str_replace(array('‘', '’'), "'", $_); + + # double quote open close + $_ = str_replace(array('“', '”'), '"', $_); + + $_ = str_replace('…', '...', $_); # ellipsis + + return $_; + } + + + protected function processEscapes($_) { + # + # Parameter: String. + # Returns: The string, with after processing the following backslash + # escape sequences. This is useful if you want to force a "dumb" + # quote or other character to appear. + # + # Escape Value + # ------ ----- + # \\ \ + # \" " + # \' ' + # \. . + # \- - + # \` ` + # + $_ = str_replace( + array('\\\\', '\"', "\'", '\.', '\-', '\`'), + array('\', '"', ''', '.', '-', '`'), $_); + + return $_; + } + + + protected function tokenizeHTML($str) { + # + # Parameter: String containing HTML markup. + # Returns: An array of the tokens comprising the input + # string. Each token is either a tag (possibly with nested, + # tags contained therein, such as , or a + # run of text between tags. Each element of the array is a + # two-element array; the first is either 'tag' or 'text'; + # the second is the actual value. + # + # + # Regular expression derived from the _tokenize() subroutine in + # Brad Choate's MTRegex plugin. + # + # + $index = 0; + $tokens = array(); + + $match = '(?s:)|'. # comment + '(?s:<\?.*?\?>)|'. # processing instruction + # regular tags + '(?:<[/!$]?[-a-zA-Z0-9:]+\b(?>[^"\'>]+|"[^"]*"|\'[^\']*\')*>)'; + + $parts = preg_split("{($match)}", $str, -1, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($parts as $part) { + if (++$index % 2 && $part != '') + $tokens[] = array('text', $part); + else + $tokens[] = array('tag', $part); + } + return $tokens; + } + +} diff --git a/public/vendor/michelf/php-smartypants/Michelf/SmartyPantsTypographer.inc.php b/public/vendor/michelf/php-smartypants/Michelf/SmartyPantsTypographer.inc.php new file mode 100644 index 0000000..9b3d274 --- /dev/null +++ b/public/vendor/michelf/php-smartypants/Michelf/SmartyPantsTypographer.inc.php @@ -0,0 +1,10 @@ + +# +# Original SmartyPants +# Copyright (c) 2003-2004 John Gruber +# +# +namespace Michelf; + + +# +# SmartyPants Typographer Parser Class +# +class SmartyPantsTypographer extends \Michelf\SmartyPants { + + ### Configuration Variables ### + + # Options to specify which transformations to make: + public $do_comma_quotes = 0; + public $do_guillemets = 0; + public $do_geresh_gershayim = 0; + public $do_space_emdash = 0; + public $do_space_endash = 0; + public $do_space_colon = 0; + public $do_space_semicolon = 0; + public $do_space_marks = 0; + public $do_space_frenchquote = 0; + public $do_space_thousand = 0; + public $do_space_unit = 0; + + # Quote characters for replacing ASCII approximations + public $doublequote_low = "„"; // replacement for ,, + public $guillemet_leftpointing = "«"; // replacement for << + public $guillemet_rightpointing = "»"; // replacement for >> + public $geresh = "׳"; + public $gershayim = "״"; + + # Space characters for different places: + # Space around em-dashes. "He_—_or she_—_should change that." + public $space_emdash = " "; + # Space around en-dashes. "He_–_or she_–_should change that." + public $space_endash = " "; + # Space before a colon. "He said_: here it is." + public $space_colon = " "; + # Space before a semicolon. "That's what I said_; that's what he said." + public $space_semicolon = " "; + # Space before a question mark and an exclamation mark: "¡_Holà_! What_?" + public $space_marks = " "; + # Space inside french quotes. "Voici la «_chose_» qui m'a attaqué." + public $space_frenchquote = " "; + # Space as thousand separator. "On compte 10_000 maisons sur cette liste." + public $space_thousand = " "; + # Space before a unit abreviation. "This 12_kg of matter costs 10_$." + public $space_unit = " "; + + + # Expression of a space (breakable or not): + public $space = '(?: | | |�*160;|�*[aA]0;)'; + + + ### Parser Implementation ### + + public function __construct($attr = SmartyPants::ATTR_DEFAULT) { + # + # Initialize a SmartyPantsTypographer_Parser with certain attributes. + # + # Parser attributes: + # 0 : do nothing + # 1 : set all, except dash spacing + # 2 : set all, except dash spacing, using old school en- and em- dash shortcuts + # 3 : set all, except dash spacing, using inverted old school en and em- dash shortcuts + # + # Punctuation: + # q -> quotes + # b -> backtick quotes (``double'' only) + # B -> backtick quotes (``double'' and `single') + # c -> comma quotes (,,double`` only) + # g -> guillemets (<> only) + # d -> dashes + # D -> old school dashes + # i -> inverted old school dashes + # e -> ellipses + # w -> convert " entities to " for Dreamweaver users + # + # Spacing: + # : -> colon spacing +- + # ; -> semicolon spacing +- + # m -> question and exclamation marks spacing +- + # h -> em-dash spacing +- + # H -> en-dash spacing +- + # f -> french quote spacing +- + # t -> thousand separator spacing - + # u -> unit spacing +- + # (you can add a plus sign after some of these options denoted by + to + # add the space when it is not already present, or you can add a minus + # sign to completly remove any space present) + # + # Initialize inherited SmartyPants parser. + parent::__construct($attr); + + if ($attr == "1" || $attr == "2" || $attr == "3") { + # Do everything, turn all options on. + $this->do_comma_quotes = 1; + $this->do_guillemets = 1; + $this->do_geresh_gershayim = 1; + $this->do_space_emdash = 1; + $this->do_space_endash = 1; + $this->do_space_colon = 1; + $this->do_space_semicolon = 1; + $this->do_space_marks = 1; + $this->do_space_frenchquote = 1; + $this->do_space_thousand = 1; + $this->do_space_unit = 1; + } + else if ($attr == "-1") { + # Special "stupefy" mode. + $this->do_stupefy = 1; + } + else { + $chars = preg_split('//', $attr); + foreach ($chars as $c){ + if ($c == "c") { $current =& $this->do_comma_quotes; } + else if ($c == "g") { $current =& $this->do_guillemets; } + else if ($c == "G") { $current =& $this->do_geresh_gershayim; } + else if ($c == ":") { $current =& $this->do_space_colon; } + else if ($c == ";") { $current =& $this->do_space_semicolon; } + else if ($c == "m") { $current =& $this->do_space_marks; } + else if ($c == "h") { $current =& $this->do_space_emdash; } + else if ($c == "H") { $current =& $this->do_space_endash; } + else if ($c == "f") { $current =& $this->do_space_frenchquote; } + else if ($c == "t") { $current =& $this->do_space_thousand; } + else if ($c == "u") { $current =& $this->do_space_unit; } + else if ($c == "+") { + $current = 2; + unset($current); + } + else if ($c == "-") { + $current = -1; + unset($current); + } + else { + # Unknown attribute option, ignore. + } + $current = 1; + } + } + } + + + function decodeEntitiesInConfiguration() { + parent::decodeEntitiesInConfiguration(); + $output_config_vars = array( + 'doublequote_low', + 'guillemet_leftpointing', + 'guillemet_rightpointing', + 'space_emdash', + 'space_endash', + 'space_colon', + 'space_semicolon', + 'space_marks', + 'space_frenchquote', + 'space_thousand', + 'space_unit', + ); + foreach ($output_config_vars as $var) { + $this->$var = html_entity_decode($this->$var); + } + } + + + function educate($t, $prev_token_last_char) { + # must happen before regular smart quotes + if ($this->do_geresh_gershayim) $t = $this->educateGereshGershayim($t); + + $t = parent::educate($t, $prev_token_last_char); + + if ($this->do_comma_quotes) $t = $this->educateCommaQuotes($t); + if ($this->do_guillemets) $t = $this->educateGuillemets($t); + + if ($this->do_space_emdash) $t = $this->spaceEmDash($t); + if ($this->do_space_endash) $t = $this->spaceEnDash($t); + if ($this->do_space_colon) $t = $this->spaceColon($t); + if ($this->do_space_semicolon) $t = $this->spaceSemicolon($t); + if ($this->do_space_marks) $t = $this->spaceMarks($t); + if ($this->do_space_frenchquote) $t = $this->spaceFrenchQuotes($t); + if ($this->do_space_thousand) $t = $this->spaceThousandSeparator($t); + if ($this->do_space_unit) $t = $this->spaceUnit($t); + + return $t; + } + + + protected function educateCommaQuotes($_) { + # + # Parameter: String. + # Returns: The string, with ,,comma,, -style double quotes + # translated into HTML curly quote entities. + # + # Example input: ,,Isn't this fun?,, + # Example output: „Isn't this fun?„ + # + # Note: this is meant to be used alongside with backtick quotes; there is + # no language that use only lower quotations alone mark like in the example. + # + $_ = str_replace(",,", $this->doublequote_low, $_); + return $_; + } + + + protected function educateGuillemets($_) { + # + # Parameter: String. + # Returns: The string, with << guillemets >> -style quotes + # translated into HTML guillemets entities. + # + # Example input: << Isn't this fun? >> + # Example output: „ Isn't this fun? „ + # + $_ = preg_replace("/(?:<|<){2}/", $this->guillemet_leftpointing, $_); + $_ = preg_replace("/(?:>|>){2}/", $this->guillemet_rightpointing, $_); + return $_; + } + + + protected function educateGereshGershayim($_) { + # + # Parameter: String, UTF-8 encoded. + # Returns: The string, where simple a or double quote surrounded by + # two hebrew characters is replaced into a typographic + # geresh or gershayim punctuation mark. + # + # Example input: צה"ל / צ'ארלס + # Example output: צה״ל / צ׳ארלס + # + // surrounding code points can be U+0590 to U+05BF and U+05D0 to U+05F2 + // encoded in UTF-8: D6.90 to D6.BF and D7.90 to D7.B2 + $_ = preg_replace('/(?<=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])\'(?=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])/', $this->geresh, $_); + $_ = preg_replace('/(?<=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])"(?=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])/', $this->gershayim, $_); + return $_; + } + + + protected function spaceFrenchQuotes($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # inside french-style quotes, only french quotes. + # + # Example input: Quotes in « French », »German« and »Finnish» style. + # Example output: Quotes in «_French_», »German« and »Finnish» style. + # + $opt = ( $this->do_space_frenchquote == 2 ? '?' : '' ); + $chr = ( $this->do_space_frenchquote != -1 ? $this->space_frenchquote : '' ); + + # Characters allowed immediatly outside quotes. + $outside_char = $this->space . '|\s|[.,:;!?\[\](){}|@*~=+-]|¡|¿'; + + $_ = preg_replace( + "/(^|$outside_char)(«|«|›|‹)$this->space$opt/", + "\\1\\2$chr", $_); + $_ = preg_replace( + "/$this->space$opt(»|»|‹|›)($outside_char|$)/", + "$chr\\1\\2", $_); + return $_; + } + + + protected function spaceColon($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # before colons. + # + # Example input: Ingredients : fun. + # Example output: Ingredients_: fun. + # + $opt = ( $this->do_space_colon == 2 ? '?' : '' ); + $chr = ( $this->do_space_colon != -1 ? $this->space_colon : '' ); + + $_ = preg_replace("/$this->space$opt(:)(\\s|$)/m", + "$chr\\1\\2", $_); + return $_; + } + + + protected function spaceSemicolon($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # before semicolons. + # + # Example input: There he goes ; there she goes. + # Example output: There he goes_; there she goes. + # + $opt = ( $this->do_space_semicolon == 2 ? '?' : '' ); + $chr = ( $this->do_space_semicolon != -1 ? $this->space_semicolon : '' ); + + $_ = preg_replace("/$this->space(;)(?=\\s|$)/m", + " \\1", $_); + $_ = preg_replace("/((?:^|\\s)(?>[^&;\\s]+|&#?[a-zA-Z0-9]+;)*)". + " $opt(;)(?=\\s|$)/m", + "\\1$chr\\2", $_); + return $_; + } + + + protected function spaceMarks($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # around question and exclamation marks. + # + # Example input: ¡ Holà ! What ? + # Example output: ¡_Holà_! What_? + # + $opt = ( $this->do_space_marks == 2 ? '?' : '' ); + $chr = ( $this->do_space_marks != -1 ? $this->space_marks : '' ); + + // Regular marks. + $_ = preg_replace("/$this->space$opt([?!]+)/", "$chr\\1", $_); + + // Inverted marks. + $imarks = "(?:¡|¡|¡|&#x[Aa]1;|¿|¿|¿|&#x[Bb][Ff];)"; + $_ = preg_replace("/($imarks+)$this->space$opt/", "\\1$chr", $_); + + return $_; + } + + + protected function spaceEmDash($_) { + # + # Parameters: String, two replacement characters separated by a hyphen (`-`), + # and forcing flag. + # + # Returns: The string, with appropriates spaces replaced + # around dashes. + # + # Example input: Then — without any plan — the fun happend. + # Example output: Then_—_without any plan_—_the fun happend. + # + $opt = ( $this->do_space_emdash == 2 ? '?' : '' ); + $chr = ( $this->do_space_emdash != -1 ? $this->space_emdash : '' ); + $_ = preg_replace("/$this->space$opt(—|—)$this->space$opt/", + "$chr\\1$chr", $_); + return $_; + } + + + protected function spaceEnDash($_) { + # + # Parameters: String, two replacement characters separated by a hyphen (`-`), + # and forcing flag. + # + # Returns: The string, with appropriates spaces replaced + # around dashes. + # + # Example input: Then — without any plan — the fun happend. + # Example output: Then_—_without any plan_—_the fun happend. + # + $opt = ( $this->do_space_endash == 2 ? '?' : '' ); + $chr = ( $this->do_space_endash != -1 ? $this->space_endash : '' ); + $_ = preg_replace("/$this->space$opt(–|–)$this->space$opt/", + "$chr\\1$chr", $_); + return $_; + } + + + protected function spaceThousandSeparator($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # inside numbers (thousand separator in french). + # + # Example input: Il y a 10 000 insectes amusants dans ton jardin. + # Example output: Il y a 10_000 insectes amusants dans ton jardin. + # + $chr = ( $this->do_space_thousand != -1 ? $this->space_thousand : '' ); + $_ = preg_replace('/([0-9]) ([0-9])/', "\\1$chr\\2", $_); + return $_; + } + + + protected $units = ' + ### Metric units (with prefixes) + (?: + p | + µ | µ | &\#0*181; | &\#[xX]0*[Bb]5; | + [mcdhkMGT] + )? + (?: + [mgstAKNJWCVFSTHBL]|mol|cd|rad|Hz|Pa|Wb|lm|lx|Bq|Gy|Sv|kat| + Ω | Ohm | Ω | &\#0*937; | &\#[xX]0*3[Aa]9; + )| + ### Computers units (KB, Kb, TB, Kbps) + [kKMGT]?(?:[oBb]|[oBb]ps|flops)| + ### Money + ¢ | ¢ | &\#0*162; | &\#[xX]0*[Aa]2; | + M?(?: + £ | £ | &\#0*163; | &\#[xX]0*[Aa]3; | + ¥ | ¥ | &\#0*165; | &\#[xX]0*[Aa]5; | + € | € | &\#0*8364; | &\#[xX]0*20[Aa][Cc]; | + $ + )| + ### Other units + (?: ° | ° | &\#0*176; | &\#[xX]0*[Bb]0; ) [CF]? | + %|pt|pi|M?px|em|en|gal|lb|[NSEOW]|[NS][EOW]|ha|mbar + '; //x + + protected function spaceUnit($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # before unit symbols. + # + # Example input: Get 3 mol of fun for 3 $. + # Example output: Get 3_mol of fun for 3_$. + # + $opt = ( $this->do_space_unit == 2 ? '?' : '' ); + $chr = ( $this->do_space_unit != -1 ? $this->space_unit : '' ); + + $_ = preg_replace('/ + (?:([0-9])[ ]'.$opt.') # Number followed by space. + ('.$this->units.') # Unit. + (?![a-zA-Z0-9]) # Negative lookahead for other unit characters. + /x', + "\\1$chr\\2", $_); + + return $_; + } + + + protected function spaceAbbr($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # around abbreviations. + # + # Example input: Fun i.e. something pleasant. + # Example output: Fun i.e._something pleasant. + # + $opt = ( $this->do_space_abbr == 2 ? '?' : '' ); + + $_ = preg_replace("/(^|\s)($this->abbr_after) $opt/m", + "\\1\\2$this->space_abbr", $_); + $_ = preg_replace("/( )$opt($this->abbr_sp_before)(?![a-zA-Z'])/m", + "\\1$this->space_abbr\\2", $_); + return $_; + } + + + protected function stupefyEntities($_) { + # + # Adding angle quotes and lower quotes to SmartyPants's stupefy mode. + # + $_ = parent::stupefyEntities($_); + + $_ = str_replace(array('„', '«', '»'), '"', $_); + + return $_; + } + + + protected function processEscapes($_) { + # + # Adding a few more escapes to SmartyPants's escapes: + # + # Escape Value + # ------ ----- + # \, , + # \< < + # \> > + # + $_ = parent::processEscapes($_); + + $_ = str_replace( + array('\,', '\<', '\>', '\<', '\>'), + array(',', '<', '>', '<', '>'), $_); + + return $_; + } +} diff --git a/public/vendor/michelf/php-smartypants/Readme.md b/public/vendor/michelf/php-smartypants/Readme.md new file mode 100644 index 0000000..5d67695 --- /dev/null +++ b/public/vendor/michelf/php-smartypants/Readme.md @@ -0,0 +1,246 @@ +PHP SmartyPants +=============== + +PHP SmartyPants Lib 1.8.1 - 12 Dec 2016 + +by Michel Fortin + + +Original SmartyPants by John Gruber + + + +Introduction +------------ + +This is a library package that includes the PHP SmartyPants and its +sibling PHP SmartyPants Typographer with additional features. + +SmartyPants is a free web typography prettifyier tool for web writers. It +easily translates plain ASCII punctuation characters into "smart" typographic +punctuation HTML entities. + +PHP SmartyPants is a port to PHP of the original SmartyPants written +in Perl by John Gruber. + +SmartyPants can perform the following transformations: + +* Straight quotes (`"` and `'`) into “curly” quote HTML entities +* Backtick-style quotes (` ``like this'' `) into “curly” quote HTML + entities +* Dashes (`--` and `---`) into en- and em-dash entities +* Three consecutive dots (`...`) into an ellipsis entity + +SmartyPants Typographer can perform additional transformations: + +* French guillemets done using (`<<` and `>>`) into true « guillemets » + HTML entities. +* Comma-style quotes (` ,,like this`` ` or ` ''like this,, `) into their + curly equivalent. +* Replace existing spaces with non-break spaces around punctuation marks + where appropriate, can also add or remove them if configured to. +* Replace existing spaces with non-break spaces for spaces used as + a thousand separator and between a number and the unit symbol that + follows it (for most common units). + +This means you can write, edit, and save using plain old ASCII straight +quotes, plain dashes, and plain dots, but your published posts (and +final HTML output) will appear with smart quotes, em-dashes, proper +ellipses, and proper no-break spaces (with Typographer). + +SmartyPants does not modify characters within `
    `, ``,
    +``, or `
    +
    +
    +
    +
    diff --git a/src/assets/editor-ui.css b/src/assets/editor-ui.css
    new file mode 100644
    index 0000000..e69de29
    diff --git a/src/assets/pagedjs-interface.css b/src/assets/pagedjs-interface.css
    new file mode 100644
    index 0000000..bcef24a
    --- /dev/null
    +++ b/src/assets/pagedjs-interface.css
    @@ -0,0 +1,171 @@
    +/* CSS for Paged.js interface – v0.4 */
    +
    +/* Change the look */
    +:root {
    +  --color-background: whitesmoke;
    +  --color-pageSheet: #cfcfcf;
    +  --color-pageBox: violet;
    +  --color-paper: white;
    +  --color-marginBox: transparent;
    +  --pagedjs-crop-color: black;
    +  --pagedjs-crop-shadow: white;
    +  --pagedjs-crop-stroke: 1px;
    +}
    +
    +/* To define how the book look on the screen: */
    +@media screen, pagedjs-ignore {
    +  body {
    +    background-color: var(--color-background);
    +  }
    +
    +  .pagedjs_pages {
    +    display: flex;
    +    width: calc(var(--pagedjs-width) * 2);
    +    flex: 0;
    +    flex-wrap: wrap;
    +    margin: 0 auto;
    +  }
    +
    +  .pagedjs_page {
    +    background-color: var(--color-paper);
    +    box-shadow: 0 0 0 1px var(--color-pageSheet);
    +    margin: 0;
    +    flex-shrink: 0;
    +    flex-grow: 0;
    +    margin-top: 10mm;
    +  }
    +
    +  .pagedjs_first_page {
    +    margin-left: var(--pagedjs-width);
    +  }
    +
    +  .pagedjs_page:last-of-type {
    +    margin-bottom: 10mm;
    +  }
    +
    +  .pagedjs_pagebox {
    +    box-shadow: 0 0 0 1px var(--color-pageBox);
    +  }
    +
    +  .pagedjs_left_page {
    +    z-index: 20;
    +    width: calc(
    +      var(--pagedjs-bleed-left) + var(--pagedjs-pagebox-width)
    +    ) !important;
    +  }
    +
    +  .pagedjs_left_page .pagedjs_bleed-right .pagedjs_marks-crop {
    +    border-color: transparent;
    +  }
    +
    +  .pagedjs_left_page .pagedjs_bleed-right .pagedjs_marks-middle {
    +    width: 0;
    +  }
    +
    +  .pagedjs_right_page {
    +    z-index: 10;
    +    position: relative;
    +    left: calc(var(--pagedjs-bleed-left) * -1);
    +  }
    +
    +  /* show the margin-box */
    +
    +  .pagedjs_margin-top-left-corner-holder,
    +  .pagedjs_margin-top,
    +  .pagedjs_margin-top-left,
    +  .pagedjs_margin-top-center,
    +  .pagedjs_margin-top-right,
    +  .pagedjs_margin-top-right-corner-holder,
    +  .pagedjs_margin-bottom-left-corner-holder,
    +  .pagedjs_margin-bottom,
    +  .pagedjs_margin-bottom-left,
    +  .pagedjs_margin-bottom-center,
    +  .pagedjs_margin-bottom-right,
    +  .pagedjs_margin-bottom-right-corner-holder,
    +  .pagedjs_margin-right,
    +  .pagedjs_margin-right-top,
    +  .pagedjs_margin-right-middle,
    +  .pagedjs_margin-right-bottom,
    +  .pagedjs_margin-left,
    +  .pagedjs_margin-left-top,
    +  .pagedjs_margin-left-middle,
    +  .pagedjs_margin-left-bottom {
    +    box-shadow: 0 0 0 1px inset var(--color-marginBox);
    +  }
    +
    +  /* uncomment this part for recto/verso book : ------------------------------------ */
    +
    +  /*
    +  .pagedjs_pages {
    +      flex-direction: column;
    +      width: 100%;
    +  }
    +
    +  .pagedjs_first_page {
    +      margin-left: 0;
    +  }
    +
    +  .pagedjs_page {
    +      margin: 0 auto;
    +      margin-top: 10mm;
    +  } 
    +
    +  .pagedjs_left_page{
    +      width: calc(var(--pagedjs-bleed-left) + var(--pagedjs-pagebox-width) + var(--pagedjs-bleed-left))!important;
    +  }
    +
    +  .pagedjs_left_page .pagedjs_bleed-right .pagedjs_marks-crop{
    +      border-color: var(--pagedjs-crop-color);
    +  }
    +
    +  .pagedjs_left_page .pagedjs_bleed-right .pagedjs_marks-middle{
    +      width: var(--pagedjs-cross-size)!important;
    +  } 
    +
    +  .pagedjs_right_page{
    +      left: 0; 
    +  } 
    +  */
    +
    +  /*--------------------------------------------------------------------------------------*/
    +
    +  /* uncomment this par to see the baseline : -------------------------------------------*/
    +
    +  /* .pagedjs_pagebox {
    +      --pagedjs-baseline: 22px;
    +      --pagedjs-baseline-position: 5px;
    +      --pagedjs-baseline-color: cyan;
    +      background: linear-gradient(transparent 0%, transparent calc(var(--pagedjs-baseline) - 1px), var(--pagedjs-baseline-color) calc(var(--pagedjs-baseline) - 1px), var(--pagedjs-baseline-color) var(--pagedjs-baseline)), transparent;
    +      background-size: 100% var(--pagedjs-baseline);
    +      background-repeat: repeat-y;
    +      background-position-y: var(--pagedjs-baseline-position);
    +  }  */
    +
    +  /*--------------------------------------------------------------------------------------*/
    +}
    +
    +/* Marks (to delete when merge in paged.js) */
    +
    +.pagedjs_marks-crop {
    +  z-index: 999999999999;
    +}
    +
    +.pagedjs_bleed-top .pagedjs_marks-crop,
    +.pagedjs_bleed-bottom .pagedjs_marks-crop {
    +  box-shadow: 1px 0px 0px 0px var(--pagedjs-crop-shadow);
    +}
    +
    +.pagedjs_bleed-top .pagedjs_marks-crop:last-child,
    +.pagedjs_bleed-bottom .pagedjs_marks-crop:last-child {
    +  box-shadow: -1px 0px 0px 0px var(--pagedjs-crop-shadow);
    +}
    +
    +.pagedjs_bleed-left .pagedjs_marks-crop,
    +.pagedjs_bleed-right .pagedjs_marks-crop {
    +  box-shadow: 0px 1px 0px 0px var(--pagedjs-crop-shadow);
    +}
    +
    +.pagedjs_bleed-left .pagedjs_marks-crop:last-child,
    +.pagedjs_bleed-right .pagedjs_marks-crop:last-child {
    +  box-shadow: 0px -1px 0px 0px var(--pagedjs-crop-shadow);
    +}
    diff --git a/src/components/PagedJsWrapper.vue b/src/components/PagedJsWrapper.vue
    new file mode 100644
    index 0000000..8e7c81a
    --- /dev/null
    +++ b/src/components/PagedJsWrapper.vue
    @@ -0,0 +1,29 @@
    +
    +
    +
    +
    +
    diff --git a/src/main.js b/src/main.js
    new file mode 100644
    index 0000000..2425c0f
    --- /dev/null
    +++ b/src/main.js
    @@ -0,0 +1,5 @@
    +import { createApp } from 'vue'
    +import './style.css'
    +import App from './App.vue'
    +
    +createApp(App).mount('#app')
    diff --git a/src/style.css b/src/style.css
    new file mode 100644
    index 0000000..cbac745
    --- /dev/null
    +++ b/src/style.css
    @@ -0,0 +1,2 @@
    +@import url('./assets/pagedjs-interface.css');
    +@import url('./assets/editor-ui.css');
    diff --git a/vite.config.js b/vite.config.js
    new file mode 100644
    index 0000000..bbcf80c
    --- /dev/null
    +++ b/vite.config.js
    @@ -0,0 +1,7 @@
    +import { defineConfig } from 'vite'
    +import vue from '@vitejs/plugin-vue'
    +
    +// https://vite.dev/config/
    +export default defineConfig({
    +  plugins: [vue()],
    +})